Compare commits

..
37 Commits
Author SHA1 Message Date
Ryan Ward 3fa7f2f00c Prevent idle event waits from stalling user follow-ups 2026-11-06 21:25:17 +00:00
Ryan Ward 38f1dcdef6 Harden provider diagnostics and tool failure handling 2026-11-06 19:49:39 +00:00
Ryan Ward 4b968f34f8 Add app-wide AI activity inspector 2026-11-04 22:18:31 +00:00
Ryan Ward a5eda544c4 Keep zsh profile functions in background jobs
Load zshrc and evaluate the submitted command in the same noninteractive worker so functions, aliases and unexported shell state survive. Preserve private overrides, working directory, stdin restrictions and command exit status.
2026-11-03 18:34:26 +00:00
Ryan Ward 62df16c605 Reduce repeated request context and diagnostic formatting work
Deduplicate exact shared context without dropping distinct instructions or tool results. Stop raw request diagnostic serialization at the configured prefix limit and mark truncated sizes as unknown.
2026-11-03 16:20:18 +00:00
Ryan Ward be5433177d Load profile environments for background commands
Treat terminal metadata as partial overrides, preserve the host environment, and initialize shell profiles before running the unchanged command noninteractively. Restore the approved directory and prompt guards while retaining background process ownership.
2026-11-03 16:20:13 +00:00
Ryan Ward 02af0af55f Remove obsolete upstream release workflows 2026-11-01 23:02:10 +00:00
Ryan Ward f9c1541fdb Improve background job cards and log browser 2026-10-30 05:01:05 +00:00
Ryan Ward 06c25af731 Align context usage display with provider request budgets 2026-10-30 01:49:32 +00:00
Ryan Ward f399ada2a3 Filter unsupported reasoning from Bedrock request history 2026-10-28 18:26:02 +00:00
Ryan Ward 3cce041a6d Run agent shell commands as independent background jobs 2026-10-28 17:47:42 +00:00
Ryan Ward 51782350f1 Fix skill discovery and listing with scoped skill creation 2026-10-28 16:20:07 +00:00
Ryan Ward 1647791a9e Fix macOS computer use and restored command replay 2026-10-27 21:04:03 +00:00
Ryan Ward 7ce0236825 Report installer preflight and cleanup failures 2026-10-26 07:07:45 +00:00
Ryan Ward 005aa8cdb8 Clean and verify Galaxy before local macOS installation 2026-10-26 07:01:17 +00:00
rkw6086 01f838b6eb Share install and deploy preparation with verified release bundle reuse 2026-10-25 23:15:32 -05:00
Ryan Ward b5a8393c4a Add reasoning selection to agent profile and model controls 2026-10-25 21:41:39 +00:00
Ryan Ward 2e74b00d5d Fix reasoning model identity and restored selections 2026-10-25 07:46:35 +00:00
rkw6086 6655fe7822 Merge remote-tracking branch 'ryserve/master'
# Conflicts:
#	app/src/ai/llms.rs
2026-10-25 01:32:18 -05:00
rkw6086 596b7266a4 Separate model reasoning selections and reduce agent notification noise 2026-10-25 01:31:39 -05:00
Ryan Ward 7dceca51cf Allow clarifying questions in Allow All profiles
Separate clarification questions from action approvals while preserving Galaxy Decides and Read Only behavior. Respect question capabilities in explicit tool overrides, keep CLI monitors focused, and render approved asynchronous questions while they wait for answers.

Validation: cargo build passed on Linux. Added permission, request and question-state regressions; tests and GUI execution were not run under the build-only workflow.
2026-10-22 16:12:16 +00:00
Ryan Ward f6c6434e5e Fix LiteLLM context budgets and direct computer use
Keep efficient context windows separate from expanded maxima throughout discovery, display and provider budgets. Expose correlated computer-use requests and actions with existing permissions, preserve screenshots as images, and retain them across checkpoints, compaction and explicit history recall.

Validation: cargo build on Linux passed. Added focused regression coverage; test execution and native GUI verification remain outside this CLI build-only workflow.
2026-10-22 14:25:21 +00:00
Ryan Ward d5fa711e5c Check protected-file access before macOS startup guidance
Run a conservative, read-only preflight off the UI thread before offering Full Disk Access setup. Successful or inconclusive checks stay silent; only a protected-access denial offers guidance. Honor the native reminder opt-out locally and expose manual settings access in the macOS app menu.
2026-10-21 13:42:28 +00:00
Ryan Ward 7948555bb9 Keep AI session history local and update Galaxy branding 2026-10-21 02:06:19 +00:00
Ryan Ward 7ccb0e9abc Track task progress and organize Galaxy Drive plans 2026-10-20 21:57:15 +00:00
Ryan Ward cb830e1ba7 Run progressive compaction alongside foreground provider work 2026-10-20 21:05:57 +00:00
Ryan Ward 0383166f4e Use the active model context limit for provider compaction 2026-10-20 18:27:34 +00:00
Ryan Ward 75b9334a0b Handle ChatGPT subscription metadata without aborting streams 2026-10-19 19:41:37 +00:00
Josh WoodcockandWarp c894a11589 Update ChatGPT authentication and sign-in recovery
Co-Authored-By: Warp <agent@warp.dev>
2026-10-19 12:53:11 -05:00
Josh WoodcockandWarp 3ddd8f25ac Add Bedrock model refresh and fix auto-region discovery race
Co-Authored-By: Warp <agent@warp.dev>
2026-10-19 12:51:53 -05:00
Josh Woodcock 1820561f2a remove old okta login authentication refresh 2026-10-14 13:51:35 -05:00
Josh Woodcock 04cff91509 fix bedrock authentication refresh 2026-10-14 12:43:32 -05:00
rkw6086 55eb87a983 Build only the macOS app bundle during local installation 2026-10-06 21:06:08 -05:00
rkw6086 2d723d9151 Fix release build colors and avoid unnecessary install elevation 2026-10-06 21:01:44 -05:00
rkw6086 56b357fb3a Fix orchestration provider labels and checkbox theme colors 2026-10-06 15:13:19 -05:00
rkw6086 75d4965385 Port recent Warp terminal and UI improvements
Improve tab-group search, rename focus, history navigation, shell prompt and
completion handling, emoji width promotion, and macOS clipboard support.
Guard unavailable settings/model-selector/undo-close views and forward the
MCP OAuth callback issuer through the existing authentication flow.

Adapt local changes from 14 upstream Warp commits through f571865ca3f2,
reviewed from the latest 200 commits. Preserve Galaxy provider lifecycle,
permissions, storage, and branding; exclude hosted/cloud infrastructure.

Fix existing boolean-assertion lints and use stronger optimization with four
code-generation units for the app test target to fit macOS unwind offsets.
Production and dependency compilation settings remain unchanged.

Validation: two clean builds and a final build; both prescribed Clippy
commands with -D warnings; formatting, shell syntax and completion cleanup;
167 focused regression tests. Galaxy was not launched.
2026-10-06 14:48:09 -05:00
rkw6086 d6da089862 Update Cargo dependencies 2026-10-06 14:48:09 -05:00
273 changed files with 25246 additions and 4678 deletions
File diff suppressed because it is too large Load Diff
@@ -1,41 +0,0 @@
# A workflow to cut a Galaxy OSS release candidate on the OSS release branch.
name: Cut New Release Candidate
on:
workflow_dispatch:
jobs:
verify_release_branch:
name: Verify OSS release branch
runs-on: ubuntu-latest
steps:
- name: Verify branch
run: |
case "$GITHUB_REF_NAME" in
oss_release/*) ;;
*)
echo "::error::Can only create OSS release candidates on an oss_release/* branch"
exit 1
;;
esac
shell: bash
create_release_candidate:
name: Create OSS Release Candidate
uses: ./.github/workflows/create_release.yml
needs: verify_release_branch
secrets: inherit
update_channel_versions:
name: Update channel_versions.json
needs:
- create_release_candidate
runs-on: ubuntu-latest
steps:
- name: Invoke update workflow in channel-versions repository
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
repository: warpdotdev/channel-versions
token: ${{ secrets.CHANNEL_VERSIONS_REPOSITORY_DISPATCH_TOKEN }}
event-type: update-channel-versions
-38
View File
@@ -1,38 +0,0 @@
# A workflow to cut the Galaxy OSS release.
name: Cut New Releases
on:
schedule:
# Run this job every morning at 3am EST.
- cron: "0 8 * * *"
workflow_dispatch:
jobs:
verify_master:
name: Verify master branch
runs-on: ubuntu-latest
steps:
- name: Check for master branch
run: |
[ "$GITHUB_REF" = "refs/heads/master" ] || (echo "::error::Can only cut new releases on the master branch" && exit 1)
shell: bash
create_release:
name: Create OSS Release
uses: ./.github/workflows/create_release.yml
needs: verify_master
secrets: inherit
update_channel_versions:
name: Update channel_versions.json
needs:
- create_release
runs-on: ubuntu-latest
steps:
- name: Invoke update workflow in channel-versions repository
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
repository: warpdotdev/channel-versions
token: ${{ secrets.CHANNEL_VERSIONS_REPOSITORY_DISPATCH_TOKEN }}
event-type: update-channel-versions
+39 -14
View File
@@ -5,6 +5,7 @@ This file provides guidance when working with code in this repository.
## Development Commands ## Development Commands
### Build and Run ### Build and Run
- `cargo build` - Build Galaxy and verify feature and bug-fix changes
- `cargo run` - Build and run Warp locally - `cargo run` - Build and run Warp locally
- `cargo bundle --bin warp` - Bundle the main app - `cargo bundle --bin warp` - Bundle the main app
- `make run` - Build and run `galaxy-oss` - `make run` - Build and run `galaxy-oss`
@@ -28,8 +29,9 @@ Environment variables:
- `WS_SERVER_URL` - WebSocket endpoint (default: `ws://localhost:8080/graphql/v2`) - `WS_SERVER_URL` - WebSocket endpoint (default: `ws://localhost:8080/graphql/v2`)
### Testing ### Testing
- Before launching `galaxy-oss`, check for existing app instances and close the prior development instance so only one app instance runs at a time. Distinguish the app process from its `terminal-server` helper; do not count the helper as a duplicate app. Leave the newly launched instance running for the user. - This development machine is CLI-only and has no GUI. The user performs GUI and most behavioral testing on a remote machine.
- During interactive feature and bug-fix verification, make the requested code changes first and run only `cargo run --bin galaxy-oss` for the user to verify. Leave the app running for the user; do not stop it or treat the command timeout as a failure. Do not run `cargo fmt`, `cargo check`, `cargo test`, `cargo clippy`, presubmit, or other validation commands until the user confirms the behavior. Run formatting, tests, and linting only as the final cleanup step after interactive approval. - Verify feature and bug-fix changes with `cargo build` only. Do not use `cargo run`, `make run`, or launch the app to verify results, and do not wait for interactive approval before completing the work.
- After `cargo build` succeeds and all requested work and final review are complete, commit the changes and push the current branch to both configured remotes, `origin` and `samsung`, using ordinary non-force pushes. If the build fails, report the blocker and do not push.
- `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2` - Run tests with nextest - `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2` - Run tests with nextest
- `cargo nextest run -p galaxy_completer --features v2` - Run completer tests with v2 features - `cargo nextest run -p galaxy_completer --features v2` - Run completer tests with v2 features
- `cargo test --doc` - Run doc tests - `cargo test --doc` - Run doc tests
@@ -48,6 +50,13 @@ Environment variables:
- 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. - Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation.
### Local AI Activity Inspector
- Open `/ai-activity` or **Show AI Activity** in the Command Palette to start an app-wide, in-memory recording of model requests/results, compaction, tools, permission assessments, crosscheck reviews, and background-job lifecycle events.
- Each event carries its owning conversation/session where available. Filter by activity or click a session label; click the activity to inspect wrapped, selectable details. Recording continues after closing the inspector until paused, and Clear removes retained events without affecting running work.
- Capture is off until first opened, independent of remote-logging settings, and bounded to 2,000 events / 16 MiB of retained entries and 262,144 characters per capture. Truncation and eviction are explicit. Transport headers/provider credentials are excluded; user-supplied model/tool content may appear in the local capture.
- Requests are Galaxy's provider-neutral snapshots, not exact upstream HTTP payloads. ACP exposes only the traffic Galaxy exchanges with the external agent; the external agent's internal model requests and compaction are not visible. Provider token usage is marked as reported only when supplied by the runtime.
### Remote Logging: Pulling Galaxy Logs ### Remote Logging: Pulling Galaxy Logs
When investigating Galaxy behavior, retrieve recent remote logs from When investigating Galaxy behavior, retrieve recent remote logs from
@@ -85,6 +94,12 @@ zsh -lc '
conclusions about retries or repeated discovery. conclusions about retries or repeated discovery.
- Inspect `context.app.source_commit` and `source_modified` to identify the running - Inspect `context.app.source_commit` and `source_modified` to identify the running
build. Remote events may come from another machine or an older build. build. Remote events may come from another machine or an older build.
- Terminal `provider_run_finished` events include `model_failure_diagnostics`:
the last eight sanitized model failures observed in the current process, with
an observed count and explicit truncation. A recovered run can complete with
retries; inspect this evidence even if individual retry logs were lost during
a connection interruption. This diagnostic history is never model context or
a persisted checkpoint and does not cover failures before a process restart.
- `raw_model_request` events can include prompts, history, and tool data in - `raw_model_request` events can include prompts, history, and tool data in
`context.raw_payload.payload`. Check `truncated` and the accompanying size fields `context.raw_payload.payload`. Check `truncated` and the accompanying size fields
before parsing or treating missing content as evidence. These are Galaxy request before parsing or treating missing content as evidence. These are Galaxy request
@@ -143,7 +158,7 @@ ACP: controller.rs → ResponseStream → acp_output_stream (session-owned lifec
- `convert.rs` — Bedrock request construction and prompt-caching behavior - `convert.rs` — Bedrock request construction and prompt-caching behavior
- `client.rs` — AWS SDK client construction, runtime creation, and independent background streaming calls - `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` — Checks bundled `galaxy_bedrock_model_catalog` candidates with AWS `GetFoundationModelAvailability`; agreement, authorization, entitlement, and regional availability must all pass. Cached access is never restored as authorization; per-model enablement survives successful refreshes. Sweeps run once at startup when enabled and once per explicit connection Test; settings saves, reloads, and model toggles must never trigger another sweep. Live verification belongs to `LLMPreferences` and is scoped to the exact connection configuration. - `discovery.rs` — Discovers the live inventory with AWS `ListFoundationModels` and paginated `ListInferenceProfiles`, resolving profile-only model details with `GetFoundationModel`. The bundled `galaxy_bedrock_model_catalog` is optional capability/token-budget enrichment, never an inventory allowlist. Invocation uses AWS-returned model IDs/profile ARNs rather than guessed prefixes. `GetFoundationModelAvailability` still requires agreement, authorization, entitlement, and regional availability; cached access is never restored as authorization. Saved model enablement and names survive refreshes, and failed discovery must not overwrite preferences with an empty catalog. Sweeps run once at startup when enabled and once per explicit connection Test; settings saves, credential reloads, login completion, and model toggles must never trigger another sweep. Live verification belongs to `LLMPreferences` and is scoped to the exact connection configuration. Prompt-cache preferences must never hide models; unknown cache capabilities use no caching.
- `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
@@ -172,9 +187,10 @@ base_url = "http://localhost:4000/v1"
api_key = "sk-..." api_key = "sk-..."
[[ai.providers.models]] [[ai.providers.models]]
model_id = "claude-sonnet-4-20250514[1m]" model_id = "claude-sonnet-4-20250514"
display_name = "Claude Sonnet 4 (1M)" display_name = "Claude Sonnet 4"
context_size = 1000000 context_size = 1000000
default_context_size = 200000
[[ai.providers]] [[ai.providers]]
name = "Ollama (Local)" name = "Ollama (Local)"
@@ -187,10 +203,9 @@ context_size = 128000
``` ```
**OpenAI/LiteLLM model discovery**: **OpenAI/LiteLLM model discovery**:
- Models can be auto-fetched from the `/models` endpoint via the Settings > OpenAI / LiteLLM page - Models can be auto-fetched from `/models` and enriched with LiteLLM `/model/info` via the Settings > OpenAI / LiteLLM page.
- For each model, the system probes `{model_id}[1m]` with a minimal chat completion request - Preserve the efficient/default context window separately from the provider's maximum. Ordinary requests, compaction, and context displays use the lower positive default/input/maximum budget, including any explicit profile cap; availability of a larger maximum must never expand that budget automatically.
- If the `[1m]` variant is accepted (HTTP 200 or 429), it's used with 1M context window - Retain lower budgets when merging metadata from both endpoints, apply `effective_context_window_percent` once, and preserve the maximum as metadata. Discovery must not probe or select an extended `[1m]` variant automatically.
- Otherwise, the base model ID is used with its reported context size
- Models injected via `ai.providers[]` are routed to their specific endpoint (per-model routing map) - Models injected via `ai.providers[]` are routed to their specific endpoint (per-model routing map)
- 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
@@ -209,19 +224,28 @@ Key invariants:
- 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 - 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 - 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` - Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs`
- Direct-provider computer use starts with `request_computer_use`; the computer-use profile exposes `use_computer`, including when its model falls back to the main model. Tool availability and every action remain subject to session capabilities and the existing profile permission checks.
- Computer-use screenshots must reach providers as image parts, survive checkpoints and tool-result archival, and remain correlated with their tool call on explicit history recall. Emit every result in a tool batch before its screenshot image groups, and estimate image tokens without counting encoded image bytes as text.
- 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 - 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
- Direct-provider normal and plan turns expose `update_task_list` for explicit progress updates. Keep the validated current list in durable `ProviderRun` state, inject it into each request, preserve it across compaction and restore, and reconcile restored UI updates idempotently. Finishing a response must never mark unfinished work complete. CLI monitors and completed-command assessments retain their focused tool sets.
- Completed local plans are archived intact into `Plans/Archive` after seven days only when their conversation has explicitly completed all checklist items and has no active provider run or child. Draft, unfinished, or reopened plans stay visible; resumed work restores an automatically archived plan, and manual recovery is respected. Archival never deletes plan contents.
- Session history lists conversations available in local panes or local persistence, with local status/date/search filters. Restore from memory or SQLite only; never fall back to hosted attachment or server recovery, and report unavailable local data without substituting a new conversation. Ignore stale cloud/team/source/harness/artifact filters on this screen.
- Active UI and built-in agent instructions use Galaxy branding. Preserve legacy serialized names, protocol identifiers, environment variables, actual third-party package names, and upstream service references where compatibility requires them; do not relabel an unsupported hosted service as a Galaxy feature.
- Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation - 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` 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` - `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` - 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 - Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
- Bedrock requests project reasoning history for the selected foundation model: omit unsigned response-only reasoning and signed blocks from incompatible models while preserving Claude's complete signed blocks for tool continuations. Keep durable/UI history intact, remove messages emptied by projection, and use the discovered foundation model when invocation targets an opaque inference-profile ARN.
- `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`
- Progressive summaries are prepended to provider requests as a user/assistant pair; direct-provider runs compact their live transcript at a checkpointed `ReadyToCallModel` boundary before another model call, while background summarization remains independent for session-owned runtimes - Progressive summaries are prepended to provider requests as a user/assistant pair; direct-provider runs compact their live transcript at a checkpointed `ReadyToCallModel` boundary before another model call, while background summarization remains independent for session-owned runtimes
- Direct-provider progressive compaction runs in a separate request alongside foreground work. Start early against the smaller usable history budget of the current and terminal-use profiles, including each profile's prompt/tools/output reservation. Apply a result only at a safe model boundary after validating its exact snapshotted prefix, preserving all newer history and recomputing live context accounting. Wait for pending compaction only after context exhaustion; cancellation and terminal cleanup must remain immediate. Cleanly completed conversations may accept a late summary while idle; stale, cancelled, or superseded generations must never replace a newer run's history.
- Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction - Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction
- 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 - Agent-requested shell commands run as independent, noninteractive background jobs with closed stdin and no PTY. User-typed commands retain the normal terminal execution and rendering path. No model turn may poll commands, write process input, or take over a shell; unavoidable interactive commands are given to the user to run themselves.
- 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 - A successful shell launch returns `BackgroundJobStarted` and yields the provider generation only after every tool result in that batch is committed. The process belongs to `BackgroundJobsModel`, independently of conversation cancellation or subsequent user input. Running or undelivered jobs keep orchestrated children pending rather than reporting premature success.
- 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 - Background completion sends command, bounded logs, and actual exit status as hidden execution evidence to the owning conversation when it is idle. Completion is not a user request or new authorization, must preserve the user's draft and selected conversation, and is acknowledged only after acceptance. Failed, cancelled, and successful jobs all deliver terminal evidence.
- Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration - `/background-jobs` lists persistent history scoped by git project or directory with adjustable filters, logs, and process-tree termination. Inline tool panes show status only: animated running indicator, green success, red failure, gray cancelled square. Full logs live in private temporary files; View logs appears only while a retained log is readable, and truncation in model context is explicit.
- Restoring history never replays a command or wakes an agent from old completion records. Local interrupted jobs become cancelled; remote running jobs reconcile with their owning daemon before claiming an outcome and remain controllable after reconnect. Process cancellation is scoped to an owned process group/job object, never a reused PID or another conversation's command.
- ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun` - 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 - 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` acknowledges linked local children and yields the parent generation after committing its launch results. Other tools in that launch batch are explicitly not executed. Persistent `LocalAgentGroup` membership survives parent follow-ups; only when every child reaches `Success`, `Error`, or `Cancelled` (with provider cleanup complete), or is removed/deleted, does one combined completion message wake the parent. `Blocked`, `TransientError`, and `WaitingForEvents` children remain pending. Restarts invalidate undelivered group results, and group delivery is confirmed only after the parent input is accepted. - Direct-provider `RunAgents` acknowledges linked local children and yields the parent generation after committing its launch results. Other tools in that launch batch are explicitly not executed. Persistent `LocalAgentGroup` membership survives parent follow-ups; only when every child reaches `Success`, `Error`, or `Cancelled` (with provider cleanup complete), or is removed/deleted, does one combined completion message wake the parent. `Blocked`, `TransientError`, and `WaitingForEvents` children remain pending. Restarts invalidate undelivered group results, and group delivery is confirmed only after the parent input is accepted.
@@ -236,11 +260,12 @@ Key invariants:
- `app/src/ai/blocklist/action_model/risk_review.rs` assesses Galaxy Decides actions using the terminal-use model in a separate, tool-free request, falling back to the base model when unconfigured. Risk 0–3 runs; risk 4–5 and denylist/protected-path/workspace restrictions deny automatically. Galaxy Decides never requests human permission. Denials return reasons so the agent can revise its scope/justification or choose a safer approach. - `app/src/ai/blocklist/action_model/risk_review.rs` assesses Galaxy Decides actions using the terminal-use model in a separate, tool-free request, falling back to the base model when unconfigured. Risk 0–3 runs; risk 4–5 and denylist/protected-path/workspace restrictions deny automatically. Galaxy Decides never requests human permission. Denials return reasons so the agent can revise its scope/justification or choose a safer approach.
- Risk reviews carry chronological available recorded user requests, including intermediate restrictions; oversized review context denies rather than discarding revocations, separate from agent rationale/delegated scenarios. Worker reviews inherit root-conversation authorization; agent claims never establish permission. Later revocations take precedence. An explicitly requested ordinary non-force push within the stated/established remote and branch may be risk 3; external visibility alone is not risk 4. Force/deletion/out-of-scope pushes, secrets, and production changes remain stricter. - Risk reviews carry chronological available recorded user requests, including intermediate restrictions; oversized review context denies rather than discarding revocations, separate from agent rationale/delegated scenarios. Worker reviews inherit root-conversation authorization; agent claims never establish permission. Later revocations take precedence. An explicitly requested ordinary non-force push within the stated/established remote and branch may be risk 3; external visibility alone is not risk 4. Force/deletion/out-of-scope pushes, secrets, and production changes remain stricter.
- Invalid, unavailable, oversized, or timed-out assessments never grant execution. Assessment calls are bounded to 30 seconds. Reviews bind conversation, action contents, execution identity, profile mode and environment. Cancellation, replacement and cleanup invalidate callbacks; stale approval clicks are not authorization. - Invalid, unavailable, oversized, or timed-out assessments never grant execution. Assessment calls are bounded to 30 seconds. Reviews bind conversation, action contents, execution identity, profile mode and environment. Cancellation, replacement and cleanup invalidate callbacks; stale approval clicks are not authorization.
- Approval of a running agent command covers subsequent output monitoring and PTY input for that same conversation-owned active block while the agent retains control. Validate block and launch-action identity; never extend PTY writes to a finished command, another conversation, or a fresh shell prompt. A finished owning block may return its final snapshot without additional permission. - Approval of an agent shell command covers launching that exact background job and terminating its owned process tree. It does not grant interactive input or another command. Cancellation must match the job and owning conversation; users can manage jobs directly in `/background-jobs`.
- Galaxy Decides risk assessment appears only as a transient “Checking permissions…” status above the input, not a per-tool permission pane. Ask First keeps its actionable approval panes. `PermissionReviewChanged` refreshes the owning conversation’s status bar. - Galaxy Decides risk assessment appears only as a transient “Checking permissions…” status above the input, not a per-tool permission pane. Ask First keeps its actionable approval panes. `PermissionReviewChanged` refreshes the owning conversation’s status bar.
- Safe inspection and output polling do not add risk-review model calls. A narrow shell fast path accepts validated `pwd`, `ls`, Git version, and hardened Git status/diff forms; shell expansion, chaining, redirects, unknown flags and unhardened Git commands still require review. Explicit restrictions are checked before fast approval. Galaxy Decides skips user-question pauses. External ACP/CLI agents retain their own protocol permission flows; unstructured ACP requests must not grant Galaxy terminal mutations. - Safe inspection and output polling do not add risk-review model calls. A narrow shell fast path accepts validated `pwd`, `ls`, Git version, and hardened Git status/diff forms; shell expansion, chaining, redirects, unknown flags and unhardened Git commands still require review. Explicit restrictions are checked before fast approval. Galaxy Decides skips user-question pauses. External ACP/CLI agents retain their own protocol permission flows; unstructured ACP requests must not grant Galaxy terminal mutations.
- Allow All bypasses independent model review and human approval for Galaxy-owned tools, while explicit command/MCP denylists, protected paths, workspace restrictions, command ownership, and child delegation boundaries remain enforced. - Allow All bypasses independent model review and human approval for Galaxy-owned tools, while explicit command/MCP denylists, protected paths, workspace restrictions, command ownership, and child delegation boundaries remain enforced.
- Ask First and Allow All expose `ask_user_question` for clarifying missing information; Allow All still bypasses action-approval panes. Galaxy Decides and Read Only omit clarification pauses. CLI monitoring and tool-free assessments must not gain question tools through an explicit tool override.
- Profiles persist `command_allowlist` regular expressions. A matching complete submitted command bypasses permission review in every mode, including Read Only, only after explicit restrictions are checked; denylist matching uses the shell command decomposition as well. Profile permission-mode changes preserve the explicit list but clear remembered command digests. - Profiles persist `command_allowlist` regular expressions. A matching complete submitted command bypasses permission review in every mode, including Read Only, only after explicit restrictions are checked; denylist matching uses the shell command decomposition as well. Profile permission-mode changes preserve the explicit list but clear remembered command digests.
- Profiles persist `orchestration_providers`: absent/null includes all providers (including future additions); an explicit empty list permits none. Entries use configured provider names. Orchestration tool descriptions and pickers filter models, and RunAgents validates per-child/default model selection again at launch and recovery. Restricted profiles cannot use custom routers or external harnesses to bypass provider selection. - Profiles persist `orchestration_providers`: absent/null includes all providers (including future additions); an explicit empty list permits none. Entries use configured provider names. Orchestration tool descriptions and pickers filter models, and RunAgents validates per-child/default model selection again at launch and recovery. Restricted profiles cannot use custom routers or external harnesses to bypass provider selection.
Generated
+93 -82
View File
@@ -493,7 +493,7 @@ dependencies = [
"clipboard-win", "clipboard-win",
"image", "image",
"log", "log",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -2280,7 +2280,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
dependencies = [ dependencies = [
"objc2 0.5.2", "objc2 0.5.3",
] ]
[[package]] [[package]]
@@ -2289,7 +2289,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [ dependencies = [
"objc2 0.6.4", "objc2 0.6.5",
] ]
[[package]] [[package]]
@@ -3237,7 +3237,7 @@ dependencies = [
"instant", "instant",
"libc", "libc",
"log", "log",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -3533,7 +3533,7 @@ dependencies = [
"ndk-context", "ndk-context",
"num-derive", "num-derive",
"num-traits", "num-traits",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-audio-toolbox", "objc2-audio-toolbox",
"objc2-avf-audio", "objc2-avf-audio",
"objc2-core-audio", "objc2-core-audio",
@@ -3817,9 +3817,9 @@ dependencies = [
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [ dependencies = [
"cmov", "cmov",
] ]
@@ -4499,7 +4499,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"libc", "libc",
"objc2 0.6.4", "objc2 0.6.5",
] ]
[[package]] [[package]]
@@ -4678,16 +4678,16 @@ dependencies = [
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.11" version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
dependencies = [ dependencies = [
"cc", "cc",
"memchr", "memchr",
"rustc_version", "rustc_version",
"toml 1.1.6+spec-1.1.0", "toml 1.1.6+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg 0.56.0",
] ]
[[package]] [[package]]
@@ -5720,7 +5720,7 @@ dependencies = [
"num_cpus", "num_cpus",
"oauth2", "oauth2",
"objc", "objc",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
@@ -5833,7 +5833,7 @@ dependencies = [
"windows-registry 0.2.0", "windows-registry 0.2.0",
"windows-result 0.2.0", "windows-result 0.2.0",
"winit", "winit",
"winreg", "winreg 0.55.0",
"x11rb", "x11rb",
"zbus", "zbus",
] ]
@@ -6086,6 +6086,7 @@ dependencies = [
"galaxy_core", "galaxy_core",
"galaxy_util", "galaxy_util",
"galaxyui_core", "galaxyui_core",
"libc",
"log", "log",
"notify-debouncer-full", "notify-debouncer-full",
"remote_server", "remote_server",
@@ -6412,7 +6413,7 @@ dependencies = [
"notify-rust", "notify-rust",
"num-traits", "num-traits",
"objc", "objc",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-av-foundation", "objc2-av-foundation",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
@@ -6446,7 +6447,7 @@ dependencies = [
"windows-core 0.62.2", "windows-core 0.62.2",
"windows-version", "windows-version",
"winit", "winit",
"winreg", "winreg 0.55.0",
"x11-dl", "x11-dl",
"x11rb", "x11rb",
"zbus", "zbus",
@@ -6542,7 +6543,7 @@ dependencies = [
"galaxyui_extras", "galaxyui_extras",
"gloo-storage", "gloo-storage",
"log", "log",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"ouroboros", "ouroboros",
"rand 0.8.8", "rand 0.8.8",
@@ -6856,7 +6857,7 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"keyboard-types", "keyboard-types",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"once_cell", "once_cell",
"thiserror 2.0.21", "thiserror 2.0.21",
@@ -7412,9 +7413,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.19" version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -7769,9 +7770,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.11.1" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -8555,9 +8556,9 @@ dependencies = [
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.37" version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
dependencies = [ dependencies = [
"defmt", "defmt",
"jiff-core", "jiff-core",
@@ -8582,9 +8583,9 @@ dependencies = [
[[package]] [[package]]
name = "jiff-static" name = "jiff-static"
version = "0.2.37" version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
dependencies = [ dependencies = [
"jiff-core", "jiff-core",
"proc-macro2", "proc-macro2",
@@ -8594,9 +8595,9 @@ dependencies = [
[[package]] [[package]]
name = "jiff-tzdb" name = "jiff-tzdb"
version = "0.1.8" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
[[package]] [[package]]
name = "jiff-tzdb-platform" name = "jiff-tzdb-platform"
@@ -9266,7 +9267,7 @@ checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [ dependencies = [
"cc", "cc",
"log", "log",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"time", "time",
"uuid", "uuid",
@@ -10256,9 +10257,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
[[package]] [[package]]
name = "objc2" name = "objc2"
version = "0.5.2" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b"
dependencies = [ dependencies = [
"objc-sys", "objc-sys",
"objc2-encode", "objc2-encode",
@@ -10266,9 +10267,9 @@ dependencies = [
[[package]] [[package]]
name = "objc2" name = "objc2"
version = "0.6.4" version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [ dependencies = [
"objc2-encode", "objc2-encode",
] ]
@@ -10282,7 +10283,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"libc", "libc",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-core-data 0.2.2", "objc2-core-data 0.2.2",
"objc2-core-image 0.2.2", "objc2-core-image 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -10296,7 +10297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
@@ -10311,7 +10312,7 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"libc", "libc",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-audio", "objc2-core-audio",
"objc2-core-audio-types", "objc2-core-audio-types",
"objc2-core-foundation", "objc2-core-foundation",
@@ -10325,7 +10326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -10335,7 +10336,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [ dependencies = [
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -10347,7 +10348,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-core-location 0.2.2", "objc2-core-location 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10359,7 +10360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -10370,7 +10371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10381,7 +10382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [ dependencies = [
"dispatch2", "dispatch2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-audio-types", "objc2-core-audio-types",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
@@ -10394,7 +10395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"objc2 0.6.4", "objc2 0.6.5",
] ]
[[package]] [[package]]
@@ -10405,7 +10406,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10415,7 +10416,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [ dependencies = [
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -10429,7 +10430,7 @@ dependencies = [
"block2 0.6.2", "block2 0.6.2",
"dispatch2", "dispatch2",
"libc", "libc",
"objc2 0.6.4", "objc2 0.6.5",
] ]
[[package]] [[package]]
@@ -10442,7 +10443,7 @@ dependencies = [
"block2 0.6.2", "block2 0.6.2",
"dispatch2", "dispatch2",
"libc", "libc",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-surface", "objc2-io-surface",
"objc2-metal 0.3.2", "objc2-metal 0.3.2",
@@ -10455,7 +10456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-metal 0.2.2", "objc2-metal 0.2.2",
] ]
@@ -10466,7 +10467,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [ dependencies = [
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -10477,7 +10478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-contacts", "objc2-contacts",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10488,7 +10489,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [ dependencies = [
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -10499,7 +10500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
] ]
@@ -10520,7 +10521,7 @@ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"dispatch", "dispatch",
"libc", "libc",
"objc2 0.5.2", "objc2 0.5.3",
] ]
[[package]] [[package]]
@@ -10532,7 +10533,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"libc", "libc",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -10553,7 +10554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -10564,7 +10565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10577,7 +10578,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10590,7 +10591,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"dispatch2", "dispatch2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -10602,7 +10603,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-metal 0.2.2", "objc2-metal 0.2.2",
] ]
@@ -10614,7 +10615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-metal 0.3.2", "objc2-metal 0.3.2",
@@ -10626,7 +10627,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
dependencies = [ dependencies = [
"objc2 0.5.2", "objc2 0.5.3",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10638,7 +10639,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-cloud-kit 0.2.2", "objc2-cloud-kit 0.2.2",
"objc2-core-data 0.2.2", "objc2-core-data 0.2.2",
"objc2-core-image 0.2.2", "objc2-core-image 0.2.2",
@@ -10659,7 +10660,7 @@ checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-cloud-kit 0.3.2", "objc2-cloud-kit 0.3.2",
"objc2-core-data 0.3.2", "objc2-core-data 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -10679,7 +10680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10691,7 +10692,7 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-core-location 0.2.2", "objc2-core-location 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -10702,7 +10703,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [ dependencies = [
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -11004,7 +11005,7 @@ dependencies = [
"android_system_properties", "android_system_properties",
"log", "log",
"nix 0.31.3", "nix 0.31.3",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2", "objc2-ui-kit 0.3.2",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -11596,9 +11597,9 @@ dependencies = [
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]] [[package]]
name = "pprof" name = "pprof"
@@ -11722,7 +11723,7 @@ dependencies = [
"futures", "futures",
"itertools 0.14.0", "itertools 0.14.0",
"log", "log",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"parking_lot", "parking_lot",
"pin-project", "pin-project",
@@ -12492,7 +12493,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [ dependencies = [
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2", "objc2-quartz-core 0.3.2",
@@ -15919,9 +15920,9 @@ dependencies = [
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.9.0" version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
@@ -16406,9 +16407,9 @@ dependencies = [
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [ dependencies = [
"try-lock", "try-lock",
] ]
@@ -17074,7 +17075,7 @@ dependencies = [
"log", "log",
"naga", "naga",
"ndk-sys", "ndk-sys",
"objc2 0.6.4", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-metal 0.3.2", "objc2-metal 0.3.2",
@@ -17701,7 +17702,7 @@ dependencies = [
"libc", "libc",
"memmap2", "memmap2",
"ndk", "ndk",
"objc2 0.5.2", "objc2 0.5.3",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2", "objc2-ui-kit 0.2.2",
@@ -17768,6 +17769,16 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "wio" name = "wio"
version = "0.2.2" version = "0.2.2"
@@ -18095,18 +18106,18 @@ checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.59" version = "0.8.60"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.59" version = "0.8.60"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -18136,9 +18147,9 @@ dependencies = [
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
dependencies = [ dependencies = [
"zeroize_derive", "zeroize_derive",
] ]
+7
View File
@@ -442,6 +442,13 @@ debug = "line-tables-only"
# location while retaining the debug information. # location while retaining the debug information.
split-debuginfo = "unpacked" split-debuginfo = "unpacked"
# Reduce duplicated code/unwind records in the monolithic app test binary
# to stay within macOS compact-unwind offsets. Production and dependency
# compilation retain their existing settings.
[profile.test.package.galaxy]
opt-level = 3
codegen-units = 4
[profile.dev.package] [profile.dev.package]
# Improve the performance of core terminal logic. # Improve the performance of core terminal logic.
galaxy_terminal.opt-level = 3 galaxy_terminal.opt-level = 3
+2
View File
@@ -374,8 +374,10 @@ windows = { workspace = true, features = [
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
"Win32_System_Console", "Win32_System_Console",
"Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_Debug",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Environment", "Win32_System_Environment",
"Win32_System_IO", "Win32_System_IO",
"Win32_System_JobObjects",
"Win32_System_ProcessStatus", "Win32_System_ProcessStatus",
"Win32_System_SystemInformation", "Win32_System_SystemInformation",
"Win32_System_Threading", "Win32_System_Threading",
+9 -11
View File
@@ -493,6 +493,14 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then
WARP_INPUT_REPORTING_SUPPORTED=$(warp_input_reporting_supported) WARP_INPUT_REPORTING_SUPPORTED=$(warp_input_reporting_supported)
fi fi
local honor_ps1
local deref_ps1=""
local escaped_ps1=""
if [[ "$WARP_HONOR_PS1" == "1" ]]; then
honor_ps1="true"
else
honor_ps1="false"
# If we haven't already, cache information about supported features. # If we haven't already, cache information about supported features.
if [[ -z $WARP_PS1_EXPANSION_SUPPORTED ]]; then if [[ -z $WARP_PS1_EXPANSION_SUPPORTED ]]; then
WARP_PS1_EXPANSION_SUPPORTED=$(warp_ps1_expanding_supported) WARP_PS1_EXPANSION_SUPPORTED=$(warp_ps1_expanding_supported)
@@ -515,11 +523,10 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then
deref_ps1=$(echo -e "\n" | PS1="$WARP_PS1" BASH_SILENCE_DEPRECATION_WARNING=1 "$BASH" --norc -i 2>&1 | command -p head -2 | command -p tail -1) deref_ps1=$(echo -e "\n" | PS1="$WARP_PS1" BASH_SILENCE_DEPRECATION_WARNING=1 "$BASH" --norc -i 2>&1 | command -p head -2 | command -p tail -1)
fi fi
# Escaped PS1 variable
local escaped_ps1
if [ "$WARP_IN_MSYS2" = false ]; then if [ "$WARP_IN_MSYS2" = false ]; then
escaped_ps1=$(warp_escape_ps1 "$(echo "$deref_ps1")") escaped_ps1=$(warp_escape_ps1 "$(echo "$deref_ps1")")
fi fi
fi
# Flush history # Flush history
history -a history -a
@@ -661,15 +668,6 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then
# Note WARP_SESSION_ID doesn't need to be escaped since it's a number # Note WARP_SESSION_ID doesn't need to be escaped since it's a number
# We also pass the shell's notion of `honor_ps1` to ensure it's synced correctly on the Warp-side for prompt handling. # We also pass the shell's notion of `honor_ps1` to ensure it's synced correctly on the Warp-side for prompt handling.
# This is passed as a "real boolean" via the JSON payload (string interpolated into JSON string below). # This is passed as a "real boolean" via the JSON payload (string interpolated into JSON string below).
local honor_ps1
if [[ "$WARP_HONOR_PS1" == "1" ]]; then
honor_ps1="true"
# The Warp prompt preview can be rendered using the active prompt in this case (which uses prompt markers).
escaped_ps1=""
deref_ps1=""
else
honor_ps1="false"
fi
# We send the escaped PS1, if we are in active Warp prompt mode, for prompt preview rendering (note the shell's PS1 is unset in this case). # We send the escaped PS1, if we are in active Warp prompt mode, for prompt preview rendering (note the shell's PS1 is unset in this case).
if [ "$WARP_IN_MSYS2" = true ]; then if [ "$WARP_IN_MSYS2" = true ]; then
warp_send_hook_via_kv_pairs_start "Precmd" warp_send_hook_via_kv_pairs_start "Precmd"
+9 -3
View File
@@ -1390,6 +1390,7 @@ esac
# The main logic for generating completions. # The main logic for generating completions.
function warp_main_completer () { function warp_main_completer () {
(( ${+functions[_generic]} )) || return 0
# We want all the results listed. # We want all the results listed.
compstate[list_max]=-1 compstate[list_max]=-1
@@ -1422,12 +1423,17 @@ esac
# Start by reading in the completion buffer. # Start by reading in the completion buffer.
zle warp_read_completion_buffer zle warp_read_completion_buffer
compprefuncs=( warp_mark_start_of_completions_for_compadd_override ) # Own the response frame even when compinit is missing or completion fails.
comppostfuncs=( warp_mark_end_of_completions ) warp_mark_start_of_completions_for_compadd_override
{
local -a compprefuncs=() comppostfuncs=()
COMPADD_OVERRIDE=true COMPADD_OVERRIDE=true
zle warp_complete_via_compadd_override_internal zle warp_complete_via_compadd_override_internal
BUFFER="" } always {
unset COMPADD_OVERRIDE unset COMPADD_OVERRIDE
warp_mark_end_of_completions
BUFFER=""
}
} }
function warp_read_completion_buffer() { function warp_read_completion_buffer() {
+395
View File
@@ -0,0 +1,395 @@
//! Local, opt-in inspection of Galaxy's AI traffic and execution lifecycle.
//!
//! Payloads describe Galaxy's provider-neutral requests/results, not HTTP wire
//! traffic. No transport headers or provider credentials are captured here.
#[cfg(not(target_family = "wasm"))]
pub(crate) mod view;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{json, Value};
/// Limits bound both retention and the work spent serializing a single event.
pub(crate) const MAX_PAYLOAD_CHARS: usize = 256 * 1024;
const MAX_ITEMS: usize = 2_000;
const MAX_RETAINED_BYTES: usize = 16 * 1024 * 1024;
const MAX_LABELS: usize = 4_096;
static RECORDING: AtomicBool = AtomicBool::new(false);
static STORE: LazyLock<Mutex<ActivityStore>> = LazyLock::new(Mutex::default);
#[derive(Clone)]
pub(crate) struct ActivityEntry {
pub id: u64,
pub timestamp: DateTime<Utc>,
pub session_id: Option<String>,
pub session_label: String,
pub category: String,
pub event: String,
pub summary: String,
pub model: Option<String>,
pub duration_ms: Option<u64>,
pub detail: String,
}
pub(crate) struct ActivitySnapshot {
pub recording: bool,
pub revision: u64,
pub dropped: usize,
pub entries: Vec<Arc<ActivityEntry>>,
}
#[derive(Default)]
struct ActivityStore {
entries: VecDeque<Arc<ActivityEntry>>,
labels: HashMap<String, String>,
label_order: VecDeque<String>,
sessions: HashMap<String, String>,
correlation_order: VecDeque<String>,
retained_bytes: usize,
next_id: u64,
revision: u64,
dropped: usize,
recording: bool,
generation: u64,
}
pub(crate) fn is_recording() -> bool {
RECORDING.load(Ordering::Relaxed)
}
pub(crate) fn set_recording(recording: bool) {
if let Ok(mut store) = STORE.lock() {
store.set_recording(recording);
RECORDING.store(recording, Ordering::Relaxed);
}
}
/// Stops neither running work nor recording; it only clears retained diagnostics.
pub(crate) fn clear() {
if let Ok(mut store) = STORE.lock() {
store.clear();
}
}
pub(crate) fn snapshot() -> ActivitySnapshot {
let store = STORE.lock().unwrap_or_else(|error| error.into_inner());
ActivitySnapshot {
recording: store.recording,
revision: store.revision,
dropped: store.dropped,
entries: store.entries.iter().cloned().collect(),
}
}
/// Titles are bounded separately from payloads, and retained across Clear so
/// subsequent tool/background events keep their owning session's name.
pub(crate) fn register_session(session_id: impl Into<String>, label: impl Into<String>) {
if !is_recording() {
return;
}
let session_id = session_id.into();
if session_id.is_empty() || session_id.len() > 256 {
return;
}
let label = bounded_text(&label.into(), 200);
if label.trim().is_empty() {
return;
}
if let Ok(mut store) = STORE.lock() {
if store.recording {
store.register_session(session_id, label);
}
}
}
pub(crate) fn record(event: &str, summary: impl Into<String>, context: Value) {
if !is_recording() {
return;
}
record_serializable(event, summary, context, &());
}
/// Serialize by reference with early termination, without first cloning a
/// conversation or materializing an unbounded serde_json::Value.
pub(crate) fn record_serializable(
event: &str,
summary: impl Into<String>,
context: Value,
payload: &(impl Serialize + ?Sized),
) {
if !is_recording() {
return;
}
let Some(generation) = STORE
.lock()
.ok()
.and_then(|store| store.capture_generation())
else {
return;
};
let timestamp = Utc::now();
#[derive(Serialize)]
struct Capture<'a, T: Serialize + ?Sized> {
context: &'a Value,
payload: &'a T,
}
let captured =
super::remote_logging::prefix_bounded_payload_context(MAX_PAYLOAD_CHARS, |writer| {
serde_json::to_writer_pretty(
writer,
&Capture {
context: &context,
payload,
},
)
});
let mut detail = captured["payload"].as_str().unwrap_or_default().to_owned();
if captured["truncated"].as_bool() == Some(true) {
detail.push_str(
"\n\n[Capture truncated at 262,144 characters; remaining payload was not serialized.]",
);
}
if let Some(error) = captured.get("serialization_error") {
detail = format!("Capture could not be serialized: {error}");
}
let Ok(mut store) = STORE.lock() else {
return;
};
store.push_captured(
generation,
event,
summary.into(),
&context,
detail,
timestamp,
);
}
impl ActivityStore {
fn set_recording(&mut self, recording: bool) {
if self.recording != recording {
self.generation = self.generation.wrapping_add(1);
self.recording = recording;
self.revision = self.revision.wrapping_add(1);
}
}
fn clear(&mut self) {
self.entries.clear();
self.retained_bytes = 0;
self.dropped = 0;
self.generation = self.generation.wrapping_add(1);
self.revision = self.revision.wrapping_add(1);
}
fn capture_generation(&self) -> Option<u64> {
self.recording.then_some(self.generation)
}
fn register_session(&mut self, session_id: String, label: String) {
let label = format!(
"{} · {}",
bounded_text(&label, 185),
session_id.chars().take(8).collect::<String>()
);
if self.labels.get(&session_id) == Some(&label) {
return;
}
if !self.labels.contains_key(&session_id) {
if self.labels.len() >= MAX_LABELS {
if let Some(oldest) = self.label_order.pop_front() {
self.labels.remove(&oldest);
}
}
self.label_order.push_back(session_id.clone());
}
self.labels.insert(session_id.clone(), label.clone());
for entry in &mut self.entries {
if entry.session_id.as_deref() == Some(&session_id) {
self.retained_bytes -= entry_size(entry);
Arc::make_mut(entry).session_label.clone_from(&label);
self.retained_bytes += entry_size(entry);
}
}
self.evict();
self.revision = self.revision.wrapping_add(1);
}
fn push_captured(
&mut self,
generation: u64,
event: &str,
summary: String,
context: &Value,
detail: String,
timestamp: DateTime<Utc>,
) {
// Clear and pause/resume invalidate captures that started before the
// transition, even when serialization finishes after recording resumes.
if self.capture_generation() != Some(generation) {
return;
}
let mut session_id = session_id(context);
let correlation = correlation_keys(context);
if session_id.is_none() {
session_id = correlation
.iter()
.find_map(|key| self.sessions.get(key).cloned());
}
if let Some(id) = &session_id {
for key in correlation {
if !self.sessions.contains_key(&key) {
if self.sessions.len() >= MAX_LABELS {
if let Some(oldest) = self.correlation_order.pop_front() {
self.sessions.remove(&oldest);
}
}
self.correlation_order.push_back(key.clone());
}
self.sessions.insert(key, id.clone());
}
}
let session_label = session_id.as_ref().map_or_else(
|| "App background activity".to_owned(),
|id| {
self.labels.get(id).cloned().unwrap_or_else(|| {
format!("Session {}", id.chars().take(8).collect::<String>())
})
},
);
let entry = Arc::new(ActivityEntry {
id: self.next_id,
timestamp,
session_id,
session_label,
category: category(event).to_owned(),
event: bounded_text(event, 160),
summary: bounded_text(&summary, 400),
model: context
.get("model_id")
.or_else(|| context.get("model"))
.and_then(Value::as_str)
.map(|model| bounded_text(model, 200)),
duration_ms: context
.get("elapsed_ms")
.or_else(|| context.get("duration_ms"))
.and_then(Value::as_u64),
detail,
});
self.next_id = self.next_id.wrapping_add(1);
self.retained_bytes += entry_size(&entry);
self.entries.push_back(entry);
self.evict();
self.revision = self.revision.wrapping_add(1);
}
fn evict(&mut self) {
while self.entries.len() > MAX_ITEMS || self.retained_bytes > MAX_RETAINED_BYTES {
if let Some(evicted) = self.entries.pop_front() {
self.retained_bytes -= entry_size(&evicted);
self.dropped += 1;
}
}
}
}
/// Entry retention counts metadata as well as captured content. The two
/// identity registries are separately bounded by count and string length.
fn entry_size(entry: &ActivityEntry) -> usize {
std::mem::size_of::<ActivityEntry>()
+ entry.session_id.as_ref().map_or(0, String::capacity)
+ entry.session_label.capacity()
+ entry.category.capacity()
+ entry.event.capacity()
+ entry.summary.capacity()
+ entry.model.as_ref().map_or(0, String::capacity)
+ entry.detail.capacity()
}
fn correlation_keys(context: &Value) -> Vec<String> {
["stream_id", "request_id", "provider_run_id", "run_id"]
.into_iter()
.filter_map(|key| {
let value = context.get(key)?.as_str()?;
// Overlong identifiers cannot be allowed to grow the registry or
// be truncated into another session's correlation key.
(!value.is_empty() && value.len() <= 256).then(|| format!("{key}:{value}"))
})
.collect()
}
fn session_id(context: &Value) -> Option<String> {
[
context.get("conversation_id"),
context.pointer("/tool/conversation_id"),
context.pointer("/identifiers/client_conversation_id"),
context.get("parent_conversation_id"),
]
.into_iter()
.flatten()
.filter_map(Value::as_str)
.find(|id| !id.is_empty() && id.len() <= 256)
.map(str::to_owned)
}
fn category(event: &str) -> &'static str {
if event.contains("compact") || event.contains("summar") {
"Compaction"
} else if event.contains("risk_review")
|| event.contains("risk_assessment")
|| event.contains("permission")
{
"Permissions"
} else if event.contains("tool") || event.contains("action") {
"Tools"
} else if event.contains("command") || event.contains("job") {
"Background jobs"
} else if event.contains("model") || event.contains("llm") {
"Completions"
} else {
"Lifecycle"
}
}
fn bounded_text(text: &str, limit: usize) -> String {
text.chars().take(limit).collect()
}
/// Reuses existing operational metadata while keeping remote logging consent
/// and credentials independent from this local inspector.
pub(crate) fn record_log(record: &super::remote_logging::RemoteLogRecord) {
if !is_recording() {
return;
}
let event = record
.context
.get("event")
.and_then(Value::as_str)
.unwrap_or("activity");
record_serializable(
event,
&record.message,
json!({
"conversation_id": session_id(&record.context),
"stream_id": record.context.get("stream_id"),
"request_id": record.context.get("request_id"),
"provider_run_id": record.context.get("provider_run_id"),
"run_id": record.context.get("run_id"),
"duration_ms": record.context.get("duration_ms"),
"model_id": record.context.get("model_id"),
"elapsed_ms": record.context.get("elapsed_ms"),
"level": record.level.as_str(),
}),
&record.context,
);
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
use super::*;
fn recording_store() -> ActivityStore {
let mut store = ActivityStore::default();
store.set_recording(true);
store
}
fn capture(store: &mut ActivityStore, context: Value, detail: String) {
store.push_captured(
store.capture_generation().unwrap(),
"model_response",
"Model completed".to_owned(),
&context,
detail,
Utc::now(),
);
}
#[test]
fn clear_and_pause_resume_invalidate_in_flight_captures() {
let mut store = recording_store();
let first_generation = store.capture_generation().unwrap();
store.clear();
store.push_captured(
first_generation,
"tool_result",
"Late".to_owned(),
&json!({}),
"old".to_owned(),
Utc::now(),
);
assert!(store.entries.is_empty());
let second_generation = store.capture_generation().unwrap();
store.set_recording(false);
assert!(store.capture_generation().is_none());
store.set_recording(true);
store.push_captured(
second_generation,
"tool_result",
"Late".to_owned(),
&json!({}),
"old".to_owned(),
Utc::now(),
);
assert!(store.entries.is_empty());
capture(&mut store, json!({}), "new".to_owned());
assert_eq!(store.entries.len(), 1);
}
#[test]
fn retention_evicts_oldest_entries_by_count_and_allocated_size() {
let mut store = recording_store();
for _ in 0..MAX_ITEMS + 3 {
capture(&mut store, json!({}), String::new());
}
assert_eq!(store.entries.len(), MAX_ITEMS);
assert_eq!(store.dropped, 3);
assert_eq!(store.entries.front().unwrap().id, 3);
store.clear();
let detail = "x".repeat(1024 * 1024);
for _ in 0..20 {
capture(&mut store, json!({}), detail.clone());
}
assert!(store.retained_bytes <= MAX_RETAINED_BYTES);
assert_eq!(
store.retained_bytes,
store
.entries
.iter()
.map(|entry| entry_size(entry))
.sum::<usize>()
);
assert!(store.dropped > 0);
// Metadata also consumes the budget, so sixteen 1 MiB payloads do not fit.
assert!(store.entries.len() < 16);
}
#[test]
fn session_names_propagate_to_prior_entries_and_correlated_responses() {
let mut store = recording_store();
capture(
&mut store,
json!({"conversation_id": "session-12345", "stream_id": "stream-1"}),
String::new(),
);
store.register_session("session-12345".to_owned(), "Inspect build".to_owned());
let previous_snapshot = store.entries.front().unwrap().clone();
capture(&mut store, json!({"stream_id": "stream-1"}), String::new());
let response = store.entries.back().unwrap();
assert_eq!(response.session_id.as_deref(), Some("session-12345"));
assert_eq!(response.session_label, "Inspect build · session-");
assert_eq!(previous_snapshot.session_label, response.session_label);
let bytes_before_rename = store.retained_bytes;
store.register_session(
"session-12345".to_owned(),
"Longer build inspection title".to_owned(),
);
assert!(store.retained_bytes > bytes_before_rename);
assert_eq!(
store.retained_bytes,
store
.entries
.iter()
.map(|entry| entry_size(entry))
.sum::<usize>()
);
}
#[test]
fn correlation_rejects_oversized_ids_and_preserves_explicit_ownership() {
let mut store = recording_store();
let overlong = "s".repeat(100_000);
capture(
&mut store,
json!({"conversation_id": "session-a", "stream_id": overlong}),
String::new(),
);
assert!(store.sessions.is_empty());
capture(&mut store, json!({"stream_id": overlong}), String::new());
assert!(store.entries.back().unwrap().session_id.is_none());
capture(
&mut store,
json!({"conversation_id": "session-a", "request_id": "request-1"}),
String::new(),
);
capture(
&mut store,
json!({"conversation_id": "session-b", "request_id": "request-1"}),
String::new(),
);
assert_eq!(
store.entries.back().unwrap().session_id.as_deref(),
Some("session-b")
);
assert_eq!(
session_id(&json!({"conversation_id": "", "tool": {"conversation_id": "owner"}})),
Some("owner".to_owned())
);
}
#[test]
fn risk_assessments_share_the_permissions_filter() {
assert_eq!(category("tool_risk_assessment_finished"), "Permissions");
assert_eq!(category("risk_review_request"), "Permissions");
assert_eq!(category("provider_compaction_requested"), "Compaction");
assert_eq!(
category("shell_command_terminal_completed"),
"Background jobs"
);
}
#[test]
fn long_recordings_keep_accepting_new_session_and_request_identities() {
let mut store = recording_store();
for index in 0..MAX_LABELS + 2 {
let owner = format!("session-{index}");
store.register_session(owner.clone(), format!("Task {index}"));
capture(
&mut store,
json!({"conversation_id": owner, "request_id": format!("request-{index}")}),
String::new(),
);
}
assert_eq!(store.labels.len(), MAX_LABELS);
assert_eq!(store.label_order.len(), MAX_LABELS);
assert_eq!(store.sessions.len(), MAX_LABELS);
assert_eq!(store.correlation_order.len(), MAX_LABELS);
assert!(!store.labels.contains_key("session-0"));
assert!(!store.sessions.contains_key("request_id:request-0"));
let latest = MAX_LABELS + 1;
capture(
&mut store,
json!({"request_id": format!("request-{latest}")}),
String::new(),
);
let response = store.entries.back().unwrap();
assert_eq!(
response.session_id.as_deref(),
Some(format!("session-{latest}").as_str())
);
assert!(response
.session_label
.starts_with(&format!("Task {latest}")));
}
+692
View File
@@ -0,0 +1,692 @@
//! App-wide live browser for local AI diagnostics.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use galaxyui::elements::{
Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CrossAxisAlignment, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, Padding, ParentElement, RowBackground, ScrollbarWidth, Table,
TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::r#async::Timer;
use galaxyui::text_layout::ClipConfig;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use super::{ActivityEntry, ActivitySnapshot};
use crate::appearance::Appearance;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState, PlainTextEditorViewAction,
SingleLineEditorOptions, TextOptions,
};
use crate::modal::ModalAction;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, SecondaryTheme};
const PAGE_SIZE: usize = 200;
#[derive(Clone, Debug)]
pub enum AIActivityAction {
ToggleRecording,
Clear,
Select(u64),
FilterSession(Option<String>),
FilterCategory(Option<String>),
ShowMore,
}
pub struct AIActivityView {
snapshot: ActivitySnapshot,
search: ViewHandle<EditorView>,
detail: ViewHandle<EditorView>,
recording_button: ViewHandle<ActionButton>,
clear_button: ViewHandle<ActionButton>,
all_sessions_button: ViewHandle<ActionButton>,
more_button: ViewHandle<ActionButton>,
category_buttons: Vec<(Option<String>, ViewHandle<ActionButton>)>,
selected: Option<Arc<ActivityEntry>>,
session_filter: Option<String>,
category_filter: Option<String>,
rows: HashMap<u64, (MouseStateHandle, MouseStateHandle)>,
display_limit: usize,
visible: bool,
refresh_scheduled: bool,
list_scroll: ClippedScrollStateHandle,
detail_scroll: ClippedScrollStateHandle,
filters_scroll: ClippedScrollStateHandle,
table_state: TableStateHandle,
}
impl AIActivityView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let search = ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::single_line(SingleLineEditorOptions::default(), ctx);
editor.set_placeholder_text("Search activity, model, or session", ctx);
editor
});
ctx.subscribe_to_view(&search, |me, _, event, ctx| match event {
EditorEvent::Edited(_) => {
me.display_limit = PAGE_SIZE;
me.synchronize_rows(ctx);
}
EditorEvent::Escape => ctx.dispatch_typed_action(&ModalAction::Close),
_ => {}
});
let appearance = Appearance::as_ref(ctx);
let font = appearance.monospace_font_family();
let size = appearance.monospace_font_size();
let detail = ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::new(
EditorOptions {
text: TextOptions {
font_family_override: Some(font),
font_size_override: Some(size),
..Default::default()
},
autogrow: true,
soft_wrap: true,
supports_vim_mode: false,
use_settings_line_height_ratio: true,
..Default::default()
},
ctx,
);
editor.set_interaction_state(InteractionState::Selectable, ctx);
editor
});
ctx.subscribe_to_view(&detail, |_, _, event, ctx| {
if matches!(event, EditorEvent::Escape) {
ctx.dispatch_typed_action(&ModalAction::Close);
}
});
let recording_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Pause recording", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ToggleRecording))
});
let clear_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Clear", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::Clear))
});
let all_sessions_button = ctx.add_typed_action_view(|_| {
ActionButton::new("All sessions", NakedTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::FilterSession(None)))
});
let more_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Show more", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ShowMore))
});
let category_buttons = [
(None, "All activity"),
(Some("Completions"), "Models"),
(Some("Compaction"), "Compaction"),
(Some("Tools"), "Tools"),
(Some("Permissions"), "Permissions"),
(Some("Background jobs"), "Jobs"),
(Some("Lifecycle"), "Lifecycle"),
]
.into_iter()
.map(|(category, label)| {
let category = category.map(str::to_owned);
let value = category.clone();
let button = ctx.add_typed_action_view(|_| {
ActionButton::new(label, NakedTheme)
.with_size(ButtonSize::Small)
.on_click(move |ctx| {
ctx.dispatch_typed_action(AIActivityAction::FilterCategory(value.clone()))
})
});
(category, button)
})
.collect();
Self {
// Workspace modals are constructed while hidden. Do not retain global
// payloads until this inspector is actually opened.
snapshot: ActivitySnapshot {
recording: super::is_recording(),
revision: 0,
dropped: 0,
entries: Vec::new(),
},
search,
detail,
recording_button,
clear_button,
all_sessions_button,
more_button,
category_buttons,
selected: None,
session_filter: None,
category_filter: None,
rows: HashMap::new(),
display_limit: PAGE_SIZE,
visible: false,
refresh_scheduled: false,
list_scroll: Default::default(),
detail_scroll: Default::default(),
filters_scroll: Default::default(),
table_state: Default::default(),
}
}
pub fn on_open(&mut self, ctx: &mut ViewContext<Self>) {
// Opening the inspector is the explicit opt-in to in-memory payload capture.
if super::snapshot().revision == 0 {
super::set_recording(true);
}
self.visible = true;
self.refresh(ctx);
self.update_recording_button(ctx);
self.synchronize_rows(ctx);
self.schedule_refresh(ctx);
ctx.focus(&self.search);
}
pub fn on_close(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
self.snapshot.entries.clear();
self.rows.clear();
self.clear_detail(ctx);
self.list_scroll = Default::default();
self.table_state = Default::default();
// Leave any pending refresh scheduled: it will observe `visible`, or
// resume on a rapid reopen, without creating a second polling chain.
ctx.notify();
}
fn schedule_refresh(&mut self, ctx: &mut ViewContext<Self>) {
if self.refresh_scheduled || !self.visible {
return;
}
self.refresh_scheduled = true;
ctx.spawn(
async { Timer::after(Duration::from_millis(500)).await },
|me, _, ctx| {
me.refresh_scheduled = false;
if me.visible {
me.refresh(ctx);
me.schedule_refresh(ctx);
}
},
);
}
fn refresh(&mut self, ctx: &mut ViewContext<Self>) {
let snapshot = super::snapshot();
let changed = snapshot.revision != self.snapshot.revision
|| snapshot.recording != self.snapshot.recording;
self.snapshot = snapshot;
if changed {
if self.selected.as_ref().is_some_and(|selected| {
!self
.snapshot
.entries
.iter()
.any(|entry| entry.id == selected.id)
}) {
self.clear_detail(ctx);
}
self.update_recording_button(ctx);
self.synchronize_rows(ctx);
}
}
fn clear_detail(&mut self, ctx: &mut ViewContext<Self>) {
self.selected = None;
self.detail.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
self.detail_scroll = Default::default();
}
fn update_recording_button(&self, ctx: &mut ViewContext<Self>) {
self.recording_button.update(ctx, |button, ctx| {
button.set_label(
if self.snapshot.recording {
"Pause recording"
} else {
"Record"
},
ctx,
);
});
}
fn filtered_entries(&self, app: &AppContext) -> Vec<Arc<ActivityEntry>> {
let query = self
.search
.as_ref(app)
.buffer_text(app)
.trim()
.to_lowercase();
self.snapshot
.entries
.iter()
.rev()
.filter(|entry| {
self.session_filter
.as_ref()
.is_none_or(|session| entry.session_id.as_ref() == Some(session))
&& self
.category_filter
.as_ref()
.is_none_or(|category| entry.category == *category)
&& (query.is_empty()
|| [
&entry.session_label,
&entry.summary,
&entry.event,
&entry.category,
]
.into_iter()
.any(|field| field.to_lowercase().contains(&query))
|| entry
.session_id
.as_ref()
.is_some_and(|id| id.to_lowercase().contains(&query))
|| entry
.model
.as_ref()
.is_some_and(|model| model.to_lowercase().contains(&query)))
})
.cloned()
.collect()
}
fn synchronize_rows(&mut self, ctx: &mut ViewContext<Self>) {
let ids = self
.filtered_entries(ctx)
.into_iter()
.take(self.display_limit)
.map(|entry| entry.id)
.collect::<HashSet<_>>();
self.rows.retain(|id, _| ids.contains(id));
for id in ids {
self.rows.entry(id).or_default();
}
for (category, button) in &self.category_buttons {
button.update(ctx, |button, ctx| {
button.set_active(*category == self.category_filter, ctx)
});
}
self.all_sessions_button.update(ctx, |button, ctx| {
button.set_active(self.session_filter.is_none(), ctx);
});
ctx.notify();
}
fn select_entry(&mut self, id: u64, ctx: &mut ViewContext<Self>) {
let Some(entry) = self
.snapshot
.entries
.iter()
.find(|entry| entry.id == id)
.cloned()
else {
return;
};
let session_id = entry.session_id.as_deref().unwrap_or("Unscoped");
let text = format!(
"{}\nSession: {}\nSession ID: {}\nTime: {}\nEvent: {}\n\n{}",
entry.summary,
entry.session_label,
session_id,
entry
.timestamp
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S%.3f %Z"),
entry.event,
readable_detail(&entry),
);
self.detail.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.insert_with_styles(&text, &[], PlainTextEditorViewAction::ReplaceBuffer, ctx);
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
self.selected = Some(entry);
self.detail_scroll = Default::default();
ctx.notify();
}
fn render_table(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let entries = self
.filtered_entries(app)
.into_iter()
.take(self.display_limit)
.collect::<Vec<_>>();
if entries.is_empty() {
let message = if self.snapshot.entries.is_empty() {
"Waiting for AI activity. Continue a conversation in any session to capture requests, responses, tools, and compaction."
} else {
"No activity matches these filters."
};
return Container::new(
Text::new(
message,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.soft_wrap(true)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
.with_padding(Padding::uniform(20.))
.finish();
}
let headers = [
("Time", TableColumnWidth::Fixed(102.)),
("Session", TableColumnWidth::Flex(1.)),
("Type", TableColumnWidth::Fixed(100.)),
("Activity", TableColumnWidth::Flex(1.8)),
("Model", TableColumnWidth::Flex(1.)),
("Duration", TableColumnWidth::Fixed(88.)),
]
.into_iter()
.map(|(label, width)| {
TableHeader::new(
Text::new(
label,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.nonactive_ui_text_color().into())
.finish(),
)
.with_width(width)
})
.collect();
let rows = self.rows.clone();
let selected = self.selected.as_ref().map(|entry| entry.id);
let table = Table::new(self.table_state.clone(), 0., 0.)
.with_headers(headers)
.with_row_count(entries.len())
.with_row_render_fn(move |index, app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let entry = &entries[index];
let id = entry.id;
let Some((row_mouse, session_mouse)) = rows.get(&id) else {
return Vec::new();
};
let text = |value: String| {
Text::new_inline(
value,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_clip(ClipConfig::ellipsis())
.with_color(theme.active_ui_text_color().into())
.finish()
};
let session_id = entry.session_id.clone();
let session = text(entry.session_label.clone());
let session = if session_id.is_some() {
Hoverable::new(session_mouse.clone(), move |_| session)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIActivityAction::FilterSession(
session_id.clone(),
))
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
session
};
let summary = entry.summary.clone();
let activity = Hoverable::new(row_mouse.clone(), move |_| {
Text::new_inline(
summary,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_clip(ClipConfig::ellipsis())
.with_style(Properties::default().weight(if selected == Some(id) {
Weight::Semibold
} else {
Weight::Normal
}))
.with_color(theme.active_ui_text_color().into())
.finish()
})
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIActivityAction::Select(id)))
.with_cursor(Cursor::PointingHand)
.finish();
vec![
text(
entry
.timestamp
.with_timezone(&chrono::Local)
.format("%H:%M:%S%.3f")
.to_string(),
),
session,
text(entry.category.clone()),
activity,
text(entry.model.clone().unwrap_or_else(|| "—".to_owned())),
text(
entry
.duration_ms
.map(|ms| format!("{:.2} s", ms as f64 / 1000.))
.unwrap_or_else(|| "—".to_owned()),
),
]
})
.with_config(TableConfig {
border_width: 1.,
border_color: theme.outline().into(),
outer_border: true,
column_dividers: false,
row_dividers: true,
cell_padding: 10.,
header_background: theme.surface_2().into(),
row_background: RowBackground::uniform(theme.surface_1().into()),
fixed_header: false,
vertical_sizing: TableVerticalSizing::ExpandToContent,
measure_body_cells_for_intrinsic_widths: false,
});
ClippedScrollable::vertical(
self.list_scroll.clone(),
table.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish()
}
}
/// Legacy/ACP and projected tool arguments arrive as bounded Debug strings.
/// Unwrap those strings for reading while retaining their operational metadata.
/// A truncated capture is deliberately left as text rather than parsed as JSON.
fn readable_detail(entry: &ActivityEntry) -> String {
let Ok(capture) = serde_json::from_str::<serde_json::Value>(&entry.detail) else {
return entry.detail.clone();
};
let payload = &capture["payload"];
let raw = if entry.event == "tool_call" {
payload.as_str()
} else if matches!(
entry.event.as_str(),
"raw_model_request" | "raw_model_response"
) {
payload
.pointer("/raw_payload/payload")
.and_then(serde_json::Value::as_str)
} else {
None
};
let Some(raw) = raw else {
return entry.detail.clone();
};
let decoded = serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_owned());
let mut metadata = capture;
if entry.event == "tool_call" {
metadata["payload"] = serde_json::json!("See captured content below");
} else if let Some(raw_payload) = metadata
.pointer_mut("/payload/raw_payload")
.and_then(serde_json::Value::as_object_mut)
{
raw_payload.remove("payload");
}
format!(
"{}\n\nCaptured content:\n{decoded}",
serde_json::to_string_pretty(&metadata).unwrap_or_default()
)
}
impl Entity for AIActivityView {
type Event = ();
}
impl View for AIActivityView {
fn ui_name() -> &'static str {
"AIActivityView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let small_text = |text: String| {
Text::new(
text,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.soft_wrap(true)
.with_color(theme.sub_text_color(theme.background()).into())
.finish()
};
let search = Container::new(Clipped::new(ChildView::new(&self.search).finish()).finish())
.with_horizontal_padding(10.)
.with_vertical_padding(8.)
.with_border(Border::all(1.).with_border_color(theme.outline().into()))
.finish();
let mut filters = Flex::row()
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
for (_, button) in &self.category_buttons {
filters.add_child(ChildView::new(button).finish());
}
filters.add_child(ChildView::new(&self.all_sessions_button).finish());
let filters = ConstrainedBox::new(
ClippedScrollable::horizontal(
self.filters_scroll.clone(),
filters.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish(),
)
.with_height(36.)
.finish();
let session_label = self.session_filter.as_ref().map(|id| {
self.snapshot
.entries
.iter()
.find(|entry| entry.session_id.as_ref() == Some(id))
.map(|entry| entry.session_label.clone())
.unwrap_or_else(|| id.clone())
});
let count = self.filtered_entries(app).len();
let mut footer = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(small_text(format!(
"{} of {count} events · {}{}",
self.display_limit.min(count),
session_label
.map(|label| format!("Session: {label}"))
.unwrap_or_else(|| "All sessions · click a session to filter".to_owned()),
if self.snapshot.dropped > 0 {
format!(" · {} older events discarded", self.snapshot.dropped)
} else {
String::new()
}
)));
if count > self.display_limit {
footer.add_child(ChildView::new(&self.more_button).finish());
}
let detail = if self.selected.is_some() {
ClippedScrollable::vertical(
self.detail_scroll.clone(),
Container::new(ChildView::new(&self.detail).finish())
.with_padding(Padding::uniform(12.))
.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish()
} else {
Container::new(small_text("Click an activity to inspect its request, response, or lifecycle details. Text is selectable and wrapped.".to_owned()))
.with_padding(Padding::uniform(16.)).finish()
};
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Max).with_spacing(10.)
.with_child(Flex::row().with_spacing(8.).with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., search).finish())
.with_child(ChildView::new(&self.recording_button).finish())
.with_child(ChildView::new(&self.clear_button).finish()).finish())
.with_child(filters)
.with_child(small_text(format!("{} · Local Galaxy request/result snapshots, up to 2,000 events / 16 MiB and 256 Ki characters per event. Recording continues when this window closes.",
if self.snapshot.recording { "Recording all sessions" } else { "Recording paused" })))
.with_child(Expanded::new(1., self.render_table(app)).finish())
.with_child(footer.finish())
.with_child(Expanded::new(1., Container::new(detail)
.with_background(theme.background())
.with_border(Border::all(1.).with_border_color(theme.outline().into())).finish()).finish())
.finish()
}
}
impl TypedActionView for AIActivityView {
type Action = AIActivityAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AIActivityAction::ToggleRecording => {
super::set_recording(!super::snapshot().recording);
self.refresh(ctx);
}
AIActivityAction::Clear => {
super::clear();
self.clear_detail(ctx);
self.refresh(ctx);
}
AIActivityAction::Select(id) => self.select_entry(*id, ctx),
AIActivityAction::FilterSession(session) => {
self.session_filter = session.clone();
self.display_limit = PAGE_SIZE;
self.list_scroll = Default::default();
self.synchronize_rows(ctx);
}
AIActivityAction::FilterCategory(category) => {
self.category_filter = category.clone();
self.display_limit = PAGE_SIZE;
self.list_scroll = Default::default();
self.synchronize_rows(ctx);
}
AIActivityAction::ShowMore => {
self.display_limit += PAGE_SIZE;
self.synchronize_rows(ctx);
}
}
}
}
+27 -8
View File
@@ -28,12 +28,36 @@ pub(crate) fn prepare_direct_provider_params(
.clone() .clone()
.unwrap_or_else(|| get_supported_tools(params)); .unwrap_or_else(|| get_supported_tools(params));
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled); remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
let ask_user_question_enabled = FeatureFlag::AskUserQuestion.is_enabled()
&& params.ask_user_question_enabled
&& matches!(
params.permission_mode,
crate::ai::execution_profiles::PermissionMode::AskFirst
| crate::ai::execution_profiles::PermissionMode::AllowAll
);
supported_tools.retain(|tool| {
*tool != api::ToolType::AskUserQuestion || ask_user_question_enabled
});
// Computer use controls this machine, even when the terminal is connected elsewhere.
// Only expose the initial request here; the dedicated profile owns subsequent actions.
supported_tools.retain(|tool| *tool != api::ToolType::UseComputer
&& (*tool != api::ToolType::RequestComputerUse || params.computer_use_enabled));
let mut supported_cli_agent_tools = let mut supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(params)); supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(params));
remove_orchestration_tools_if_disabled( remove_orchestration_tools_if_disabled(
&mut supported_cli_agent_tools, &mut supported_cli_agent_tools,
params.orchestration_enabled, params.orchestration_enabled,
); );
supported_cli_agent_tools.retain(|tool| !matches!(tool, api::ToolType::UseComputer | api::ToolType::RequestComputerUse));
supported_cli_agent_tools.retain(|tool| *tool != api::ToolType::AskUserQuestion);
// Explicit overrides cannot restore retired terminal monitoring or PTY access.
for tools in [&mut supported_tools, &mut supported_cli_agent_tools] {
tools.retain(|tool| !matches!(tool,
api::ToolType::WriteToLongRunningShellCommand
| api::ToolType::ReadShellCommandOutput
| api::ToolType::TransferShellCommandControlToUser
));
}
if params.permission_mode == crate::ai::execution_profiles::PermissionMode::ReadOnly { if params.permission_mode == crate::ai::execution_profiles::PermissionMode::ReadOnly {
let permitted = |tool: &api::ToolType| is_read_only_tool(tool) let permitted = |tool: &api::ToolType| is_read_only_tool(tool)
|| (!params.command_allowlist.is_empty() && matches!(tool, api::ToolType::RunShellCommand | api::ToolType::ReadShellCommandOutput | api::ToolType::WriteToLongRunningShellCommand)); || (!params.command_allowlist.is_empty() && matches!(tool, api::ToolType::RunShellCommand | api::ToolType::ReadShellCommandOutput | api::ToolType::WriteToLongRunningShellCommand));
@@ -67,12 +91,13 @@ 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::WriteToLongRunningShellCommand,
api::ToolType::ReadShellCommandOutput,
api::ToolType::ReadDocuments, api::ToolType::ReadDocuments,
api::ToolType::CreateDocuments, api::ToolType::CreateDocuments,
api::ToolType::EditDocuments, api::ToolType::EditDocuments,
]; ];
if params.computer_use_enabled {
supported_tools.push(api::ToolType::RequestComputerUse);
}
if params.orchestration_enabled { if params.orchestration_enabled {
supported_tools.push(api::ToolType::Subagent); supported_tools.push(api::ToolType::Subagent);
@@ -116,17 +141,11 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
fn get_supported_cli_agent_tools(params: &RequestParams) -> Vec<api::ToolType> { fn get_supported_cli_agent_tools(params: &RequestParams) -> Vec<api::ToolType> {
let mut supported_cli_agent_tools = vec![ let mut supported_cli_agent_tools = vec![
api::ToolType::WriteToLongRunningShellCommand,
api::ToolType::ReadShellCommandOutput,
api::ToolType::Grep, api::ToolType::Grep,
api::ToolType::FileGlob, api::ToolType::FileGlob,
api::ToolType::FileGlobV2, api::ToolType::FileGlobV2,
]; ];
if FeatureFlag::TransferControlTool.is_enabled() {
supported_cli_agent_tools.push(api::ToolType::TransferShellCommandControlToUser);
}
match params.session_context.session_type() { match params.session_context.session_type() {
None | Some(SessionType::Local) => { None | Some(SessionType::Local) => {
supported_cli_agent_tools supported_cli_agent_tools
+88 -3
View File
@@ -91,6 +91,37 @@ fn supported_tools_include_plan_document_capabilities() {
assert!(supported_tools.contains(&api::ToolType::EditDocuments)); assert!(supported_tools.contains(&api::ToolType::EditDocuments));
} }
#[test]
fn computer_use_is_explicitly_enabled_and_kept_out_of_command_monitor_tools() {
let mut params = request_params_with_ask_user_question_enabled(false);
params.computer_use_enabled = true;
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
assert!(normal.contains(&api::ToolType::RequestComputerUse));
assert!(!normal.contains(&api::ToolType::UseComputer));
assert!(!cli.contains(&api::ToolType::RequestComputerUse));
assert!(!cli.contains(&api::ToolType::UseComputer));
params.computer_use_enabled = false;
let (normal, _) = super::prepare_direct_provider_params(&mut params);
assert!(!normal.contains(&api::ToolType::RequestComputerUse));
}
#[test]
fn computer_use_never_expands_explicit_tool_overrides_or_read_only_permissions() {
let mut params = request_params_with_ask_user_question_enabled(false);
params.computer_use_enabled = true;
params.supported_tools_override = Some(Vec::new());
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
assert!(normal.is_empty());
assert!(cli.is_empty());
params.permission_mode = crate::ai::execution_profiles::PermissionMode::ReadOnly;
params.supported_tools_override = Some(vec![api::ToolType::RequestComputerUse, api::ToolType::UseComputer]);
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
assert!(normal.is_empty());
assert!(cli.is_empty());
}
#[test] #[test]
fn supported_tools_omit_subagents_when_orchestration_is_disabled() { fn supported_tools_omit_subagents_when_orchestration_is_disabled() {
let params = request_params_with_ask_user_question_enabled(false); let params = request_params_with_ask_user_question_enabled(false);
@@ -160,9 +191,7 @@ fn supported_tools_omits_ask_user_question_when_disabled() {
#[test] #[test]
fn supported_tools_includes_ask_user_question_when_enabled_and_feature_flag_is_enabled() { fn supported_tools_includes_ask_user_question_when_enabled_and_feature_flag_is_enabled() {
if !FeatureFlag::AskUserQuestion.is_enabled() { let _flag = FeatureFlag::AskUserQuestion.override_enabled(true);
return;
}
let params = request_params_with_ask_user_question_enabled(true); let params = request_params_with_ask_user_question_enabled(true);
let supported_tools = get_supported_tools(&params); let supported_tools = get_supported_tools(&params);
@@ -170,6 +199,47 @@ fn supported_tools_includes_ask_user_question_when_enabled_and_feature_flag_is_e
assert!(supported_tools.contains(&api::ToolType::AskUserQuestion)); assert!(supported_tools.contains(&api::ToolType::AskUserQuestion));
} }
#[test]
fn question_overrides_respect_mode_capability_and_feature_without_enabling_cli_pauses() {
use crate::ai::execution_profiles::PermissionMode;
for (mode, enabled, feature_enabled, expected) in [
(PermissionMode::AskFirst, true, true, true),
(PermissionMode::AllowAll, true, true, true),
(PermissionMode::AllowAll, false, true, false),
(PermissionMode::AllowAll, true, false, false),
(PermissionMode::GalaxyDecides, true, true, false),
(PermissionMode::ReadOnly, true, true, false),
] {
let _flag = FeatureFlag::AskUserQuestion.override_enabled(feature_enabled);
let mut params = request_params_with_ask_user_question_enabled(enabled);
params.permission_mode = mode;
params.supported_tools_override = Some(vec![api::ToolType::AskUserQuestion]);
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
assert_eq!(
normal.contains(&api::ToolType::AskUserQuestion),
expected,
"{mode:?}"
);
assert!(!cli.contains(&api::ToolType::AskUserQuestion), "{mode:?}");
}
}
#[test]
fn question_availability_does_not_expand_empty_tool_overrides() {
let _flag = FeatureFlag::AskUserQuestion.override_enabled(true);
let mut params = request_params_with_ask_user_question_enabled(true);
params.permission_mode = crate::ai::execution_profiles::PermissionMode::AllowAll;
params.supported_tools_override = Some(Vec::new());
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
assert!(normal.is_empty());
assert!(cli.is_empty());
}
#[test] #[test]
fn remote_supported_tools_include_search_codebase_when_connected_and_feature_flag_is_enabled() { fn remote_supported_tools_include_search_codebase_when_connected_and_feature_flag_is_enabled() {
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(true); let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(true);
@@ -229,3 +299,18 @@ fn read_only_advertises_shell_only_when_explicit_command_approvals_exist() {
assert!(!base.contains(&api::ToolType::ApplyFileDiffs)); assert!(!base.contains(&api::ToolType::ApplyFileDiffs));
assert!(!base.contains(&api::ToolType::RunAgents)); assert!(!base.contains(&api::ToolType::RunAgents));
} }
#[test]
fn explicit_overrides_cannot_restore_interactive_shell_tools() {
let mut params = request_params_with_ask_user_question_enabled(false);
params.supported_tools_override = Some(vec![
api::ToolType::RunShellCommand,
api::ToolType::WriteToLongRunningShellCommand,
api::ToolType::ReadShellCommandOutput,
api::ToolType::TransferShellCommandControlToUser,
]);
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
for tools in [normal, cli] {
assert_eq!(tools, vec![api::ToolType::RunShellCommand]);
}
}
+27
View File
@@ -60,6 +60,7 @@ use crate::ai::blocklist::{
SerializedBlockListItem, SerializedBlockListItem,
}; };
use crate::ai::llms::LLMPreferences; use crate::ai::llms::LLMPreferences;
use crate::ai::runtime::ProviderContextUsage;
use crate::ai::skills::SkillDescriptor; use crate::ai::skills::SkillDescriptor;
use crate::code_review::CodeReviewTelemetryEvent; use crate::code_review::CodeReviewTelemetryEvent;
use crate::notebooks::NotebookId; use crate::notebooks::NotebookId;
@@ -341,6 +342,9 @@ pub struct AIConversation {
progressive_summary: Option<String>, progressive_summary: Option<String>,
messages_summarized_up_to: usize, messages_summarized_up_to: usize,
current_context_tokens: u32, current_context_tokens: u32,
/// The bounded request projection used by direct-provider context budgeting.
/// Recomputed at committed model/tool boundaries rather than restored as live usage.
provider_context_usage: Option<ProviderContextUsage>,
latest_model_call_usage: ProviderUsage, latest_model_call_usage: ProviderUsage,
has_pending_progressive_summary: bool, has_pending_progressive_summary: bool,
} }
@@ -419,6 +423,7 @@ impl AIConversation {
progressive_summary: None, progressive_summary: None,
messages_summarized_up_to: 0, messages_summarized_up_to: 0,
current_context_tokens: 0, current_context_tokens: 0,
provider_context_usage: None,
latest_model_call_usage: ProviderUsage::default(), latest_model_call_usage: ProviderUsage::default(),
has_pending_progressive_summary: false, has_pending_progressive_summary: false,
} }
@@ -732,6 +737,7 @@ impl AIConversation {
// The previous response stream no longer exists; its drain count cannot apply. // The previous response stream no longer exists; its drain count cannot apply.
messages_summarized_up_to: 0, messages_summarized_up_to: 0,
current_context_tokens: 0, current_context_tokens: 0,
provider_context_usage: None,
latest_model_call_usage: ProviderUsage::default(), latest_model_call_usage: ProviderUsage::default(),
has_pending_progressive_summary: false, has_pending_progressive_summary: false,
}) })
@@ -786,6 +792,9 @@ impl AIConversation {
if !can_change_backend && self.agent_backend != agent_backend { if !can_change_backend && self.agent_backend != agent_backend {
return false; return false;
} }
if self.agent_backend != agent_backend {
self.provider_context_usage = None;
}
self.agent_backend = agent_backend; self.agent_backend = agent_backend;
true true
} }
@@ -813,6 +822,24 @@ impl AIConversation {
self.current_context_tokens = val; self.current_context_tokens = val;
} }
pub(crate) fn provider_context_usage(&self) -> Option<&ProviderContextUsage> {
self.provider_context_usage.as_ref()
}
pub(crate) fn set_provider_context_usage(&mut self, usage: Option<ProviderContextUsage>) {
self.provider_context_usage = usage;
}
/// Provider-reported input for the last model call, separate from the next-request estimate.
pub(crate) fn latest_model_call_reported_context_tokens(&self) -> Option<u32> {
let tokens = self
.latest_model_call_usage
.input_tokens
.saturating_add(self.latest_model_call_usage.cached_input_tokens)
.saturating_add(self.latest_model_call_usage.cache_creation_input_tokens);
(tokens > 0).then(|| u32::try_from(tokens).unwrap_or(u32::MAX))
}
pub fn set_latest_model_call_usage(&mut self, usage: ProviderUsage) { pub fn set_latest_model_call_usage(&mut self, usage: ProviderUsage) {
self.current_context_tokens = u32::try_from( self.current_context_tokens = u32::try_from(
usage usage
+24 -3
View File
@@ -754,7 +754,7 @@ pub enum RenderableAIError {
impl RenderableAIError { impl RenderableAIError {
const TRANSIENT_NETWORK_ERROR_MESSAGE: &'static str = const TRANSIENT_NETWORK_ERROR_MESSAGE: &'static str =
"Warp lost connection while receiving the agent response. This is usually temporary."; "Galaxy lost connection while receiving the agent response. This is usually temporary.";
/// User-facing message shown when an agent-issued command exits the shell. /// User-facing message shown when an agent-issued command exits the shell.
pub const AGENT_EXITED_SHELL_MESSAGE: &'static str = pub const AGENT_EXITED_SHELL_MESSAGE: &'static str =
"The shell exited while the agent was running a command, so the run could not continue. Ensure the agent is not asked to run commands or source scripts that can exit the shell."; "The shell exited while the agent was running a command, so the run could not continue. Ensure the agent is not asked to run commands or source scripts that can exit the shell.";
@@ -905,9 +905,9 @@ impl Display for RenderableAIError {
} }
} }
Self::ServerOverloaded => { Self::ServerOverloaded => {
write!(f, "Warp is currently overloaded. Please try again later.") write!(f, "The AI service is currently overloaded. Please try again later.")
} }
Self::InternalWarpError => write!(f, "Internal Warp error."), Self::InternalWarpError => write!(f, "Internal Galaxy error."),
Self::ContextWindowExceeded(message) => { Self::ContextWindowExceeded(message) => {
write!(f, "Context window exceeded: {message}") write!(f, "Context window exceeded: {message}")
} }
@@ -1211,6 +1211,12 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 { match self.0 {
AIAgentActionResultType::RequestCommandOutput(result) => match result { AIAgentActionResultType::RequestCommandOutput(result) => match result {
RequestCommandOutputResult::BackgroundJobStarted { job_id, command } => {
write!(
f,
"\n**Started in background:**\n```bash\n{command}\n```\n\nJob: `{job_id}`. Completion results will arrive when it exits."
)
}
RequestCommandOutputResult::Completed { RequestCommandOutputResult::Completed {
command, output, .. command, output, ..
} => { } => {
@@ -1243,6 +1249,11 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
} }
}, },
AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result {
WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
job_id,
} => {
write!(f, "\n_Cancellation requested for background job `{job_id}`_")
}
WriteToLongRunningShellCommandResult::CommandFinished { output, .. } => { WriteToLongRunningShellCommandResult::CommandFinished { output, .. } => {
write!(f, "\n```\n{output}\n```") write!(f, "\n```\n{output}\n```")
} }
@@ -3078,6 +3089,16 @@ impl AIAgentInput {
) )
} }
/// Background job completion uses a hidden observation, but remains a normal agent turn
/// rather than the legacy tool-free assessment of an interactively monitored command.
pub fn is_background_job_completion(&self) -> bool {
matches!(
self,
Self::CommandCompletionAssessment { completed_command, .. }
if completed_command.block_id.as_str().starts_with("background-job:")
)
}
pub fn context(&self) -> Option<&[AIAgentContext]> { pub fn context(&self) -> Option<&[AIAgentContext]> {
match self { match self {
Self::UserQuery { context, .. } Self::UserQuery { context, .. }
+1 -1
View File
@@ -150,7 +150,7 @@ fn transient_network_error_includes_user_facing_message_and_debug_details() {
let rendered = error.to_string(); let rendered = error.to_string();
assert!( assert!(
rendered.starts_with( rendered.starts_with(
"Warp lost connection while receiving the agent response. This is usually temporary.\n\nDebug info: " "Galaxy lost connection while receiving the agent response. This is usually temporary.\n\nDebug info: "
), ),
"unexpected rendering: {rendered}" "unexpected rendering: {rendered}"
); );
+103
View File
@@ -8,6 +8,81 @@ use crate::ai::runtime::ProviderActionContext;
const TOOL_PREFIX: &str = "galaxy:provider-tool:v1:"; const TOOL_PREFIX: &str = "galaxy:provider-tool:v1:";
pub(crate) fn computer_use_tool(action: &super::AIAgentActionType) -> Option<api::message::tool_call::Tool> {
use api::message::tool_call::{self as tool, use_computer::action as action_api};
use computer_use::{Action, Key, MouseButton, ScrollDirection, ScrollDistance};
let coordinates = |point: computer_use::Vector2I| Some(api::Coordinates { x: point.x(), y: point.y() });
let button = |button: &MouseButton| match button {
MouseButton::Left => action_api::MouseButton::Left as i32,
MouseButton::Right => action_api::MouseButton::Right as i32,
MouseButton::Middle => action_api::MouseButton::Middle as i32,
MouseButton::Back => action_api::MouseButton::Back as i32,
MouseButton::Forward => action_api::MouseButton::Forward as i32,
};
let key = |key: &Key| Some(action_api::Key { data: Some(match key {
Key::Char(ch) => action_api::key::Data::Char(ch.to_string()),
Key::Keycode(code) => action_api::key::Data::Keycode(*code),
}) });
match action {
super::AIAgentActionType::RequestComputerUse(request) => Some(tool::Tool::RequestComputerUse(tool::RequestComputerUse {
task_summary: request.task_summary.clone(),
screenshot_params: request.screenshot_params.map(computer_screenshot),
})),
super::AIAgentActionType::UseComputer(request) => Some(tool::Tool::UseComputer(tool::UseComputer {
action_summary: request.action_summary.clone(),
post_actions_screenshot_params: request.screenshot_params.map(computer_screenshot),
actions: request.actions.iter().map(|targeted| tool::use_computer::Action {
target: computer_target(targeted.target),
r#type: Some(match &targeted.action {
Action::MouseMove { to } => action_api::Type::MouseMove(action_api::MouseMove { to: coordinates(*to) }),
Action::MouseDown { button: b, at } => action_api::Type::MouseDown(action_api::MouseDown { button: button(b), at: coordinates(*at) }),
Action::MouseUp { button: b } => action_api::Type::MouseUp(action_api::MouseUp { button: button(b) }),
Action::MouseWheel { at, direction, distance } => action_api::Type::MouseWheel(action_api::MouseWheel {
at: coordinates(*at),
direction: match direction {
ScrollDirection::Up => action_api::mouse_wheel::Direction::Up as i32,
ScrollDirection::Down => action_api::mouse_wheel::Direction::Down as i32,
ScrollDirection::Left => action_api::mouse_wheel::Direction::Left as i32,
ScrollDirection::Right => action_api::mouse_wheel::Direction::Right as i32,
},
distance: Some(match distance {
ScrollDistance::Pixels(value) => action_api::mouse_wheel::Distance::Pixels(*value),
ScrollDistance::Clicks(value) => action_api::mouse_wheel::Distance::Clicks(*value),
}),
}),
Action::TypeText { text } => action_api::Type::TypeText(action_api::TypeText { text: text.clone() }),
Action::KeyDown { key: k } => action_api::Type::KeyDown(action_api::KeyDown { key: key(k) }),
Action::KeyUp { key: k } => action_api::Type::KeyUp(action_api::KeyUp { key: key(k) }),
Action::Wait(duration) => action_api::Type::Wait(action_api::Wait { duration: Some(prost_types::Duration {
seconds: duration.as_secs() as i64, nanos: duration.subsec_nanos() as i32,
}) }),
}),
}).collect(),
})),
_ => None,
}
}
fn computer_target(target: computer_use::Target) -> Option<api::message::tool_call::ComputerUseTarget> {
use api::message::tool_call::{computer_use_target, ComputerUseTarget};
match target {
computer_use::Target::Screen => None,
computer_use::Target::Window { window_id, pid } => Some(ComputerUseTarget { target: Some(computer_use_target::Target::Window(computer_use_target::Window { window_id: window_id.to_string(), pid })) }),
}
}
fn computer_screenshot(params: computer_use::ScreenshotParams) -> api::message::tool_call::ScreenshotParams {
api::message::tool_call::ScreenshotParams {
max_long_edge_px: params.max_long_edge_px.unwrap_or_default() as i32,
max_total_px: params.max_total_px.unwrap_or_default() as i32,
target: computer_target(params.target),
region: params.region.map(|region| api::message::tool_call::screenshot_params::Region {
top_left: Some(api::Coordinates { x: region.top_left.x(), y: region.top_left.y() }),
bottom_right: Some(api::Coordinates { x: region.bottom_right.x(), y: region.bottom_right.y() }),
}),
}
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SavedTool { struct SavedTool {
context: ProviderActionContext, context: ProviderActionContext,
@@ -106,6 +181,8 @@ struct SavedResultDetails {
cancelled: bool, cancelled: bool,
file_edit: Option<(String, usize, usize)>, file_edit: Option<(String, usize, usize)>,
command_error: Option<(String, String)>, command_error: Option<(String, String)>,
background_job: Option<(String, String)>,
background_job_cancellation: Option<String>,
} }
/// Supplement fields that the server's request/result protocol does not round-trip. /// Supplement fields that the server's request/result protocol does not round-trip.
@@ -148,6 +225,18 @@ pub(crate) fn restore_result_details(
super::RequestCommandOutputResult::ExecutionError { command, message }, super::RequestCommandOutputResult::ExecutionError { command, message },
); );
} }
if let Some((job_id, command)) = details.background_job {
result.result = super::AIAgentActionResultType::RequestCommandOutput(
super::RequestCommandOutputResult::BackgroundJobStarted { job_id, command },
);
}
if let Some(job_id) = details.background_job_cancellation {
result.result = super::AIAgentActionResultType::WriteToLongRunningShellCommand(
super::WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
job_id,
},
);
}
} }
// FileGlob remains part of saved history even though the server deprecated it. // FileGlob remains part of saved history even though the server deprecated it.
@@ -174,6 +263,20 @@ pub(crate) fn result_message(result: &AIAgentActionResult) -> anyhow::Result<api
) => Some((command.clone(), message.clone())), ) => Some((command.clone(), message.clone())),
_ => None, _ => None,
}, },
background_job: match &result.result {
super::AIAgentActionResultType::RequestCommandOutput(
super::RequestCommandOutputResult::BackgroundJobStarted { job_id, command },
) => Some((job_id.clone(), command.clone())),
_ => None,
},
background_job_cancellation: match &result.result {
super::AIAgentActionResultType::WriteToLongRunningShellCommand(
super::WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
job_id,
},
) => Some(job_id.clone()),
_ => None,
},
}; };
let input: api::request::input::user_inputs::user_input::Input = result.clone().try_into()?; let input: api::request::input::user_inputs::user_input::Input = result.clone().try_into()?;
let api::request::input::user_inputs::user_input::Input::ToolCallResult(input) = input else { let api::request::input::user_inputs::user_input::Input::ToolCallResult(input) = input else {
@@ -24,6 +24,29 @@ fn query(id: &str) -> api::Message {
} }
} }
#[test]
fn computer_tool_display_and_native_restoration_preserve_the_same_actions() {
let context = ProviderActionContext::new_for_test("task");
let call = ToolCall {
id: "desktop-call".into(), name: "use_computer".into(), arguments: json!({
"action_summary": "Type and press Return", "actions": [
{ "type": "type_text", "text": "hello" },
{ "type": "key_down", "keycode": 36 },
{ "type": "key_up", "keycode": 36 },
],
}),
};
let action = context.action_from_tool_call(&call).unwrap();
let message = tool_message(&context, &call, &action).unwrap();
let Some(api::message::Message::ToolCall(display)) = message.message else { panic!("expected tool display"); };
let Some(api::message::tool_call::Tool::UseComputer(display)) = display.tool else { panic!("expected computer display"); };
let display_action = crate::ai::agent::AIAgentActionType::try_from(display).unwrap();
assert_eq!(display_action, action.action);
let restored = super::restore_tool(&message.server_message_data).unwrap().unwrap();
assert_eq!(restored.action, action.action);
assert_eq!(restored.id, action.id);
}
#[test] #[test]
fn saved_tools_and_results_restore_as_individual_panes_across_turns() { fn saved_tools_and_results_restore_as_individual_panes_across_turns() {
let context = ProviderActionContext::new_for_test("task"); let context = ProviderActionContext::new_for_test("task");
+10 -2
View File
@@ -128,16 +128,24 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
redact_context(Arc::make_mut(context)); redact_context(Arc::make_mut(context));
match &mut result.result { match &mut result.result {
AIAgentActionResultType::RequestCommandOutput(output) => { AIAgentActionResultType::RequestCommandOutput(output) => {
if let RequestCommandOutputResult::Completed { output, .. } = output { match output {
RequestCommandOutputResult::BackgroundJobStarted {
command, ..
} => redact_secrets(command),
RequestCommandOutputResult::Completed { output, .. } => {
redact_secrets(output); redact_secrets(output);
} }
_ => {}
}
} }
AIAgentActionResultType::WriteToLongRunningShellCommand(result) => { AIAgentActionResultType::WriteToLongRunningShellCommand(result) => {
use crate::ai::agent::WriteToLongRunningShellCommandResult::*; use crate::ai::agent::WriteToLongRunningShellCommandResult::*;
match result { match result {
Snapshot { grid_contents, .. } => redact_secrets(grid_contents), Snapshot { grid_contents, .. } => redact_secrets(grid_contents),
CommandFinished { output, .. } => redact_secrets(output), CommandFinished { output, .. } => redact_secrets(output),
Error(_) | Cancelled => {} BackgroundJobCancellationRequested { .. }
| Error(_)
| Cancelled => {}
} }
} }
AIAgentActionResultType::ReadShellCommandOutput(result) => { AIAgentActionResultType::ReadShellCommandOutput(result) => {
+10
View File
@@ -83,8 +83,18 @@ impl AIAgentTodoList {
.position(|item| item.id == completed_todo_id.clone().into()) .position(|item| item.id == completed_todo_id.clone().into())
.map(|i| self.pending_items.remove(i)) .map(|i| self.pending_items.remove(i))
{ {
// Restored provider batches can redeliver a pending update and
// its completion. Keep one completed item per stable task ID.
if let Some(previous) = self
.completed_items
.iter_mut()
.find(|old| old.id == item.id)
{
*previous = item;
} else {
self.completed_items.push(item); self.completed_items.push(item);
} }
} }
} }
}
} }
+104
View File
@@ -284,6 +284,16 @@ pub struct AgentManagementFilters {
} }
impl AgentManagementFilters { impl AgentManagementFilters {
/// Retain only filters supported by the local session history screen.
/// Older app state may still contain cloud-only filter selections.
pub fn for_local_history(&self) -> Self {
Self {
status: self.status,
created_on: self.created_on,
..Default::default()
}
}
pub fn reset_all_but_owner(&mut self) { pub fn reset_all_but_owner(&mut self) {
self.status = StatusFilter::default(); self.status = StatusFilter::default();
self.source = SourceFilter::default(); self.source = SourceFilter::default();
@@ -1204,6 +1214,98 @@ impl AgentConversationsModel {
} }
} }
/// Returns locally recoverable sessions without hosted run or server-only rows.
pub fn get_local_entries(
&self,
filters: &AgentManagementFilters,
app: &AppContext,
) -> Vec<AgentConversationEntry> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let filters = filters.for_local_history();
let mut entries = HashMap::new();
for metadata in self.conversations.values() {
let entry = entry::entry_for_conversation(metadata, history_model, app);
// Metadata from a live local session can precede its first disk write.
if !entry.backing.has_ambient_run
&& (entry.backing.has_loaded_conversation
|| entry.backing.has_local_persisted_data
|| !entry.backing.has_cloud_data)
{
entries.insert(entry.id, entry.for_local_history());
}
}
for metadata in history_model.get_local_conversations_metadata() {
if !metadata.has_local_data && history_model.conversation(&metadata.id).is_none() {
continue;
}
let id = AgentConversationEntryId::Conversation(metadata.id);
entries.entry(id).or_insert_with(|| {
let nav_data =
ConversationNavigationData::from_historical_conversation_metadata(metadata);
entry::entry_for_historical_metadata(metadata, nav_data, history_model, app)
.for_local_history()
});
}
entries
.into_values()
.filter(|entry| entry.matches_filters(&filters, app))
.sorted_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated))
.collect()
}
pub fn get_local_entry_by_id(
&self,
id: &AgentConversationEntryId,
app: &AppContext,
) -> Option<AgentConversationEntry> {
let AgentConversationEntryId::Conversation(conversation_id) = id else {
return None;
};
let entry = self.get_entry_by_id(id, app)?;
(!entry.backing.has_ambient_run
&& (entry.backing.has_loaded_conversation
|| entry.backing.has_local_persisted_data
|| (!entry.backing.has_cloud_data
&& self.conversations.contains_key(conversation_id))))
.then(|| entry.for_local_history())
}
/// Resolve history navigation exclusively through an existing local pane or disk data.
pub fn resolve_local_open_action(
id: AgentConversationEntryId,
restore_layout: Option<RestoreConversationLayout>,
app: &AppContext,
) -> Option<WorkspaceAction> {
let model = Self::as_ref(app);
let entry = model.get_local_entry_by_id(&id, app)?;
let conversation_id = entry.identity.local_conversation_id?;
let active_views = ActiveAgentViewsModel::as_ref(app);
let nav_data = model
.conversations
.get(&conversation_id)
.map(|metadata| &metadata.nav_data);
if active_views.is_conversation_open(conversation_id, app) && nav_data.is_none() {
return active_views
.get_terminal_view_id_for_conversation(conversation_id, app)
.map(|terminal_view_id| WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id,
});
}
Some(WorkspaceAction::RestoreOrNavigateToConversation {
conversation_id,
window_id: nav_data.and_then(|data| data.window_id),
pane_view_locator: nav_data.and_then(|data| data.pane_view_locator),
terminal_view_id: nav_data.and_then(|data| data.terminal_view_id),
restore_layout,
local_only: true,
})
}
/// Returns normalized, owned entries for agent management/navigation surfaces. /// Returns normalized, owned entries for agent management/navigation surfaces.
pub fn get_entries( pub fn get_entries(
&self, &self,
@@ -1380,6 +1482,7 @@ impl AgentConversationsModel {
pane_view_locator: nav_data.pane_view_locator, pane_view_locator: nav_data.pane_view_locator,
terminal_view_id: nav_data.terminal_view_id, terminal_view_id: nav_data.terminal_view_id,
restore_layout, restore_layout,
local_only: false,
}); });
} }
@@ -1409,6 +1512,7 @@ impl AgentConversationsModel {
pane_view_locator: None, pane_view_locator: None,
terminal_view_id: nav_data.and_then(|nav_data| nav_data.terminal_view_id), terminal_view_id: nav_data.and_then(|nav_data| nav_data.terminal_view_id),
restore_layout, restore_layout,
local_only: false,
}); });
} }
} }
@@ -166,6 +166,26 @@ pub struct AgentConversationCapabilities {
} }
impl AgentConversationEntry { impl AgentConversationEntry {
/// Local history never offers hosted attachment, sharing, or server recovery.
pub(super) fn for_local_history(mut self) -> Self {
self.provenance = AgentConversationProvenance::LocalInteractive;
self.identity.ambient_agent_task_id = None;
self.identity.server_conversation_token = None;
self.identity.session_id = None;
self.display.session_status = None;
self.display.source = None;
self.display.environment_id = None;
self.display.harness = None;
self.display.request_usage = None;
self.display.executor = None;
self.capabilities.can_open = true;
self.capabilities.can_copy_link = false;
self.capabilities.can_share = false;
self.capabilities.can_fork_locally = false;
self.capabilities.can_cancel = false;
self
}
pub(super) fn matches_filters( pub(super) fn matches_filters(
&self, &self,
filters: &AgentManagementFilters, filters: &AgentManagementFilters,
+215 -3
View File
@@ -16,7 +16,8 @@ use super::entry::{
use super::{ use super::{
record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel, record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel,
AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter,
ConversationMetadata, ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, ConversationMetadata, ConversationUpdateKind, CreatedOnFilter, CreatorFilter,
EnvironmentFilter, HarnessFilter, OwnerFilter, SourceFilter,
RtcTaskRefreshThrottleState, StatusFilter, TaskFetchError, TaskFetchState, MAX_PERSONAL_TASKS, RtcTaskRefreshThrottleState, StatusFilter, TaskFetchError, TaskFetchState, MAX_PERSONAL_TASKS,
MAX_TEAM_TASKS, MAX_TEAM_TASKS,
}; };
@@ -28,11 +29,12 @@ use crate::ai::agent::conversation::{
}; };
use crate::ai::ambient_agents::task::{HarnessConfig, TaskPrincipalInfo, TaskStatusMessage}; use crate::ai::ambient_agents::task::{HarnessConfig, TaskPrincipalInfo, TaskStatusMessage};
use crate::ai::ambient_agents::{ use crate::ai::ambient_agents::{
AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState, AgentConfigSnapshot, AgentSource, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState,
}; };
use crate::ai::artifacts::Artifact; use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::history_model::{ use crate::ai::blocklist::history_model::{
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatusUpdate, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData,
ConversationStatusUpdate,
}; };
use crate::ai::conversation_navigation::ConversationNavigationData; use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::auth::AuthStateProvider; use crate::auth::AuthStateProvider;
@@ -814,6 +816,216 @@ fn create_server_conversation_metadata(
} }
} }
#[test]
fn local_history_discards_obsolete_filters_but_preserves_status_and_date() {
let filters = AgentManagementFilters {
owners: OwnerFilter::All,
status: StatusFilter::Failed,
source: SourceFilter::Specific(AgentSource::CloudMode),
created_on: CreatedOnFilter::LastWeek,
creator: CreatorFilter::Specific {
name: "Former team".to_string(),
uid: "former-user".to_string(),
},
artifact: ArtifactFilter::PullRequest,
environment: EnvironmentFilter::Specific("old-environment".to_string()),
harness: HarnessFilter::Specific(Harness::Oz),
};
assert_eq!(
filters.for_local_history(),
AgentManagementFilters {
status: StatusFilter::Failed,
created_on: CreatedOnFilter::LastWeek,
..Default::default()
}
);
}
#[test]
fn local_history_keeps_saved_legacy_sessions_and_excludes_hosted_rows() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
let conversation_id = AIConversationId::new();
let task_id = "saved-root-task";
let saved = persistence::model::AgentConversation {
conversation: persistence::model::AgentConversationRecord {
id: 0,
conversation_id: conversation_id.to_string(),
conversation_data: serde_json::to_string(&AgentConversationData {
server_conversation_token: Some("legacy-saved-token".to_string()),
..Default::default()
})
.unwrap(),
last_modified_at: Utc::now().naive_utc(),
},
tasks: vec![create_api_task(
task_id,
vec![create_message("saved-message", task_id)],
)],
};
let history =
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[saved]));
history.update(&mut app, |model, _| {
model.merge_cloud_conversation_metadata(vec![create_server_conversation_metadata(
"Remote-only conversation",
"remote-only-token",
None,
)]);
});
app.add_singleton_model(|_| {
let mut model = create_test_model();
let task = create_test_task(&make_uuid(8400), "user-a", Utc::now());
model.tasks.insert(task.task_id, task);
model
});
app.update(|ctx| {
let model = AgentConversationsModel::as_ref(ctx);
let entries = model.get_local_entries(&AgentManagementFilters::default(), ctx);
assert_eq!(entries.len(), 1);
let entry = &entries[0];
assert_eq!(entry.identity.local_conversation_id, Some(conversation_id));
assert!(entry.identity.server_conversation_token.is_none());
assert!(entry.display.harness.is_none());
assert!(!entry.capabilities.can_share);
assert!(!entry.capabilities.can_copy_link);
assert!(model.get_local_entry_by_id(&entry.id, ctx).is_some());
assert!(matches!(
AgentConversationsModel::resolve_local_open_action(entry.id, None, ctx),
Some(WorkspaceAction::RestoreOrNavigateToConversation {
conversation_id: restored_id,
local_only: true,
..
}) if restored_id == conversation_id
));
let remote_id = history
.as_ref(ctx)
.find_conversation_id_by_server_token(&ServerConversationToken::new(
"remote-only-token".to_string(),
))
.unwrap();
assert!(AgentConversationsModel::resolve_local_open_action(
AgentConversationEntryId::Conversation(remote_id), None, ctx,
).is_none());
});
});
}
#[test]
fn local_history_loader_does_not_fetch_missing_cloud_data() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);
let history = BlocklistAIHistoryModel::handle(&app);
history.update(&mut app, |model, _| {
model.merge_cloud_conversation_metadata(vec![create_server_conversation_metadata(
"Remote-only conversation",
"remote-only-token",
None,
)]);
});
let future = app.update(|ctx| {
let model = history.as_ref(ctx);
let id = model
.find_conversation_id_by_server_token(&ServerConversationToken::new(
"remote-only-token".to_string(),
))
.unwrap();
model.load_local_conversation_data(id)
});
// No ServerApiProvider is installed: any accidental server fallback would fail.
assert!(future.await.is_none());
});
}
#[test]
fn local_history_recovers_current_live_state_without_hosted_task_shadowing() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);
let conversation_id = AIConversationId::new();
let run_id = make_uuid(8401);
let conversation = create_restored_conversation(
conversation_id,
"live-root-task",
AgentConversationData {
run_id: Some(run_id.clone()),
..Default::default()
},
);
let history = BlocklistAIHistoryModel::handle(&app);
history.update(&mut app, |model, ctx| {
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
model
.conversation_mut(&conversation_id)
.unwrap()
.set_last_event_sequence(42);
});
let mut model = create_test_model();
let task = create_test_task(&run_id, "user-a", Utc::now());
model.tasks.insert(task.task_id, task);
let future = app.update(|ctx| {
let entries = model.get_local_entries(&AgentManagementFilters::default(), ctx);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].id,
AgentConversationEntryId::Conversation(conversation_id)
);
assert!(entries[0].identity.ambient_agent_task_id.is_none());
assert!(model.get_local_entry_by_id(&entries[0].id, ctx).is_some());
history
.as_ref(ctx)
.load_local_conversation_data(conversation_id)
});
let Some(CloudConversationData::Oz(restored)) = future.await else {
panic!("live local session should restore without disk or server access");
};
assert_eq!(restored.id(), conversation_id);
assert_eq!(restored.last_event_sequence(), Some(42));
});
}
#[test]
fn local_history_excludes_cached_ambient_sessions_from_list_and_lookup() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);
let conversation_id = AIConversationId::new();
let task_id = make_uuid(8402).parse().unwrap();
let mut conversation = create_restored_conversation(
conversation_id,
"ambient-root-task",
AgentConversationData::default(),
);
conversation.set_server_metadata(create_server_conversation_metadata(
"Cached hosted run",
"cached-ambient-token",
Some(task_id),
));
let history = BlocklistAIHistoryModel::handle(&app);
history.update(&mut app, |model, ctx| {
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
});
let mut model = create_test_model();
model.conversations.insert(
conversation_id,
create_test_conversation_metadata(conversation_id, "Cached hosted run"),
);
let future = app.update(|ctx| {
assert!(model
.get_local_entries(&AgentManagementFilters::default(), ctx)
.is_empty());
assert!(model
.get_local_entry_by_id(
&AgentConversationEntryId::Conversation(conversation_id),
ctx,
)
.is_none());
history
.as_ref(ctx)
.load_local_conversation_data(conversation_id)
});
assert!(future.await.is_none());
});
}
#[test] #[test]
fn test_get_entries_includes_task_only_entry() { fn test_get_entries_includes_task_only_entry() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -249,7 +249,7 @@ impl AgentNotificationsModel {
terminal_surface_id, terminal_surface_id,
conversation_id, conversation_id,
// We shouldn't trigger toasts when restoring conversations on startup. // We shouldn't trigger toasts when restoring conversations on startup.
update: ConversationStatusUpdate::Changed { .. }, update: ConversationStatusUpdate::Changed { prev_status },
.. ..
} = event } = event
else { else {
@@ -266,6 +266,21 @@ impl AgentNotificationsModel {
} }
let status = updated_conversation.status().clone(); let status = updated_conversation.status().clone();
if &status == prev_status {
return;
}
// Workers report their results to the orchestrator. Only a worker that
// needs user input should interrupt the user independently.
if updated_conversation.is_child_agent_conversation()
&& !status.should_notify_for_agent(true)
{
self.pending_artifacts.remove(conversation_id);
self.remove_notification_by_source(
NotificationOrigin::Conversation(*conversation_id),
ctx,
);
return;
}
let latest_query = updated_conversation.latest_user_query(); let latest_query = updated_conversation.latest_user_query();
if FeatureFlag::HOANotifications.is_enabled() { if FeatureFlag::HOANotifications.is_enabled() {
self.handle_history_event_for_mailbox( self.handle_history_event_for_mailbox(
@@ -283,6 +298,12 @@ impl AgentNotificationsModel {
return; return;
} }
if matches!(status, ConversationStatus::Success)
&& QueuedQueryModel::as_ref(ctx).has_autofireable_prompt(*conversation_id)
{
return;
}
if is_terminal_view_visible(*terminal_surface_id, ctx) { if is_terminal_view_visible(*terminal_surface_id, ctx) {
return; return;
} }
@@ -354,18 +375,10 @@ impl AgentNotificationsModel {
); );
} }
ConversationStatus::Cancelled => { ConversationStatus::Cancelled => {
let artifacts = self.flush_pending_artifacts(conversation_id); // Steering and replacing a turn cancels the old generation;
self.add_notification( // that is not a separate task-completion notification.
title, self.pending_artifacts.remove(&conversation_id);
"Task was cancelled.".to_owned(), self.remove_notification_by_source(origin, ctx);
NotificationCategory::Complete,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
} }
ConversationStatus::Blocked { blocked_action } => { ConversationStatus::Blocked { blocked_action } => {
self.add_notification( self.add_notification(
@@ -484,6 +497,10 @@ pub enum AgentManagementEvent {
} }
impl ConversationStatus { impl ConversationStatus {
pub(crate) fn should_notify_for_agent(&self, is_child: bool) -> bool {
self.should_trigger_notification() && (!is_child || self.is_blocked())
}
/// Returns true if the updating the conversation with this status should trigger some /// Returns true if the updating the conversation with this status should trigger some
/// notification to the user. /// notification to the user.
/// ///
@@ -297,6 +297,36 @@ fn should_trigger_notification_returns_false_for_cancelled() {
assert!(!ConversationStatus::Cancelled.should_trigger_notification()); assert!(!ConversationStatus::Cancelled.should_trigger_notification());
} }
#[test]
fn worker_results_do_not_interrupt_the_orchestrator() {
for status in [ConversationStatus::Success, ConversationStatus::Error] {
assert!(!status.should_notify_for_agent(true));
assert!(status.should_notify_for_agent(false));
}
}
#[test]
fn worker_permission_requests_still_notify() {
let status = ConversationStatus::Blocked {
blocked_action: "Approve deployment".to_owned(),
};
assert!(status.should_notify_for_agent(true));
assert!(status.should_notify_for_agent(false));
}
#[test]
fn running_recovering_and_cancelled_agents_do_not_notify() {
for status in [
ConversationStatus::InProgress,
ConversationStatus::TransientError,
ConversationStatus::WaitingForEvents,
ConversationStatus::Cancelled,
] {
assert!(!status.should_notify_for_agent(true));
assert!(!status.should_notify_for_agent(false));
}
}
// Mailbox suppression for non-terminal status updates. In App::test the // Mailbox suppression for non-terminal status updates. In App::test the
// `is_conversation_open` gate always returns false, so the // `is_conversation_open` gate always returns false, so the
// WaitingForEvents and InProgress arms both clear stale notifications // WaitingForEvents and InProgress arms both clear stale notifications
+59 -118
View File
@@ -36,7 +36,7 @@ use siphasher::sip::SipHasher;
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{ use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationEntry, AgentConversationEntryId,
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter, ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
OwnerFilter, SessionStatus, SourceFilter, StatusFilter, OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
@@ -55,7 +55,6 @@ use crate::ai::agent_management::telemetry::{
}; };
use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource}; use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource};
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent}; use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
use crate::ai::blocklist::format_credits;
use crate::ai::conversation_details_panel::{ use crate::ai::conversation_details_panel::{
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent, ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
}; };
@@ -89,7 +88,6 @@ use crate::workflows::WorkflowType;
use crate::workspace::{ use crate::workspace::{
ForkedConversationDestination, RestoreConversationLayout, ToastStack, WorkspaceAction, ForkedConversationDestination, RestoreConversationLayout, ToastStack, WorkspaceAction,
}; };
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{send_telemetry_from_ctx, AgentModeEntrypoint}; use crate::{send_telemetry_from_ctx, AgentModeEntrypoint};
lazy_static! { lazy_static! {
@@ -311,7 +309,7 @@ impl AgentManagementView {
}, },
ctx, ctx,
); );
editor.set_placeholder_text("Search", ctx); editor.set_placeholder_text("Search local sessions", ctx);
editor editor
}); });
ctx.subscribe_to_view(&search_editor, |me, _handle, event, ctx| { ctx.subscribe_to_view(&search_editor, |me, _handle, event, ctx| {
@@ -319,7 +317,7 @@ impl AgentManagementView {
}); });
let new_agent_button = CompactibleActionButton::new( let new_agent_button = CompactibleActionButton::new(
"New agent".to_string(), "New session".to_string(),
None, None,
ButtonSize::Small, ButtonSize::Small,
AgentManagementViewAction::ShowAgentTypeSelector, AgentManagementViewAction::ShowAgentTypeSelector,
@@ -333,7 +331,9 @@ impl AgentManagementView {
let has_dismissed_setup_guide = *AISettings::as_ref(ctx).did_dismiss_cloud_setup_guide; let has_dismissed_setup_guide = *AISettings::as_ref(ctx).did_dismiss_cloud_setup_guide;
let filters = persisted_filters.map(|p| p.filters).unwrap_or_default(); let filters = persisted_filters
.map(|persisted| persisted.filters.for_local_history())
.unwrap_or_default();
let details_panel: ViewHandle<ConversationDetailsPanel> = let details_panel: ViewHandle<ConversationDetailsPanel> =
ctx.add_typed_action_view(|ctx| { ctx.add_typed_action_view(|ctx| {
@@ -378,28 +378,17 @@ impl AgentManagementView {
view.update_creator_dropdown(ctx); view.update_creator_dropdown(ctx);
view.update_environment_dropdown(ctx); view.update_environment_dropdown(ctx);
// Trigger server fetch if persisted filters differ from defaults
// (team tasks are not loaded at startup, so we need to fetch them)
if view.filters != AgentManagementFilters::default() {
view.trigger_filter_fetch(ctx);
}
view.get_tasks_from_model(ctx); view.get_tasks_from_model(ctx);
view view
} }
fn get_view_state(&self, app: &AppContext) -> ViewState { fn get_view_state(&self, app: &AppContext) -> ViewState {
let model = AgentConversationsModel::as_ref(app); let model = AgentConversationsModel::as_ref(app);
let has_items = model.has_items(); let has_items = !model
.get_local_entries(&AgentManagementFilters::default(), app)
.is_empty();
// If loading with zero items, show skeleton cards if !has_items {
// If loading with items, show list of interactive conversations (with loading indicator in header)
if model.is_loading() && !has_items {
return ViewState::Loading;
}
// Show setup guide if: no items (zero state) or user clicked button to toggle on the guide
if !has_items || self.is_viewing_setup_guide {
return ViewState::SetupGuide { has_items }; return ViewState::SetupGuide { has_items };
} }
@@ -866,7 +855,7 @@ impl AgentManagementView {
/// Common handler for filter changes: fetch, refresh tasks, and save. /// Common handler for filter changes: fetch, refresh tasks, and save.
fn on_filter_changed(&mut self, ctx: &mut ViewContext<Self>) { fn on_filter_changed(&mut self, ctx: &mut ViewContext<Self>) {
self.trigger_filter_fetch(ctx); self.filters = self.filters.for_local_history();
self.get_tasks_from_model(ctx); self.get_tasks_from_model(ctx);
ctx.dispatch_global_action("workspace:save_app", ()); ctx.dispatch_global_action("workspace:save_app", ());
} }
@@ -888,11 +877,9 @@ impl AgentManagementView {
/// Shows the setup guide from a deep-link/action without toggling it off on repeated calls. /// Shows the setup guide from a deep-link/action without toggling it off on repeated calls.
pub(crate) fn show_setup_guide_from_link(&mut self, ctx: &mut ViewContext<Self>) { pub(crate) fn show_setup_guide_from_link(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_viewing_setup_guide { // Older links may target cloud onboarding. Keep them within local history.
send_telemetry_from_ctx!(AgentManagementTelemetryEvent::OpenSetupGuide, ctx); self.is_viewing_setup_guide = false;
} self.get_tasks_from_model(ctx);
self.is_viewing_setup_guide = true;
ctx.notify();
} }
#[cfg(test)] #[cfg(test)]
@@ -905,10 +892,10 @@ impl AgentManagementView {
environment_id: String, environment_id: String,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
// This navigation should show the team/global task runs list. // A legacy environment link must not invisibly filter local sessions.
self.filters.owners = OwnerFilter::All; let _ = environment_id;
self.filters.owners = OwnerFilter::PersonalOnly;
self.filters.reset_all_but_owner(); self.filters.reset_all_but_owner();
self.filters.environment = EnvironmentFilter::Specific(environment_id);
self.update_filter_buttons(ctx); self.update_filter_buttons(ctx);
// Clear search query. // Clear search query.
@@ -952,7 +939,7 @@ impl AgentManagementView {
let model = AgentConversationsModel::as_ref(ctx); let model = AgentConversationsModel::as_ref(ctx);
let search_query = self.search_query.trim().to_lowercase(); let search_query = self.search_query.trim().to_lowercase();
let cards: Vec<CardData> = model let cards: Vec<CardData> = model
.get_entries(&self.filters, ctx) .get_local_entries(&self.filters, ctx)
.into_iter() .into_iter()
.filter(|entry| { .filter(|entry| {
if search_query.is_empty() { if search_query.is_empty() {
@@ -962,16 +949,7 @@ impl AgentManagementView {
}) })
.map(|entry| { .map(|entry| {
let item_id = entry.id; let item_id = entry.id;
let copy_link_url = entry let copy_link_url = None;
.capabilities
.can_copy_link
.then(|| {
AgentConversationsModel::resolve_copy_link(
AgentConversationNavigationSubject::Entry(entry.id),
ctx,
)
})
.flatten();
let mut config = Self::action_buttons_config_for_entry(&entry, None, copy_link_url); let mut config = Self::action_buttons_config_for_entry(&entry, None, copy_link_url);
if FeatureFlag::AgentManagementDetailsView.is_enabled() { if FeatureFlag::AgentManagementDetailsView.is_enabled() {
config.view_details_item_id = Some(item_id); config.view_details_item_id = Some(item_id);
@@ -1100,6 +1078,9 @@ impl AgentManagementView {
{ {
config.delete_conversation_id = None; config.delete_conversation_id = None;
} }
if !entry.capabilities.can_fork_locally {
config.fork_conversation_id = None;
}
config config
} }
@@ -1184,7 +1165,7 @@ impl AgentManagementView {
AgentDetailsButtonEvent::DeleteConversation { conversation_id } => { AgentDetailsButtonEvent::DeleteConversation { conversation_id } => {
let model = AgentConversationsModel::as_ref(ctx); let model = AgentConversationsModel::as_ref(ctx);
let conversation_title = model let conversation_title = model
.get_entry_by_id(item_id, ctx) .get_local_entry_by_id(item_id, ctx)
.map(|entry| entry.display.title) .map(|entry| entry.display.title)
.unwrap_or_else(|| "Conversation".to_string()); .unwrap_or_else(|| "Conversation".to_string());
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog { ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
@@ -1341,18 +1322,15 @@ impl AgentManagementView {
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
let model = AgentConversationsModel::as_ref(ctx); let model = AgentConversationsModel::as_ref(ctx);
let Some(entry) = model.get_entry_by_id(item_id, ctx) else { let Some(entry) = model.get_local_entry_by_id(item_id, ctx) else {
return; return;
}; };
let open_action = AgentConversationsModel::resolve_open_action( let open_action = AgentConversationsModel::resolve_local_open_action(
AgentConversationNavigationSubject::Entry(*item_id), *item_id,
Some(RestoreConversationLayout::NewTab), Some(RestoreConversationLayout::NewTab),
ctx, ctx,
); );
let copy_link_url = AgentConversationsModel::resolve_copy_link( let copy_link_url = None;
AgentConversationNavigationSubject::Entry(*item_id),
ctx,
);
let task = entry let task = entry
.identity .identity
.ambient_agent_task_id .ambient_agent_task_id
@@ -1362,7 +1340,8 @@ impl AgentManagementView {
task.as_ref(), task.as_ref(),
open_action, open_action,
copy_link_url, copy_link_url,
); )
.for_local_history();
self.details_panel.update(ctx, |p, ctx| { self.details_panel.update(ctx, |p, ctx| {
p.set_conversation_details(data, ctx); p.set_conversation_details(data, ctx);
@@ -1377,7 +1356,7 @@ impl AgentManagementView {
) { ) {
let model = AgentConversationsModel::as_ref(ctx); let model = AgentConversationsModel::as_ref(ctx);
let Some((index, entry)) = self.items.iter().enumerate().find_map(|(index, card)| { let Some((index, entry)) = self.items.iter().enumerate().find_map(|(index, card)| {
let entry = model.get_entry_by_id(&card.item_id, ctx)?; let entry = model.get_local_entry_by_id(&card.item_id, ctx)?;
(entry.identity.local_conversation_id == Some(conversation_id)) (entry.identity.local_conversation_id == Some(conversation_id))
.then_some((index, entry)) .then_some((index, entry))
}) else { }) else {
@@ -1638,7 +1617,7 @@ impl AgentManagementView {
}; };
let model = AgentConversationsModel::as_ref(app); let model = AgentConversationsModel::as_ref(app);
let Some(entry) = model.get_entry_by_id(&card_state.item_id, app) else { let Some(entry) = model.get_local_entry_by_id(&card_state.item_id, app) else {
return Empty::new().finish(); return Empty::new().finish();
}; };
@@ -1673,7 +1652,7 @@ impl AgentManagementView {
let mut card_content = Flex::column() let mut card_content = Flex::column()
.with_spacing(CARD_ROW_SPACING) .with_spacing(CARD_ROW_SPACING)
.with_child(Self::render_header_row(card_state, entry, appearance)) .with_child(Self::render_header_row(card_state, entry, appearance))
.with_child(Self::render_metadata_row(entry, appearance, app)); .with_child(Self::render_metadata_row(entry, appearance));
// Add artifacts row if there is a buttons view // Add artifacts row if there is a buttons view
if let Some(buttons_element) = artifact_buttons_element { if let Some(buttons_element) = artifact_buttons_element {
@@ -1816,25 +1795,14 @@ impl AgentManagementView {
fn render_metadata_row( fn render_metadata_row(
entry: &AgentConversationEntry, entry: &AgentConversationEntry,
appearance: &Appearance, appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let theme = appearance.theme(); let theme = appearance.theme();
let font_family = appearance.ui_font_family(); let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size(); let font_size = appearance.ui_font_size();
let mut metadata_parts = Vec::new(); let mut metadata_parts = Vec::new();
if let Some(source) = &entry.display.source { if let Some(directory) = &entry.display.working_directory {
metadata_parts.push(format!("Source: {}", source.display_name())); metadata_parts.push(directory.clone());
}
let availability = HarnessAvailabilityModel::as_ref(app);
if availability.should_show_harness_selector() {
if let Some(harness) = entry.display.harness {
metadata_parts.push(format!(
"Harness: {}",
availability.display_name_for(harness)
));
}
} }
if let Some(executor) = &entry.display.executor { if let Some(executor) = &entry.display.executor {
@@ -1859,8 +1827,8 @@ impl AgentManagementView {
metadata_parts.push(format!("Run time: {run_time}")); metadata_parts.push(format!("Run time: {run_time}"));
} }
if let Some(usage) = entry.display.request_usage.map(format_credits) { if metadata_parts.is_empty() {
metadata_parts.push(format!("Credits used: {usage}")); metadata_parts.push("Saved on this device".to_string());
} }
Text::new(metadata_parts.join(" • "), font_family, font_size) Text::new(metadata_parts.join(" • "), font_family, font_size)
@@ -1870,13 +1838,7 @@ impl AgentManagementView {
// Render the main page header based on the current view state // Render the main page header based on the current view state
fn render_header(&self, app: &AppContext) -> Box<dyn Element> { fn render_header(&self, app: &AppContext) -> Box<dyn Element> {
match self.get_view_state(app) { self.render_task_list_header(app)
ViewState::Loading => self.render_loading_header(app),
ViewState::SetupGuide {
has_items: has_tasks,
} => self.render_setup_guide_header(has_tasks, app),
ViewState::NoFilterMatches | ViewState::HasTasks => self.render_task_list_header(app),
}
} }
fn render_setup_guide_header(&self, has_tasks: bool, app: &AppContext) -> Box<dyn Element> { fn render_setup_guide_header(&self, has_tasks: bool, app: &AppContext) -> Box<dyn Element> {
@@ -1920,17 +1882,11 @@ impl AgentManagementView {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
let is_loading = AgentConversationsModel::handle(app)
.as_ref(app)
.is_loading();
let is_on_team = UserWorkspaces::as_ref(app).current_team().is_some();
let size_switch_threshold = MEDIUM_SIZE_SWITCH_THRESHOLD * appearance.monospace_ui_scalar(); let size_switch_threshold = MEDIUM_SIZE_SWITCH_THRESHOLD * appearance.monospace_ui_scalar();
let build_header = |use_expanded: bool| { let build_header = |use_expanded: bool| {
let title = Text::new_inline( let title = Text::new_inline(
"Runs", "AI sessions",
appearance.ui_font_family(), appearance.ui_font_family(),
appearance.ui_font_size() + 4., appearance.ui_font_size() + 4.,
) )
@@ -1938,12 +1894,6 @@ impl AgentManagementView {
.with_color(theme.active_ui_text_color().into()) .with_color(theme.active_ui_text_color().into())
.finish(); .finish();
let setup_guide_button = if use_expanded {
self.setup_guide_button.expanded_button()
} else {
self.setup_guide_button.compact_button()
};
let new_agent_button = if use_expanded { let new_agent_button = if use_expanded {
self.new_agent_button.expanded_button() self.new_agent_button.expanded_button()
} else { } else {
@@ -1956,17 +1906,7 @@ impl AgentManagementView {
.with_spacing(4.) .with_spacing(4.)
.with_child(Container::new(title).with_margin_right(12.).finish()); .with_child(Container::new(title).with_margin_right(12.).finish());
if is_on_team {
header_top.add_child(ChildView::new(&self.personal_filter_button).finish());
header_top.add_child(ChildView::new(&self.all_filter_button).finish());
}
if is_loading {
header_top.add_child(self.render_cloud_loading_icon(appearance));
}
header_top.add_child(Expanded::new(1., Empty::new().finish()).finish()); header_top.add_child(Expanded::new(1., Empty::new().finish()).finish());
header_top.add_child(ChildView::new(setup_guide_button).finish());
if !cfg!(target_family = "wasm") { if !cfg!(target_family = "wasm") {
header_top.add_child(ChildView::new(new_agent_button).finish()); header_top.add_child(ChildView::new(new_agent_button).finish());
@@ -1976,19 +1916,7 @@ impl AgentManagementView {
.with_spacing(4.) .with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&self.status_dropdown).finish()) .with_child(ChildView::new(&self.status_dropdown).finish())
.with_child(ChildView::new(&self.source_dropdown).finish()) .with_child(ChildView::new(&self.created_on_dropdown).finish());
.with_child(ChildView::new(&self.created_on_dropdown).finish())
.with_child(ChildView::new(&self.artifact_dropdown).finish());
if HarnessAvailabilityModel::as_ref(app).should_show_harness_selector() {
filters_wrap.add_child(ChildView::new(&self.harness_dropdown).finish());
}
filters_wrap.add_child(ChildView::new(&self.environment_dropdown).finish());
if self.filters.owners != OwnerFilter::PersonalOnly {
filters_wrap.add_child(ChildView::new(&self.creator_dropdown).finish());
}
if self.filters.is_filtering() { if self.filters.is_filtering() {
filters_wrap.add_child(ChildView::new(&self.clear_all_filters_button).finish()); filters_wrap.add_child(ChildView::new(&self.clear_all_filters_button).finish());
@@ -2198,7 +2126,19 @@ impl View for AgentManagementView {
fn render(&self, app: &AppContext) -> Box<dyn Element> { fn render(&self, app: &AppContext) -> Box<dyn Element> {
let main_content = match self.get_view_state(app) { let main_content = match self.get_view_state(app) {
ViewState::Loading => self.render_loading_state(app), ViewState::Loading => self.render_loading_state(app),
ViewState::SetupGuide { .. } => ChildView::new(&self.cloud_setup_guide_view).finish(), ViewState::SetupGuide { .. } => {
let appearance = Appearance::as_ref(app);
Align::new(
Text::new(
"No saved AI sessions on this device. Start a session to keep its history here.",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(),
)
.finish()
}
ViewState::NoFilterMatches => self.render_no_results_view(app), ViewState::NoFilterMatches => self.render_no_results_view(app),
ViewState::HasTasks => self.render_default_scroll_view(app), ViewState::HasTasks => self.render_default_scroll_view(app),
}; };
@@ -2388,16 +2328,17 @@ impl TypedActionView for AgentManagementView {
} }
AgentManagementViewAction::ShowAgentTypeSelector => { AgentManagementViewAction::ShowAgentTypeSelector => {
send_telemetry_from_ctx!( send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::AgentTypeSelectorOpened, AgentManagementTelemetryEvent::SpawnNewLocalAgent,
ctx ctx
); );
self.is_agent_type_selector_open = true; ctx.dispatch_typed_action(&WorkspaceAction::NewTabInAgentMode {
ctx.focus(&self.agent_type_selector); entrypoint: AgentModeEntrypoint::AgentManagementView,
ctx.notify(); zero_state_prompt_suggestion_type: None,
});
} }
AgentManagementViewAction::OpenSession { item_id } => { AgentManagementViewAction::OpenSession { item_id } => {
let Some(action) = AgentConversationsModel::resolve_open_action( let Some(action) = AgentConversationsModel::resolve_local_open_action(
AgentConversationNavigationSubject::Entry(*item_id), *item_id,
Some(RestoreConversationLayout::NewTab), Some(RestoreConversationLayout::NewTab),
ctx, ctx,
) else { ) else {
+5 -3
View File
@@ -73,7 +73,7 @@ use crate::ai::mcp::{
}; };
use crate::ai::skills::{ use crate::ai::skills::{
filter_skills_by_spec, read_skills_from_directories, resolve_skill_repos, SkillManager, filter_skills_by_spec, read_skills_from_directories, resolve_skill_repos, SkillManager,
SkillWatcher, SkillManagerEvent, SkillWatcher,
}; };
use crate::auth::AuthStateProvider; use crate::auth::AuthStateProvider;
use crate::cloud_object::{CloudObject, CloudObjectLookup as _}; use crate::cloud_object::{CloudObject, CloudObjectLookup as _};
@@ -1874,9 +1874,10 @@ impl AgentDriver {
} else { } else {
log::info!("No environment skills found"); log::info!("No environment skills found");
} }
SkillManager::handle(ctx).update(ctx, |manager, _| { SkillManager::handle(ctx).update(ctx, |manager, ctx| {
manager.set_cloud_environment(true); manager.set_cloud_environment(true);
manager.handle_skills_added(skills); manager.handle_skills_added(skills);
ctx.emit(SkillManagerEvent::SkillsChanged);
}); });
}) })
.await; .await;
@@ -1942,9 +1943,10 @@ impl AgentDriver {
log::info!("Loaded {} global skill(s)", skills.len()); log::info!("Loaded {} global skill(s)", skills.len());
let add_result = foreground let add_result = foreground
.spawn(move |_, ctx| { .spawn(move |_, ctx| {
SkillManager::handle(ctx).update(ctx, |manager, _| { SkillManager::handle(ctx).update(ctx, |manager, ctx| {
manager.set_cloud_environment(true); manager.set_cloud_environment(true);
manager.handle_skills_added(skills); manager.handle_skills_added(skills);
ctx.emit(SkillManagerEvent::SkillsChanged);
}); });
}) })
.await; .await;
@@ -41,7 +41,7 @@ const PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME: &str = "pending-hook-output.ack";
const PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV: &str = "OZ_PARENT_MAX_CONTEXT_CHARS"; const PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV: &str = "OZ_PARENT_MAX_CONTEXT_CHARS";
const PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS: usize = 6000; const PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS: usize = 6000;
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str = pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str =
"Oz mailbox update for this child run.\nSource: lead agent\nContext type: user-level coordination messages\n"; "Galaxy mailbox update for this child run.\nSource: lead agent\nContext type: user-level coordination messages\n";
const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str = const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str =
"\n\nAdditional lead agent mailbox messages remain queued and may be surfaced later."; "\n\nAdditional lead agent mailbox messages remain queued and may be surfaced later.";
+18
View File
@@ -49,6 +49,9 @@ pub mod text {
| AIAgentInput::TriggerPassiveSuggestion { .. } => Ok(()), | AIAgentInput::TriggerPassiveSuggestion { .. } => Ok(()),
AIAgentInput::ActionResult { result, .. } => match &result.result { AIAgentInput::ActionResult { result, .. } => match &result.result {
AIAgentActionResultType::RequestCommandOutput(result) => match result { AIAgentActionResultType::RequestCommandOutput(result) => match result {
RequestCommandOutputResult::BackgroundJobStarted { job_id, command } => {
writeln!(w, "Background job {job_id} accepted: `{command}`. Completion will arrive separately.")
}
RequestCommandOutputResult::Completed { RequestCommandOutputResult::Completed {
command, command,
output, output,
@@ -72,6 +75,9 @@ pub mod text {
} }
}, },
AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result {
WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
job_id,
} => writeln!(w, "Cancellation requested for background job {job_id}. Its final status will arrive separately."),
WriteToLongRunningShellCommandResult::Snapshot { .. } => { WriteToLongRunningShellCommandResult::Snapshot { .. } => {
writeln!(w, "Command is still running...") writeln!(w, "Command is still running...")
} }
@@ -708,6 +714,8 @@ pub mod json {
#[derive(Serialize)] #[derive(Serialize)]
#[serde(tag = "status", rename_all = "snake_case")] #[serde(tag = "status", rename_all = "snake_case")]
enum JsonRunCommandResult<'a> { enum JsonRunCommandResult<'a> {
Accepted { job_id: &'a str, command: &'a str },
CancellationRequested { job_id: &'a str },
Complete { exit_code: i32, output: &'a str }, Complete { exit_code: i32, output: &'a str },
Running, Running,
} }
@@ -822,6 +830,11 @@ pub mod json {
fn from_action_result(result: &'a AIAgentActionResultType) -> Option<Self> { fn from_action_result(result: &'a AIAgentActionResultType) -> Option<Self> {
match result { match result {
AIAgentActionResultType::RequestCommandOutput(result) => match result { AIAgentActionResultType::RequestCommandOutput(result) => match result {
RequestCommandOutputResult::BackgroundJobStarted { job_id, command } => {
Some(JsonMessage::ToolResult(JsonToolResult::RunCommand(
JsonRunCommandResult::Accepted { job_id, command },
)))
}
RequestCommandOutputResult::Completed { RequestCommandOutputResult::Completed {
output, exit_code, .. output, exit_code, ..
} => Some(JsonMessage::ToolResult(JsonToolResult::RunCommand( } => Some(JsonMessage::ToolResult(JsonToolResult::RunCommand(
@@ -850,6 +863,11 @@ pub mod json {
}), }),
}, },
AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result {
WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
job_id,
} => Some(JsonMessage::ToolResult(JsonToolResult::RunCommand(
JsonRunCommandResult::CancellationRequested { job_id },
))),
WriteToLongRunningShellCommandResult::Snapshot { .. } => { WriteToLongRunningShellCommandResult::Snapshot { .. } => {
Some(JsonMessage::ToolResult(JsonToolResult::RunCommand( Some(JsonMessage::ToolResult(JsonToolResult::RunCommand(
JsonRunCommandResult::Running, JsonRunCommandResult::Running,
+5 -5
View File
@@ -41,9 +41,9 @@ use crate::workspaces::user_workspaces::UserWorkspaces;
pub(crate) enum BootstrapError { pub(crate) enum BootstrapError {
/// The PTY or shell process failed before the bootstrap script completed. /// The PTY or shell process failed before the bootstrap script completed.
/// When `reason` is `Some`, the message is /// When `reason` is `Some`, the message is
/// "Shell spawn failed: {reason}. Check the Warp logs for details." /// "Shell spawn failed: {reason}. Check the Galaxy logs for details."
/// When `reason` is `None`, it is /// When `reason` is `None`, it is
/// "Shell spawn failed. Check the Warp logs for details." /// "Shell spawn failed. Check the Galaxy logs for details."
PtySpawnFailed { reason: Option<String> }, PtySpawnFailed { reason: Option<String> },
/// The bootstrap script did not complete within the expected time. /// The bootstrap script did not complete within the expected time.
TimedOut, TimedOut,
@@ -58,16 +58,16 @@ impl std::fmt::Display for BootstrapError {
BootstrapError::PtySpawnFailed { reason: Some(r) } => { BootstrapError::PtySpawnFailed { reason: Some(r) } => {
write!( write!(
f, f,
"Shell spawn failed: {r}. Check the Warp logs for details." "Shell spawn failed: {r}. Check the Galaxy logs for details."
) )
} }
BootstrapError::PtySpawnFailed { reason: None } => { BootstrapError::PtySpawnFailed { reason: None } => {
write!(f, "Shell spawn failed. Check the Warp logs for details.") write!(f, "Shell spawn failed. Check the Galaxy logs for details.")
} }
BootstrapError::TimedOut => write!( BootstrapError::TimedOut => write!(
f, f,
"Terminal session did not start within the expected time. \ "Terminal session did not start within the expected time. \
Check the Warp logs for details." Check the Galaxy logs for details."
), ),
BootstrapError::InternalError => { BootstrapError::InternalError => {
write!(f, "An unexpected internal error occurred during bootstrap.") write!(f, "An unexpected internal error occurred during bootstrap.")
+1 -1
View File
@@ -112,7 +112,7 @@ static DEFAULT_TIPS: LazyLock<Vec<AgentTip>> = LazyLock::new(|| {
kind: AgentTipKind::SlashCommands, kind: AgentTipKind::SlashCommands,
}, },
AgentTip { AgentTip {
description: "<keybinding> to open the Command Palette and access Warp actions and shortcuts.".to_string(), description: "<keybinding> to open the Command Palette and access Galaxy actions and shortcuts.".to_string(),
link: Some("https://docs.warp.dev/terminal/command-palette".to_string()), link: Some("https://docs.warp.dev/terminal/command-palette".to_string()),
binding_name: Some(TOGGLE_COMMAND_PALETTE_KEYBINDING_NAME), binding_name: Some(TOGGLE_COMMAND_PALETTE_KEYBINDING_NAME),
action: Some(WorkspaceAction::OpenPalette { action: Some(WorkspaceAction::OpenPalette {
+40 -16
View File
@@ -14,6 +14,10 @@ use tokio::sync::Mutex;
use vec1::vec1; use vec1::vec1;
use crate::settings::{AISettings, AISettingsChangedEvent}; use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::ai::provider::authentication::{
BedrockAuthConnection, connection_revision, credentials_are_current,
};
use crate::ai::provider::client::BedrockClientConfig;
use crate::terminal::event::{AfterBlockCompletedEvent, BlockType, UserBlockCompleted}; use crate::terminal::event::{AfterBlockCompletedEvent, BlockType, UserBlockCompleted};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
@@ -56,7 +60,7 @@ fn aws_profile_reference_for_message(profile: &str, capitalize_first_word: bool)
} }
} }
fn user_facing_aws_credentials_error_message(err: &CredentialsError, profile: &str) -> String { pub(crate) fn user_facing_aws_credentials_error_message(err: &CredentialsError, profile: &str) -> String {
match err { match err {
CredentialsError::CredentialsNotLoaded(_) => format!( CredentialsError::CredentialsNotLoaded(_) => format!(
"AWS credentials were not found for {}. Log in with the AWS CLI or update your AWS credentials configuration, then refresh.", "AWS credentials were not found for {}. Log in with the AWS CLI or update your AWS credentials configuration, then refresh.",
@@ -129,15 +133,17 @@ fn aws_credentials_state_for_error(err: LoadAwsCredentialsError) -> AwsCredentia
/// Loads AWS credentials from the AWS SDK. /// Loads AWS credentials from the AWS SDK.
/// ///
/// # Arguments /// # Arguments
/// * `profile` - AWS profile name. If empty, uses the default AWS SDK behavior /// * `connection` - Effective AWS connection. If its profile is empty, uses the default AWS SDK behavior
/// (checks AWS_PROFILE env var, then uses "default"). /// (checks AWS_PROFILE env var, then uses "default").
pub async fn load_aws_credentials_from_sdk( pub async fn load_aws_credentials_from_sdk(
profile: &str, connection: &BedrockClientConfig,
) -> Result<AwsCredentials, LoadAwsCredentialsError> { ) -> Result<AwsCredentials, LoadAwsCredentialsError> {
let region_provider = aws_config::meta::region::RegionProviderChain::default_provider(); let profile = &connection.profile;
let loader = let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
aws_config::defaults(aws_config::BehaviorVersion::latest()).region(region_provider); if !connection.region.is_empty() {
let loader = if profile.trim().is_empty() || profile.trim() == "default" { loader = loader.region(aws_config::Region::new(connection.region.clone()));
}
let loader = if profile.trim().is_empty() {
loader // Let AWS SDK use its default behavior loader // Let AWS SDK use its default behavior
} else { } else {
loader.profile_name(profile) loader.profile_name(profile)
@@ -150,14 +156,15 @@ pub async fn load_aws_credentials_from_sdk(
let creds = provider.provide_credentials().await.map_err(|e| { let creds = provider.provide_credentials().await.map_err(|e| {
let message = user_facing_aws_credentials_error_message(&e, profile); let message = user_facing_aws_credentials_error_message(&e, profile);
log::warn!("{e}"); log::warn!("AWS credential reload failed: {message}");
// TODO(isaiah): turn this full SDK dump back down to debug once we've resolved
// the current customer-facing AWS credential issue and no longer need prod-visible
// provider internals for support debugging.
log::warn!("{e:#?}");
log::info!("AWS credential load failure message shown to user: {message}"); log::info!("AWS credential load failure message shown to user: {message}");
LoadAwsCredentialsError::CredentialsLoadFailed(message) LoadAwsCredentialsError::CredentialsLoadFailed(message)
})?; })?;
if !credentials_are_current(creds.expiry(), SystemTime::now()) {
return Err(LoadAwsCredentialsError::CredentialsLoadFailed(
"AWS credentials have expired. Refresh your AWS login.".to_owned(),
));
}
Ok(AwsCredentials::new( Ok(AwsCredentials::new(
creds.access_key_id().to_string(), creds.access_key_id().to_string(),
@@ -198,8 +205,10 @@ impl AwsCredentialRefresher for ApiKeyManager {
.. ..
}) = event }) = event
{ {
let auth_command = &AISettings::as_ref(ctx).bedrock_auth_refresh_command; let auth_command = BedrockAuthConnection::current(ctx).command;
if command.trim().starts_with(auth_command.trim()) { if !auth_command.trim().is_empty()
&& command.trim().starts_with(auth_command.trim())
{
log::debug!("Detected AWS auth command completion, refreshing credentials"); log::debug!("Detected AWS auth command completion, refreshing credentials");
drop(refresh_aws_credentials(manager, ctx)); drop(refresh_aws_credentials(manager, ctx));
} }
@@ -225,6 +234,8 @@ impl AwsCredentialRefresher for ApiKeyManager {
if matches!( if matches!(
event, event,
AISettingsChangedEvent::BedrockProfile { .. } AISettingsChangedEvent::BedrockProfile { .. }
| AISettingsChangedEvent::BedrockAuthMethod { .. }
| AISettingsChangedEvent::BedrockRegion { .. }
| AISettingsChangedEvent::BedrockAuthRefreshCommand { .. } | AISettingsChangedEvent::BedrockAuthRefreshCommand { .. }
| AISettingsChangedEvent::BedrockEnabled { .. } | AISettingsChangedEvent::BedrockEnabled { .. }
) { ) {
@@ -266,15 +277,28 @@ fn refresh_aws_credentials_local_chain(
return Box::pin(async { Ok(()) }); return Box::pin(async { Ok(()) });
} }
let profile = (*AISettings::as_ref(ctx).bedrock_profile).clone(); let connection = BedrockAuthConnection::current(ctx);
if connection.config.auth_method == crate::settings::BedrockAuthMethod::StaticKeys {
manager.set_aws_credentials_state(AwsCredentialsState::Missing, ctx);
return Box::pin(async { Ok(()) });
}
let config = connection.config.clone();
let revision = connection_revision(ctx);
manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx); manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx);
let (tx, rx) = channel(); let (tx, rx) = channel();
// credential fetch from aws cli's disk cache // credential fetch from aws cli's disk cache
let _ = ctx.spawn( let _ = ctx.spawn(
async move { load_aws_credentials_from_sdk(&profile).await }, async move { load_aws_credentials_from_sdk(&config).await },
move |manager, result, ctx| { move |manager, result, ctx| {
if connection_revision(ctx) != revision
|| BedrockAuthConnection::current(ctx) != connection
|| manager.aws_credentials_refresh_strategy() != AwsCredentialsRefreshStrategy::LocalChain
{
let _ = tx.send(Err("AWS connection changed during credential reload".to_owned()));
return;
}
let (new_state, tx_result) = match result { let (new_state, tx_result) = match result {
Ok(credentials) => ( Ok(credentials) => (
AwsCredentialsState::Loaded { AwsCredentialsState::Loaded {
+348
View File
@@ -0,0 +1,348 @@
//! Read-only presentation of captured command output, without creating a terminal session.
use std::ops::Range;
use galaxy_core::ui::theme::{AnsiColors, TerminalColors};
use galaxyui::color::ColorU;
use galaxyui::text_layout::TextStyle;
use string_offset::ByteOffset;
use vte::{Params, Parser, Perform};
/// Removes terminal controls and resolves ANSI colors against the current theme.
///
/// The caller supplies the already bounded log tail. Memory grows linearly with
/// that input: cursor movement cannot allocate padding or expand the output.
/// Reformat when the theme changes; returned colors are concrete theme colors.
pub(super) fn format_job_logs(
raw: &str,
colors: &TerminalColors,
) -> (String, Vec<(Range<ByteOffset>, TextStyle)>) {
let mut formatter = LogFormatter {
colors,
text: String::with_capacity(raw.len()),
styles: Vec::new(),
line: Vec::new(),
cursor: 0,
style: TextStyle::default(),
underline: false,
};
let mut parser = Parser::new();
for byte in raw.bytes() {
parser.advance(&mut formatter, byte);
}
formatter.flush_line();
(formatter.text, formatter.styles)
}
#[derive(Clone, Copy)]
struct LogCell {
character: char,
style: TextStyle,
}
struct LogFormatter<'a> {
colors: &'a TerminalColors,
text: String,
styles: Vec<(Range<ByteOffset>, TextStyle)>,
line: Vec<LogCell>,
cursor: usize,
style: TextStyle,
underline: bool,
}
impl LogFormatter<'_> {
fn flush_line(&mut self) {
for cell in self.line.drain(..) {
let start = self.text.len();
self.text.push(cell.character);
let end = self.text.len();
if cell.style == TextStyle::default() {
continue;
}
if let Some((range, style)) = self.styles.last_mut() {
if *style == cell.style && range.end.as_usize() == start {
range.end = ByteOffset::from(end);
continue;
}
}
self.styles
.push((ByteOffset::from(start)..ByteOffset::from(end), cell.style));
}
self.cursor = 0;
}
fn displayed_style(&self) -> TextStyle {
let mut style = self.style;
if self.underline {
style.underline_color = Some(
style
.foreground_color
.unwrap_or_else(|| self.colors.normal.white.into()),
);
}
style
}
fn set_graphics(&mut self, params: &Params) {
let params: Vec<_> = params.iter().collect();
let mut index = 0;
while let Some(parameter) = params.get(index) {
match parameter.first().copied().unwrap_or(0) {
0 => {
self.style = TextStyle::default();
self.underline = false;
}
4 => self.underline = parameter.get(1).copied() != Some(0),
9 => self.style.show_strikethrough = true,
24 => self.underline = false,
29 => self.style.show_strikethrough = false,
value @ 30..=37 => {
self.style.foreground_color = Some(self.indexed_color((value - 30) as u8));
}
value @ 40..=47 => {
self.style.background_color = Some(self.indexed_color((value - 40) as u8));
}
value @ 90..=97 => {
self.style.foreground_color = Some(self.indexed_color((value - 90 + 8) as u8));
}
value @ 100..=107 => {
self.style.background_color = Some(self.indexed_color((value - 100 + 8) as u8));
}
39 => self.style.foreground_color = None,
49 => self.style.background_color = None,
target @ (38 | 48) => {
let (color, consumed) = if parameter.len() > 1 {
(self.extended_color(&parameter[1..]), 0)
} else {
let mode = params
.get(index + 1)
.and_then(|value| value.first())
.copied();
let count = match mode {
Some(5) => 2,
Some(2) => 4,
_ => 1,
};
let values: Vec<_> = params
.iter()
.skip(index + 1)
.take(count)
.filter_map(|value| value.first().copied())
.collect();
(self.extended_color(&values), count)
};
if let Some(color) = color {
if target == 38 {
self.style.foreground_color = Some(color);
} else {
self.style.background_color = Some(color);
}
}
index += consumed;
}
// Font weight/italics, cursor controls and other terminal state
// are not represented by this selectable text-log view.
_ => {}
}
index += 1;
}
}
fn extended_color(&self, values: &[u16]) -> Option<ColorU> {
match values.first()? {
5 => Some(self.indexed_color(u8::try_from(*values.get(1)?).ok()?)),
// Colon notation may include a color-space field before RGB.
2 if values.len() >= 4 => {
let rgb = &values[values.len() - 3..];
Some(ColorU::new(
u8::try_from(rgb[0]).ok()?,
u8::try_from(rgb[1]).ok()?,
u8::try_from(rgb[2]).ok()?,
255,
))
}
_ => None,
}
}
fn indexed_color(&self, index: u8) -> ColorU {
match index {
0..=7 => palette_color(&self.colors.normal, index),
8..=15 => palette_color(&self.colors.bright, index - 8),
16..=231 => {
let cube = index - 16;
let component = |value| if value == 0 { 0 } else { 55 + 40 * value };
ColorU::new(
component(cube / 36),
component(cube / 6 % 6),
component(cube % 6),
255,
)
}
232..=255 => {
let level = 8 + (index - 232) * 10;
ColorU::new(level, level, level, 255)
}
}
}
}
fn palette_color(palette: &AnsiColors, index: u8) -> ColorU {
match index {
0 => palette.black,
1 => palette.red,
2 => palette.green,
3 => palette.yellow,
4 => palette.blue,
5 => palette.magenta,
6 => palette.cyan,
_ => palette.white,
}
.into()
}
impl Perform for LogFormatter<'_> {
fn print(&mut self, character: char) {
let cell = LogCell {
character,
style: self.displayed_style(),
};
if let Some(existing) = self.line.get_mut(self.cursor) {
*existing = cell;
} else {
self.line.push(cell);
}
self.cursor += 1;
}
fn execute(&mut self, byte: u8) {
match byte {
b'\n' => {
self.flush_line();
self.text.push('\n');
}
b'\r' => self.cursor = 0,
b'\t' => self.print('\t'),
0x08 => self.cursor = self.cursor.saturating_sub(1),
_ => {}
}
}
fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) {
if ignore || !intermediates.is_empty() {
return;
}
match action {
'm' => self.set_graphics(params),
// Support the erase-line controls commonly paired with CR progress
// output. Other cursor movement is ignored and cannot grow memory.
'K' => match params
.iter()
.next()
.and_then(|value| value.first())
.copied()
.unwrap_or(0)
{
0 => self.line.truncate(self.cursor),
1 => {
let count = self.cursor.saturating_add(1).min(self.line.len());
self.line[..count].fill(LogCell {
character: ' ',
style: TextStyle::default(),
});
}
2 => {
self.line.clear();
self.cursor = 0;
}
_ => {}
},
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use galaxy_core::ui::theme::AnsiColor;
fn colors() -> TerminalColors {
let palette = |red| {
AnsiColors::new(
AnsiColor { r: 0, g: 0, b: 0 },
AnsiColor { r: red, g: 1, b: 2 },
AnsiColor { r: 0, g: 180, b: 0 },
AnsiColor {
r: 180,
g: 180,
b: 0,
},
AnsiColor { r: 0, g: 0, b: 180 },
AnsiColor {
r: 180,
g: 0,
b: 180,
},
AnsiColor {
r: 0,
g: 180,
b: 180,
},
AnsiColor {
r: 230,
g: 230,
b: 230,
},
)
};
TerminalColors::new(palette(160), palette(250))
}
#[test]
fn ansi_colors_reset_and_unicode_ranges_use_output_bytes() {
let colors = colors();
let (text, styles) =
format_job_logs("é\x1b[31m红\x1b[0m plain \x1b[91mbright\x1b[39m!", &colors);
assert_eq!(text, "é红 plain bright!");
assert_eq!(styles.len(), 2);
assert_eq!(styles[0].0, ByteOffset::from(2)..ByteOffset::from(5));
assert_eq!(styles[0].1.foreground_color, Some(colors.normal.red.into()));
assert_eq!(styles[1].1.foreground_color, Some(colors.bright.red.into()));
for (range, _) in styles {
assert!(text
.get(range.start.as_usize()..range.end.as_usize())
.is_some());
}
}
#[test]
fn strips_opaque_controls_and_replaces_progress_lines() {
let (text, _) = format_job_logs(
"\x1b]0;hidden title\x07loading 10%\rloading 90%\r\x1b[2Kdone\r\n\x1b]8;;https://example.test\x1b\\link\x1b]8;;\x1b\\\tend\x07\x1b[99999C!",
&colors(),
);
assert_eq!(text, "done\nlink\tend!");
}
#[test]
fn supports_indexed_and_rgb_colors_in_both_sgr_notations() {
let (text, styles) = format_job_logs(
"\x1b[38;5;196mred\x1b[48;2;1;2;3mBG\x1b[0m\x1b[38:2::4:5:6mRGB\x1b[0m",
&colors(),
);
assert_eq!(text, "redBGRGB");
assert_eq!(
styles[0].1.foreground_color,
Some(ColorU::new(255, 0, 0, 255))
);
assert_eq!(
styles[1].1.background_color,
Some(ColorU::new(1, 2, 3, 255))
);
assert_eq!(
styles[2].1.foreground_color,
Some(ColorU::new(4, 5, 6, 255))
);
}
}
+911
View File
@@ -0,0 +1,911 @@
//! Agent-requested commands run independently of a conversation's current model turn.
mod log_format;
pub(crate) mod remote;
mod runtime;
pub mod view;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use galaxy_util::host_id::HostId;
use galaxyui::r#async::Timer;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentActionId;
use crate::terminal::shell::ShellLaunchData;
pub type BackgroundJobId = Uuid;
const MAX_OUTPUT_BYTES: usize = 64 * 1024;
const MAX_LOG_VIEW_BYTES: usize = 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackgroundJobStatus {
Running,
Succeeded,
Failed,
Cancelled,
}
impl BackgroundJobStatus {
pub fn is_terminal(self) -> bool {
self != Self::Running
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BackgroundJob {
pub id: BackgroundJobId,
pub conversation_id: AIConversationId,
pub action_id: AIAgentActionId,
pub command: String,
pub working_directory: PathBuf,
pub project_root: Option<PathBuf>,
pub host_id: Option<HostId>,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub status: BackgroundJobStatus,
pub exit_code: Option<i32>,
pub error: Option<String>,
pub completion_delivered: bool,
/// Full logs live in the temporary directory, independently of durable job metadata.
#[serde(skip)]
pub log_path: PathBuf,
/// A live completion snapshot; never presented as an available full log file.
#[serde(skip)]
pub output: String,
#[serde(skip)]
pub output_truncated: bool,
#[serde(skip)]
pub log_bytes: u64,
}
impl BackgroundJob {
pub fn scope_directory(&self) -> &Path {
self.project_root
.as_deref()
.unwrap_or(&self.working_directory)
}
pub fn completion_message(&self) -> String {
let status = match self.status {
BackgroundJobStatus::Running => "running",
BackgroundJobStatus::Succeeded => "succeeded",
BackgroundJobStatus::Failed => "failed",
BackgroundJobStatus::Cancelled => "cancelled",
};
let code = self
.exit_code
.map_or_else(|| "unavailable".to_owned(), |code| code.to_string());
let truncation = if self.output_truncated {
format!("\nLog output is truncated to the final {MAX_OUTPUT_BYTES} bytes of {} bytes. Full logs, while retained: {}\n", self.log_bytes, self.log_path.display())
} else {
String::new()
};
let error = self
.error
.as_ref()
.map_or_else(String::new, |error| format!("\nDetails: {error}\n"));
format!(
"Background job {} {status}.\nWorking directory: {}\nProject/directory scope: {}\nCommand:\n{}\nExit code: {code}\n{error}{truncation}\nCommand logs:\n{}",
self.id, self.working_directory.display(), self.scope_directory().display(), self.command, self.output,
)
}
}
pub struct BackgroundJobLaunch {
pub conversation_id: AIConversationId,
pub action_id: AIAgentActionId,
pub command: String,
pub working_directory: PathBuf,
pub shell: Option<ShellLaunchData>,
/// Partial session metadata overrides, not a complete exported environment.
/// The execution host's environment and shell profile supply other values.
pub environment: Option<HashMap<String, String>>,
pub host_id: Option<HostId>,
}
#[derive(Clone, Debug)]
pub enum BackgroundJobsEvent {
Changed,
Completed { id: BackgroundJobId },
}
#[derive(Serialize, Deserialize)]
struct SavedHistory {
version: u32,
jobs: Vec<BackgroundJob>,
}
struct TailUpdate {
id: BackgroundJobId,
output: String,
total_bytes: u64,
}
pub struct BackgroundJobsModel {
jobs: HashMap<BackgroundJobId, BackgroundJob>,
running: HashMap<BackgroundJobId, Arc<runtime::ProcessControl>>,
remote_cancellations: HashMap<BackgroundJobId, async_channel::Sender<()>>,
unreconciled_remote_jobs: HashSet<BackgroundJobId>,
pending_completions: HashSet<BackgroundJobId>,
unpublished_completions: HashSet<BackgroundJobId>,
interrupted_conversations: HashSet<AIConversationId>,
history_directory: PathBuf,
log_directory: PathBuf,
tail_updates: async_channel::Sender<TailUpdate>,
load_error: Option<String>,
#[cfg(test)]
_test_storage: tempfile::TempDir,
}
impl BackgroundJobsModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
#[cfg(not(test))]
{
use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
let manager = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&manager, |me, _, event, ctx| {
if let RemoteServerManagerEvent::HostConnected { host_id } = event {
remote::reattach_host(host_id, me, ctx);
}
});
}
#[cfg(test)]
let test_storage = tempfile::tempdir().expect("background job test storage");
#[cfg(test)]
let history_directory = test_storage.path().join("history");
#[cfg(not(test))]
let history_directory = galaxy_core::paths::data_dir().join("background-jobs");
#[cfg(test)]
let log_directory = test_storage.path().join("logs");
#[cfg(not(test))]
let log_directory = log_directory();
let (tail_updates, updates) = async_channel::bounded(128);
ctx.spawn_stream_local(
updates,
|me, update: TailUpdate, ctx| {
if let Some(job) = me
.jobs
.get_mut(&update.id)
.filter(|job| job.status == BackgroundJobStatus::Running)
{
job.output = update.output;
job.log_bytes = update.total_bytes;
job.output_truncated = update.total_bytes > MAX_OUTPUT_BYTES as u64;
ctx.emit(BackgroundJobsEvent::Changed);
}
},
|_, _| {},
);
let (jobs, interrupted_conversations, load_error) =
load_history(&history_directory, &log_directory);
let unreconciled_remote_jobs = jobs
.values()
.filter(|job| job.host_id.is_some() && job.status == BackgroundJobStatus::Running)
.map(|job| job.id)
.collect();
let model = Self {
jobs,
running: HashMap::new(),
remote_cancellations: HashMap::new(),
unreconciled_remote_jobs,
pending_completions: HashSet::new(),
unpublished_completions: HashSet::new(),
interrupted_conversations,
history_directory,
log_directory,
tail_updates,
load_error,
#[cfg(test)]
_test_storage: test_storage,
};
if model.load_error.is_none() {
if let Err(error) = model.persist() {
log::warn!("Failed to save restored background job metadata: {error}");
}
}
model
}
pub fn records(&self) -> impl Iterator<Item = &BackgroundJob> {
self.jobs.values()
}
pub fn job(&self, id: BackgroundJobId) -> Option<&BackgroundJob> {
self.jobs.get(&id)
}
pub fn has_running_jobs(&self, conversation_id: AIConversationId) -> bool {
self.jobs.values().any(|job| {
job.conversation_id == conversation_id && job.status == BackgroundJobStatus::Running
})
}
pub fn has_undelivered_history(&self, conversation_id: AIConversationId) -> bool {
self.interrupted_conversations.contains(&conversation_id)
}
pub fn take_interrupted_conversation(&mut self, conversation_id: AIConversationId) -> bool {
self.interrupted_conversations.remove(&conversation_id)
}
pub fn load_error(&self) -> Option<&str> {
self.load_error.as_deref()
}
pub fn logs_available(&self, id: BackgroundJobId) -> bool {
self.jobs.get(&id).is_some_and(|job| {
fs::symlink_metadata(&job.log_path).is_ok_and(|metadata| metadata.file_type().is_file())
&& fs::File::open(&job.log_path).is_ok()
})
}
pub fn read_logs(&self, id: BackgroundJobId) -> Result<String, String> {
let job = self.jobs.get(&id).ok_or("Background job not found")?;
let (text, bytes) = read_tail(&job.log_path, MAX_LOG_VIEW_BYTES)
.map_err(|error| format!("Job logs are no longer available: {error}"))?;
if bytes > MAX_LOG_VIEW_BYTES as u64 {
Ok(format!("Showing the final {MAX_LOG_VIEW_BYTES} bytes of {bytes} bytes. Full log: {}\n\n{text}", job.log_path.display()))
} else {
Ok(text)
}
}
pub fn start(
&mut self,
launch: BackgroundJobLaunch,
ctx: &mut ModelContext<Self>,
) -> Result<BackgroundJobId, String> {
self.start_with_id(Uuid::new_v4(), launch, ctx)
}
pub(crate) fn start_with_id(
&mut self,
id: BackgroundJobId,
launch: BackgroundJobLaunch,
ctx: &mut ModelContext<Self>,
) -> Result<BackgroundJobId, String> {
if let Some(error) = &self.load_error {
return Err(format!("Background job history is unavailable: {error}"));
}
if id.is_nil() || self.jobs.contains_key(&id) {
return Err("Background job identity is invalid or already recorded".to_owned());
}
if launch.host_id.is_some() {
return Err("Background command execution is not available for this remote session. Run the command yourself on that host; Galaxy will not run it on the local machine.".to_owned());
}
if launch.command.trim().is_empty() || launch.command.contains('\0') {
return Err("A nonempty command without NUL characters is required".to_owned());
}
let cwd = fs::canonicalize(&launch.working_directory)
.map_err(|error| format!("Cannot resolve command directory: {error}"))?;
if !cwd.is_dir() {
return Err("The command working directory is not a directory".to_owned());
}
let mut command = runtime::command_for_launch(&launch, &cwd)?;
command.current_dir(&cwd);
ensure_private_directory(&self.log_directory)
.map_err(|error| format!("Cannot create temporary job log directory: {error}"))?;
let log_path = self.log_directory.join(format!("{id}.log"));
let log = create_private_log(&log_path)
.map_err(|error| format!("Cannot create job log: {error}"))?;
// An independent open keeps completed output readable after temporary-file cleanup.
// Cloning the writer would share its file offset with the child process on Unix.
let mut log_reader = open_log_reader(&log_path)
.map_err(|error| format!("Cannot open job log for completion capture: {error}"))?;
command.stdout(command::Stdio::from(
log.try_clone().map_err(|error| error.to_string())?,
));
command.stderr(command::Stdio::from(log));
let job = BackgroundJob {
id,
conversation_id: launch.conversation_id,
action_id: launch.action_id,
command: launch.command,
working_directory: cwd.clone(),
project_root: project_root(&cwd),
host_id: launch.host_id,
started_at: Utc::now(),
finished_at: None,
status: BackgroundJobStatus::Running,
exit_code: None,
error: None,
completion_delivered: false,
log_path: log_path.clone(),
output: String::new(),
output_truncated: false,
log_bytes: 0,
};
self.interrupted_conversations.remove(&job.conversation_id);
self.jobs.insert(id, job);
if let Err(error) = self.persist() {
self.jobs.remove(&id);
let _ = fs::remove_file(&log_path);
return Err(format!(
"Cannot record background job before starting it: {error}"
));
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
self.finish(
id,
runtime::JobOutcome::launch_error(error.to_string()),
ctx,
);
return Ok(id);
}
};
let process = match runtime::ProcessControl::new(child.id()) {
Ok(process) => Arc::new(process),
Err(error) => {
let _ = child.kill();
self.finish(
id,
runtime::JobOutcome::launch_error(format!(
"Cannot own background process tree: {error}"
)),
ctx,
);
return Ok(id);
}
};
self.running.insert(id, Arc::clone(&process));
let updates = self.tail_updates.clone();
ctx.spawn(
async move {
let _guard = runtime::ProcessGuard(Arc::clone(&process));
let status = async {
#[cfg(unix)]
process.wait_for_exit_without_reaping().await?;
child.status().await
};
futures::pin_mut!(status);
let mut previous_size = 0;
loop {
let tick = Timer::after(Duration::from_millis(500));
futures::pin_mut!(tick);
match futures::future::select(status.as_mut(), tick).await {
futures::future::Either::Left((result, _)) => {
process.cleanup();
return runtime::JobOutcome::from_exit(
result,
process.cancel_requested(),
&mut log_reader,
);
}
futures::future::Either::Right(_) => {
let size = log_reader.metadata().map_or(0, |metadata| metadata.len());
if size != previous_size {
previous_size = size;
if let Ok((output, total_bytes)) =
read_tail_from_open_file(&mut log_reader, MAX_OUTPUT_BYTES)
{
let _ = updates.try_send(TailUpdate {
id,
output,
total_bytes,
});
}
}
}
}
}
},
move |me, outcome, ctx| me.finish(id, outcome, ctx),
);
ctx.emit(BackgroundJobsEvent::Changed);
Ok(id)
}
/// A cancellation can beat a delayed remote Start request. Persist its identity before
/// acknowledging cancellation so that request can never launch the command afterward.
pub(crate) fn cancel_before_start(
&mut self,
id: BackgroundJobId,
launch: BackgroundJobLaunch,
ctx: &mut ModelContext<Self>,
) -> Result<BackgroundJobId, String> {
if let Some(error) = &self.load_error {
return Err(format!("Background job history is unavailable: {error}"));
}
if id.is_nil() || self.jobs.contains_key(&id) {
return Err("Background job identity is invalid or already recorded".to_owned());
}
if launch.command.trim().is_empty()
|| launch.command.contains('\0')
|| !launch.working_directory.is_absolute()
{
return Err(
"A cancelled launch requires its original command and absolute directory"
.to_owned(),
);
}
let cwd = fs::canonicalize(&launch.working_directory).unwrap_or(launch.working_directory);
let now = Utc::now();
self.jobs.insert(
id,
BackgroundJob {
id,
conversation_id: launch.conversation_id,
action_id: launch.action_id,
command: launch.command,
working_directory: cwd.clone(),
project_root: project_root(&cwd),
host_id: None,
started_at: now,
finished_at: Some(now),
status: BackgroundJobStatus::Cancelled,
exit_code: None,
error: Some("Cancelled before the remote command was started".to_owned()),
completion_delivered: true,
log_path: self.log_directory.join(format!("{id}.log")),
output: String::new(),
output_truncated: false,
log_bytes: 0,
},
);
if let Err(error) = self.persist() {
self.jobs.remove(&id);
return Err(format!("Cannot record cancellation before launch: {error}"));
}
ctx.emit(BackgroundJobsEvent::Changed);
Ok(id)
}
pub fn cancel(
&mut self,
id: BackgroundJobId,
ctx: &mut ModelContext<Self>,
) -> Result<(), String> {
let job = self.jobs.get(&id).ok_or("Background job not found")?;
// Completion can win the race with an already-authorized cancellation.
// Its recorded outcome is final; acknowledge the request without
// signalling a process or publishing another completion.
if job.status.is_terminal() {
return Ok(());
}
if self.unreconciled_remote_jobs.contains(&id)
&& !self.remote_cancellations.contains_key(&id)
{
return Err("Waiting to reconnect and confirm this job's state on its host before cancellation is available".to_owned());
}
if let Some(cancel) = self.remote_cancellations.get(&id) {
cancel
.try_send(())
.map_err(|error| format!("Could not request remote job cancellation: {error}"))?;
ctx.emit(BackgroundJobsEvent::Changed);
return Ok(());
}
let process = self
.running
.get(&id)
.ok_or("This background job is no longer running")?;
process
.cancel()
.map_err(|error| format!("Could not terminate background job: {error}"))?;
ctx.emit(BackgroundJobsEvent::Changed);
Ok(())
}
/// Installs a daemon-owned job without resolving its paths on the client machine.
pub fn register_remote_job(
&mut self,
mut job: BackgroundJob,
cancellation: async_channel::Sender<()>,
ctx: &mut ModelContext<Self>,
) -> Result<BackgroundJobId, String> {
if let Some(error) = &self.load_error {
return Err(format!("Background job history is unavailable: {error}"));
}
if job.host_id.is_none() {
return Err("A remote job requires its owning host identity".to_owned());
}
if self.jobs.contains_key(&job.id) {
return Err("This background job is already registered".to_owned());
}
ensure_private_directory(&self.log_directory).map_err(|error| error.to_string())?;
job.log_path = self.log_directory.join(format!("{}.log", job.id));
create_private_log(&job.log_path).map_err(|error| error.to_string())?;
job.output.clear();
job.output_truncated = false;
job.log_bytes = 0;
job.completion_delivered = false;
let id = job.id;
self.jobs.insert(id, job);
if let Err(error) = self.persist() {
if let Some(job) = self.jobs.remove(&id) {
let _ = fs::remove_file(job.log_path);
}
return Err(format!("Cannot persist remote background job: {error}"));
}
self.remote_cancellations.insert(id, cancellation);
ctx.emit(BackgroundJobsEvent::Changed);
Ok(id)
}
/// Reconnects a previously recorded process; never submits the command again.
pub fn reattach_remote_job(
&mut self,
id: BackgroundJobId,
cancellation: async_channel::Sender<()>,
ctx: &mut ModelContext<Self>,
) -> Result<(), String> {
let job = self
.jobs
.get(&id)
.ok_or("Remote background job not found")?;
if job.host_id.is_none() || job.status != BackgroundJobStatus::Running {
return Err("Only a running remote background job can be reconnected".to_owned());
}
if self.remote_cancellations.contains_key(&id) {
return Err("This remote background job is already being monitored".to_owned());
}
self.remote_cancellations.insert(id, cancellation);
ctx.emit(BackgroundJobsEvent::Changed);
Ok(())
}
/// Only a confirmed running process can generate a new completion after restart.
pub fn arm_remote_completion(
&mut self,
id: BackgroundJobId,
ctx: &mut ModelContext<Self>,
) -> Result<(), String> {
if !self.unreconciled_remote_jobs.contains(&id) {
return Ok(());
}
let job = self
.jobs
.get_mut(&id)
.ok_or("Remote background job not found")?;
if job.host_id.is_none() || job.status != BackgroundJobStatus::Running {
return Err("Only a confirmed running remote job can notify completion".to_owned());
}
self.unreconciled_remote_jobs.remove(&id);
job.completion_delivered = false;
job.error = None;
let result = self.persist().map_err(|error| error.to_string());
ctx.emit(BackgroundJobsEvent::Changed);
result
}
/// The transport supplies only bytes not previously mirrored, using its own log cursor.
pub fn update_remote_job(
&mut self,
id: BackgroundJobId,
status: BackgroundJobStatus,
exit_code: Option<i32>,
mut error: Option<String>,
log_chunk: &[u8],
ctx: &mut ModelContext<Self>,
) -> Result<(), String> {
let job = self
.jobs
.get_mut(&id)
.ok_or("Remote background job not found")?;
if job.host_id.is_none() {
return Err("Cannot apply a remote update to a local job".to_owned());
}
if job.status.is_terminal() {
return Ok(());
}
let capture = (|| -> std::io::Result<_> {
if !log_chunk.is_empty() {
let mut options = fs::OpenOptions::new();
options.append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut file = options.open(&job.log_path)?;
if !file.metadata()?.is_file() {
return Err(std::io::Error::other(
"Remote job log is not a regular file",
));
}
file.write_all(log_chunk)?;
}
read_tail(&job.log_path, MAX_OUTPUT_BYTES)
})();
let (output, log_bytes) = match capture {
Ok(captured) => captured,
Err(capture_error) => {
// Purging temporary files never changes the remote process's authoritative
// status or discards its newly received output. Do not recreate the log path.
let mut output = job.output.clone();
output.push_str(&String::from_utf8_lossy(log_chunk));
if output.len() > MAX_OUTPUT_BYTES {
let mut start = output.len() - MAX_OUTPUT_BYTES;
while !output.is_char_boundary(start) {
start += 1;
}
output.drain(..start);
}
let message = format!("The local temporary log is unavailable; captured context output is retained: {capture_error}");
error = Some(match error {
Some(error) => format!("{error} {message}"),
None => message,
});
(output, job.log_bytes.saturating_add(log_chunk.len() as u64))
}
};
if status.is_terminal() {
self.remote_cancellations.remove(&id);
self.finish(
id,
runtime::JobOutcome {
status,
exit_code,
error,
output,
log_bytes,
},
ctx,
);
} else {
job.output = output;
job.log_bytes = log_bytes;
job.output_truncated = log_bytes > MAX_OUTPUT_BYTES as u64;
job.error = error;
ctx.emit(BackgroundJobsEvent::Changed);
}
Ok(())
}
pub fn pending_completions(&self, conversation_id: AIConversationId) -> Vec<BackgroundJob> {
let mut pending = self
.pending_completions
.iter()
.filter_map(|id| self.jobs.get(id))
.filter(|job| job.conversation_id == conversation_id && !job.completion_delivered)
.cloned()
.collect::<Vec<_>>();
pending.sort_by_key(|job| (job.finished_at, job.id));
pending
}
pub fn acknowledge_completion(
&mut self,
id: BackgroundJobId,
ctx: &mut ModelContext<Self>,
) -> Result<(), String> {
let job = self.jobs.get_mut(&id).ok_or("Background job not found")?;
if !job.status.is_terminal() {
return Err("A running background job cannot be acknowledged".to_owned());
}
job.completion_delivered = true;
self.pending_completions.remove(&id);
self.unpublished_completions.remove(&id);
let result = self.persist().map_err(|error| error.to_string());
ctx.emit(BackgroundJobsEvent::Changed);
result
}
fn finish(
&mut self,
id: BackgroundJobId,
outcome: runtime::JobOutcome,
ctx: &mut ModelContext<Self>,
) {
self.running.remove(&id);
let Some(job) = self.jobs.get_mut(&id) else {
return;
};
job.status = outcome.status;
job.exit_code = outcome.exit_code;
job.error = outcome.error;
job.output = outcome.output;
job.log_bytes = outcome.log_bytes;
job.output_truncated = outcome.log_bytes > MAX_OUTPUT_BYTES as u64;
job.finished_at = Some(Utc::now());
if self.unreconciled_remote_jobs.remove(&id) {
// A process that ended while Galaxy was closed is history, not a reason to
// restart its agent. Retain its actual status and logs without a mailbox item.
job.completion_delivered = true;
if let Err(error) = self.persist() {
log::warn!("Failed to persist reconciled remote job metadata: {error}");
}
ctx.emit(BackgroundJobsEvent::Changed);
return;
}
self.unpublished_completions.insert(id);
self.publish_completion(id, ctx);
}
fn publish_completion(&mut self, id: BackgroundJobId, ctx: &mut ModelContext<Self>) {
if !self.unpublished_completions.contains(&id) {
return;
}
if let Err(error) = self.persist() {
log::warn!("Failed to persist background job completion: {error}");
ctx.spawn(Timer::after(Duration::from_secs(5)), move |me, _, ctx| {
me.publish_completion(id, ctx)
});
return;
}
self.unpublished_completions.remove(&id);
self.pending_completions.insert(id);
ctx.emit(BackgroundJobsEvent::Changed);
ctx.emit(BackgroundJobsEvent::Completed { id });
}
fn persist(&self) -> std::io::Result<()> {
ensure_private_directory(&self.history_directory)?;
let mut jobs = self.jobs.values().cloned().collect::<Vec<_>>();
jobs.sort_by_key(|job| (job.started_at, job.id));
let mut temporary = tempfile::NamedTempFile::new_in(&self.history_directory)?;
serde_json::to_writer(&mut temporary, &SavedHistory { version: 1, jobs })
.map_err(std::io::Error::other)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
temporary
.persist(self.history_directory.join("history.json"))
.map_err(|error| error.error)?;
Ok(())
}
}
impl Drop for BackgroundJobsModel {
fn drop(&mut self) {
for process in self.running.values() {
process.cleanup();
}
}
}
impl Entity for BackgroundJobsModel {
type Event = BackgroundJobsEvent;
}
impl SingletonEntity for BackgroundJobsModel {}
fn project_root(cwd: &Path) -> Option<PathBuf> {
cwd.ancestors()
.find(|parent| parent.join(".git").exists())
.map(Path::to_path_buf)
}
fn log_directory() -> PathBuf {
#[cfg(unix)]
let user = unsafe { libc::geteuid() }.to_string();
#[cfg(not(unix))]
let user = std::env::var("USERNAME").unwrap_or_else(|_| "user".to_owned());
std::env::temp_dir().join(format!("galaxy-background-jobs-{user}"))
}
fn ensure_private_directory(path: &Path) -> std::io::Result<()> {
fs::create_dir_all(path)?;
let metadata = fs::symlink_metadata(path)?;
if !metadata.file_type().is_dir() {
return Err(std::io::Error::other(
"Job storage must be a directory, not a symlink",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
if metadata.uid() != unsafe { libc::geteuid() } {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Job storage belongs to another user",
));
}
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn create_private_log(path: &Path) -> std::io::Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.write(true).append(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)
}
pub(super) fn read_tail(path: &Path, limit: usize) -> std::io::Result<(String, u64)> {
let mut file = open_log_reader(path)?;
read_tail_from_open_file(&mut file, limit)
}
fn open_log_reader(path: &Path) -> std::io::Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
options.open(path)
}
pub(super) fn read_tail_from_open_file(
file: &mut fs::File,
limit: usize,
) -> std::io::Result<(String, u64)> {
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(std::io::Error::other("Job log is not a regular file"));
}
let length = metadata.len();
file.seek(SeekFrom::Start(length.saturating_sub(limit as u64)))?;
let mut bytes = Vec::new();
file.take(limit as u64).read_to_end(&mut bytes)?;
Ok((String::from_utf8_lossy(&bytes).into_owned(), length))
}
fn load_history(
directory: &Path,
logs: &Path,
) -> (
HashMap<BackgroundJobId, BackgroundJob>,
HashSet<AIConversationId>,
Option<String>,
) {
let path = directory.join("history.json");
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return (HashMap::new(), HashSet::new(), None)
}
Err(error) => return (HashMap::new(), HashSet::new(), Some(error.to_string())),
};
let history = match serde_json::from_slice::<SavedHistory>(&bytes) {
Ok(history) if history.version == 1 => history,
Ok(_) => {
return (
HashMap::new(),
HashSet::new(),
Some("Unsupported background job history version".to_owned()),
)
}
Err(error) => return (HashMap::new(), HashSet::new(), Some(error.to_string())),
};
let mut jobs = HashMap::new();
let mut interrupted = HashSet::new();
for mut job in history.jobs {
job.log_path = logs.join(format!("{}.log", job.id));
if (job.status == BackgroundJobStatus::Running && job.host_id.is_none())
|| (job.status.is_terminal() && !job.completion_delivered)
{
interrupted.insert(job.conversation_id);
}
if job.status == BackgroundJobStatus::Running {
if job.host_id.is_some() {
job.error = Some("Waiting to reconnect and confirm this job's state on its host. The command has not been restarted.".to_owned());
if let Ok((output, log_bytes)) = read_tail(&job.log_path, MAX_OUTPUT_BYTES) {
job.output = output;
job.log_bytes = log_bytes;
job.output_truncated = log_bytes > MAX_OUTPUT_BYTES as u64;
}
} else {
job.status = BackgroundJobStatus::Cancelled;
job.finished_at = Some(Utc::now());
job.error = Some("Galaxy restarted before this job's completion was recorded. The command has not been restarted.".to_owned());
}
}
// A restored record is history, never an instruction to wake an agent.
job.completion_delivered = true;
jobs.insert(job.id, job);
}
(jobs, interrupted, None)
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
+337
View File
@@ -0,0 +1,337 @@
//! Host-scoped process transport. Network polling belongs to Galaxy, never to a model turn.
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use galaxy_core::SessionId;
use galaxy_util::host_id::HostId;
use galaxyui::r#async::Timer;
use galaxyui::{ModelContext, SingletonEntity};
use remote_server::manager::{HostRequestHandle, RemoteServerManager};
use remote_server::proto::background_job_request::Operation;
use remote_server::proto::{BackgroundJobRequest, BackgroundJobResponse};
use uuid::Uuid;
use super::{
BackgroundJob, BackgroundJobLaunch, BackgroundJobStatus, BackgroundJobsEvent,
BackgroundJobsModel,
};
pub(crate) fn start(
transport: Arc<HostRequestHandle>,
launch: BackgroundJobLaunch,
session_id: SessionId,
model: &mut BackgroundJobsModel,
ctx: &mut ModelContext<BackgroundJobsModel>,
) -> Result<Uuid, String> {
let host_id = launch
.host_id
.clone()
.ok_or("A remote background job requires a host identity")?;
if launch.command.trim().is_empty()
|| launch.command.contains('\0')
|| launch.working_directory.as_os_str().is_empty()
{
return Err("A remote job requires a nonempty command and working directory".to_owned());
}
let id = Uuid::new_v4();
let request = BackgroundJobRequest {
operation: Operation::Start as i32,
job_id: id.to_string(),
conversation_id: launch.conversation_id.to_string(),
action_id: launch.action_id.to_string(),
command: launch.command.clone(),
working_directory: launch.working_directory.to_string_lossy().into_owned(),
has_environment: launch.environment.is_some(),
environment: launch.environment.unwrap_or_default(),
session_id: session_id.as_u64(),
..Default::default()
};
let job = BackgroundJob {
id,
conversation_id: launch.conversation_id,
action_id: launch.action_id,
command: launch.command,
working_directory: launch.working_directory,
project_root: None,
host_id: Some(host_id),
started_at: Utc::now(),
finished_at: None,
status: BackgroundJobStatus::Running,
exit_code: None,
error: Some("Waiting for the remote host to confirm this job's launch".to_owned()),
completion_delivered: false,
log_path: Default::default(),
output: String::new(),
output_truncated: false,
log_bytes: 0,
};
let (cancel_tx, cancel_rx) = async_channel::bounded(1);
// Persist the identity before external work. The model owns this task independently of
// the requesting action future, so subsequent user input cannot orphan an accepted Start.
model.register_remote_job(job.clone(), cancel_tx, ctx)?;
monitor(job, transport, cancel_rx, 0, Some(request), ctx);
Ok(id)
}
/// Reconcile daemon-owned processes without launching commands or resuming conversations.
/// A restored terminal snapshot only updates history; a genuinely running snapshot restores
/// live completion ownership for a later terminal event.
pub(crate) fn reattach_host(
host_id: &HostId,
model: &mut BackgroundJobsModel,
ctx: &mut ModelContext<BackgroundJobsModel>,
) {
let jobs = model
.records()
.filter(|job| {
job.host_id.as_ref() == Some(host_id)
&& !job.status.is_terminal()
&& !model.remote_cancellations.contains_key(&job.id)
})
.cloned()
.collect::<Vec<_>>();
if jobs.is_empty() {
return;
}
let manager = RemoteServerManager::as_ref(ctx);
if !manager
.client_for_host(host_id)
.is_some_and(|client| client.supports_background_jobs())
{
for job in jobs {
if let Some(job) = model.jobs.get_mut(&job.id) {
job.error = Some("Reconnect with an updated Galaxy remote server to reconcile this job. Its remote process outcome has not been confirmed.".to_owned());
}
}
ctx.emit(BackgroundJobsEvent::Changed);
return;
}
let transport = Arc::new(manager.host_request_handle(host_id));
for job in jobs {
let (cancel_tx, cancel_rx) = async_channel::bounded(1);
let cursor = job.log_bytes;
match model.reattach_remote_job(job.id, cancel_tx, ctx) {
Ok(()) => monitor(job, Arc::clone(&transport), cancel_rx, cursor, None, ctx),
Err(error) => {
log::warn!(
"Could not reattach remote background job {}: {error}",
job.id
);
}
}
}
}
fn monitor(
job: BackgroundJob,
transport: Arc<HostRequestHandle>,
cancel_rx: async_channel::Receiver<()>,
mut cursor: u64,
mut launch_request: Option<BackgroundJobRequest>,
ctx: &mut ModelContext<BackgroundJobsModel>,
) {
let id = job.id;
let conversation_id = job.conversation_id;
let action_id = job.action_id.clone();
let foreground = ctx.spawner();
ctx.spawn(async move {
let mut cancellation_pending = false;
let mut launch_sent = false;
let mut launch_attempts = 0;
loop {
if cancel_rx.try_recv().is_ok() {
cancellation_pending = true;
}
if cancellation_pending && launch_request.is_some() && !launch_sent {
let _ = foreground.spawn(move |model, ctx| {
model.remote_cancellations.remove(&id);
model.finish(id, super::runtime::JobOutcome {
status: BackgroundJobStatus::Cancelled,
exit_code: None,
output: String::new(),
log_bytes: 0,
error: Some("Cancelled before the remote launch request was sent".to_owned()),
}, ctx);
}).await;
return;
}
let mut request = BackgroundJobRequest {
operation: if cancellation_pending { Operation::Cancel } else { Operation::Status } as i32,
job_id: id.to_string(),
conversation_id: conversation_id.to_string(),
action_id: action_id.to_string(),
command: job.command.clone(),
working_directory: job.working_directory.to_string_lossy().into_owned(),
log_offset: cursor,
..Default::default()
};
if !cancellation_pending && launch_attempts < 3 {
if let Some(start) = &launch_request {
request = start.clone();
launch_sent = true;
launch_attempts += 1;
}
}
let response = transport.background_job(request);
let cancellation = cancel_rx.recv();
futures::pin_mut!(response, cancellation);
// A lost Start response can wait for the host timeout. Cancellation must use
// the durable job identity immediately rather than waiting for that response.
let response = match futures::future::select(response, cancellation).await {
futures::future::Either::Left((response, _)) => response,
futures::future::Either::Right((Ok(()), _)) => {
cancellation_pending = true;
continue;
}
futures::future::Either::Right((Err(_), response)) => response.await,
};
let response = match response {
Ok(response) => response,
Err(error) => {
// A dropped SSH connection does not prove the remote process stopped.
// Keep ownership and retry through the manager's host connection pool.
let message = format!("Waiting to reconnect and confirm this job's actual state: {error}");
if !record_unconfirmed(&foreground, id, message).await { return; }
Timer::after(Duration::from_secs(1)).await;
continue;
}
};
if response.job_not_found {
// A bounded Start retry has no confirmed result. Seal cancellation on the
// daemon before declaring an outcome; its tombstone also defeats a late Start.
if launch_request.is_some() { cancellation_pending = true; }
if !record_unconfirmed(&foreground, id, "The host has no recorded process for this job; its outcome has not been confirmed".to_owned()).await { return; }
Timer::after(Duration::from_millis(500)).await;
continue;
}
if !response.error.is_empty() {
if !record_unconfirmed(&foreground, id, format!("Remote job state could not be confirmed: {}", response.error)).await { return; }
Timer::after(Duration::from_millis(500)).await;
continue;
}
let remote = match parse_response(&response) {
Ok(remote) if remote.id == id && remote.conversation_id == conversation_id
&& remote.action_id == action_id && remote.command == job.command => remote,
Ok(_) => {
if !record_unconfirmed(&foreground, id, "Remote job status identity changed; no outcome has been accepted".to_owned()).await { return; }
Timer::after(Duration::from_secs(1)).await;
continue;
}
Err(error) => {
if !record_unconfirmed(&foreground, id, error).await { return; }
Timer::after(Duration::from_secs(1)).await;
continue;
}
};
launch_request = None;
cancellation_pending = false;
let mut error = remote.error.clone();
let chunk = if response.next_log_offset >= cursor
&& response.next_log_offset - cursor == response.log_chunk.len() as u64
{
response.log_chunk
} else {
error = Some("The remote log was truncated while this job was running; previously captured output is retained.".to_owned());
Vec::new()
};
cursor = response.next_log_offset;
if !response.logs_available {
let detail = if response.completion_log_tail_available {
"The remote temporary log has been purged; only retained bounded completion evidence and previously captured output remain."
} else {
"No remote temporary log is available; previously captured output is retained."
};
error = Some(match error {
Some(error) => format!("{error} {detail}"),
None => detail.to_owned(),
});
}
let drained = !response.logs_available || cursor >= response.total_log_bytes;
let terminal = remote.status.is_terminal() && drained;
let status = if terminal { remote.status } else { BackgroundJobStatus::Running };
let exit_code = terminal.then_some(remote.exit_code).flatten();
let available = response.logs_available;
let completion_tail = (terminal && !available && response.completion_log_tail_available)
.then(|| (bounded_output(&response.completion_log_tail), response.completion_log_bytes));
let confirmed_running = remote.status == BackgroundJobStatus::Running;
let applied = foreground.spawn(move |model, ctx| {
if confirmed_running {
model.arm_remote_completion(id, ctx)?;
}
let metadata_changed = model.jobs.get_mut(&id).is_some_and(|job| {
let changed = job.working_directory != remote.working_directory || job.project_root != remote.project_root;
job.working_directory = remote.working_directory;
job.project_root = remote.project_root;
changed
});
if metadata_changed {
if let Err(error) = model.persist() {
log::warn!("Could not persist confirmed remote job scope: {error}");
}
}
let result = if let Some((output, log_bytes)) = completion_tail {
model.remote_cancellations.remove(&id);
model.finish(id, super::runtime::JobOutcome { status, exit_code, error, output, log_bytes }, ctx);
Ok(())
} else {
model.update_remote_job(id, status, exit_code, error, &chunk, ctx)
};
// Do not offer an empty placeholder as a log when the source was already purged.
if !available && model.job(id).is_some_and(|job| std::fs::symlink_metadata(&job.log_path).is_ok_and(|metadata| metadata.file_type().is_file() && metadata.len() == 0)) {
if let Some(job) = model.job(id) {
let _ = std::fs::remove_file(&job.log_path);
}
ctx.emit(BackgroundJobsEvent::Changed);
}
result
}).await;
match applied {
Ok(Ok(())) => {}
Ok(Err(error)) => {
if !record_unconfirmed(&foreground, id, error).await { return; }
Timer::after(Duration::from_secs(1)).await;
continue;
}
Err(_) => return,
}
if terminal { return; }
// Drain a completed job's remaining bytes immediately, without a per-chunk delay.
if drained { Timer::after(Duration::from_millis(500)).await; }
}
}, |_, _, _| {});
}
fn parse_response(response: &BackgroundJobResponse) -> Result<BackgroundJob, String> {
if !response.error.is_empty() {
return Err(response.error.clone());
}
serde_json::from_str(&response.job_json)
.map_err(|error| format!("Invalid remote background job response: {error}"))
}
async fn record_unconfirmed(
foreground: &galaxyui::ModelSpawner<BackgroundJobsModel>,
id: Uuid,
error: String,
) -> bool {
foreground
.spawn(move |model, ctx| {
if let Some(job) = model.jobs.get_mut(&id) {
job.error = Some(error);
ctx.emit(BackgroundJobsEvent::Changed);
}
})
.await
.is_ok()
}
fn bounded_output(bytes: &[u8]) -> String {
let output = String::from_utf8_lossy(bytes);
let mut start = output.len().saturating_sub(super::MAX_OUTPUT_BYTES);
while !output.is_char_boundary(start) {
start += 1;
}
output[start..].to_owned()
}
+667
View File
@@ -0,0 +1,667 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::sync::Arc;
#[cfg(unix)]
use std::time::Duration;
use command::r#async::Command;
#[cfg(unix)]
use galaxyui::r#async::Timer;
use parking_lot::Mutex;
use super::{read_tail_from_open_file, BackgroundJobLaunch, BackgroundJobStatus, MAX_OUTPUT_BYTES};
use crate::terminal::shell::{ShellLaunchData, ShellType};
pub(super) struct ProcessControl {
#[cfg(not(windows))]
pid: u32,
state: Mutex<ProcessState>,
#[cfg(windows)]
job: std::os::windows::io::OwnedHandle,
}
struct ProcessState {
live: bool,
cancelled: bool,
}
impl ProcessControl {
pub(super) fn new(pid: u32) -> io::Result<Self> {
Ok(Self {
#[cfg(not(windows))]
pid,
state: Mutex::new(ProcessState {
live: true,
cancelled: false,
}),
#[cfg(windows)]
job: own_windows_process_tree(pid)?,
})
}
pub(super) fn cancel_requested(&self) -> bool {
self.state.lock().cancelled
}
pub(super) fn cancel(&self) -> io::Result<()> {
// Keep checking ownership and signalling atomic with respect to cleanup/reaping.
let mut state = self.state.lock();
if !state.live {
return Ok(());
}
self.terminate_tree()?;
state.cancelled = true;
Ok(())
}
pub(super) fn cleanup(&self) {
let mut state = self.state.lock();
if state.live {
state.live = false;
let _ = self.terminate_tree();
}
}
#[cfg(unix)]
pub(super) async fn wait_for_exit_without_reaping(&self) -> io::Result<()> {
// Retain the async-process Child without polling status/try_status until cleanup.
// Its background reaper only takes dropped Child guards. WNOWAIT reserves this
// child's PID even after exit, so its process-group ID cannot be reused while a
// cancellation or descendant cleanup can still signal that group.
loop {
let exited = {
// A zero PID distinguishes WNOHANG's "still running" result on every platform.
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let result = unsafe {
libc::waitid(
libc::P_PID,
self.pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
)
};
if result < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
if error.raw_os_error() == Some(libc::ECHILD) {
// Another waiter unexpectedly consumed ownership. Never signal the
// numeric ID again, including from ProcessGuard's drop path.
self.state.lock().live = false;
} else {
self.cleanup();
}
return Err(error);
}
(unsafe { info.si_pid() }) == self.pid as libc::pid_t
};
if exited {
// Kill any remaining descendants before the caller reaps the group leader.
self.cleanup();
return Ok(());
}
Timer::after(Duration::from_millis(100)).await;
}
}
fn terminate_tree(&self) -> io::Result<()> {
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::JobObjects::TerminateJobObject;
// The handle identifies this job even after its original shell process exits.
return unsafe { TerminateJobObject(HANDLE(self.job.as_raw_handle()), 1) }
.map_err(|error| io::Error::other(error.to_string()));
}
#[cfg(not(windows))]
terminate_tree(self.pid)
}
}
pub(super) struct ProcessGuard(pub Arc<ProcessControl>);
impl Drop for ProcessGuard {
fn drop(&mut self) {
self.0.cleanup();
}
}
#[cfg(unix)]
fn terminate_tree(pid: u32) -> io::Result<()> {
// Every job starts a new session/process group; only that owned group is signalled.
let result = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
if result == 0 {
return Ok(());
}
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
Ok(())
} else {
Err(error)
}
}
#[cfg(windows)]
fn own_windows_process_tree(pid: u32) -> io::Result<std::os::windows::io::OwnedHandle> {
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows::core::PCWSTR;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
let error = |error: windows::core::Error| io::Error::other(error.to_string());
let raw_job = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(error)?;
// SAFETY: the new kernel handle is transferred into exactly one OwnedHandle.
let job = unsafe { OwnedHandle::from_raw_handle(raw_job.0) };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
unsafe {
SetInformationJobObject(
HANDLE(job.as_raw_handle()),
JobObjectExtendedLimitInformation,
&limits as *const _ as *const std::ffi::c_void,
std::mem::size_of_val(&limits) as u32,
)
}
.map_err(error)?;
let raw_process =
unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid) }.map_err(error)?;
// SAFETY: OpenProcess returned a new handle, released after assignment.
let process = unsafe { OwnedHandle::from_raw_handle(raw_process.0) };
unsafe {
AssignProcessToJobObject(HANDLE(job.as_raw_handle()), HANDLE(process.as_raw_handle()))
}
.map_err(error)?;
// The child was created suspended, so no command or descendant can escape ownership
// between CreateProcess and assignment. Closing this job kills it if resuming fails.
resume_windows_initial_thread(pid)?;
Ok(job)
}
#[cfg(windows)]
fn resume_windows_initial_thread(pid: u32) -> io::Result<()> {
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
};
use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
let error = |error: windows::core::Error| io::Error::other(error.to_string());
let raw_snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }.map_err(error)?;
// SAFETY: the successful snapshot call returned a new owned handle.
let snapshot = unsafe { OwnedHandle::from_raw_handle(raw_snapshot.0) };
let mut entry = THREADENTRY32 {
dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
..Default::default()
};
unsafe { Thread32First(HANDLE(snapshot.as_raw_handle()), &mut entry) }.map_err(error)?;
loop {
if entry.th32OwnerProcessID == pid {
let raw_thread =
unsafe { OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) }
.map_err(error)?;
// SAFETY: OpenThread returned a new owned handle for this suspended process.
let thread = unsafe { OwnedHandle::from_raw_handle(raw_thread.0) };
let previous_count = unsafe { ResumeThread(HANDLE(thread.as_raw_handle())) };
return match previous_count {
1 => Ok(()),
u32::MAX => Err(io::Error::last_os_error()),
_ => Err(io::Error::other(
"Background command's initial thread had an unexpected suspension count",
)),
};
}
unsafe { Thread32Next(HANDLE(snapshot.as_raw_handle()), &mut entry) }.map_err(error)?;
}
}
#[cfg(not(any(unix, windows)))]
fn terminate_tree(pid: u32) -> io::Result<()> {
Err(io::Error::other(format!(
"Terminating background process {pid} is unsupported"
)))
}
pub(super) struct JobOutcome {
pub status: BackgroundJobStatus,
pub exit_code: Option<i32>,
pub output: String,
pub log_bytes: u64,
pub error: Option<String>,
}
impl JobOutcome {
pub(super) fn launch_error(error: String) -> Self {
Self {
status: BackgroundJobStatus::Failed,
exit_code: None,
output: String::new(),
log_bytes: 0,
error: Some(error),
}
}
pub(super) fn from_exit(
result: io::Result<ExitStatus>,
cancelled: bool,
log: &mut std::fs::File,
) -> Self {
let (output, log_bytes, log_error) = match read_tail_from_open_file(log, MAX_OUTPUT_BYTES) {
Ok((output, bytes)) => (output, bytes, None),
Err(error) => (
String::new(),
0,
Some(format!("Could not read command logs: {error}")),
),
};
match result {
Ok(exit) => {
#[cfg(unix)]
let code = {
use std::os::unix::process::ExitStatusExt;
exit.code()
.or_else(|| exit.signal().map(|signal| 128 + signal))
};
#[cfg(not(unix))]
let code = exit.code();
let status = if cancelled {
BackgroundJobStatus::Cancelled
} else if exit.success() {
BackgroundJobStatus::Succeeded
} else {
BackgroundJobStatus::Failed
};
Self {
status,
exit_code: code,
output,
log_bytes,
error: log_error,
}
}
Err(error) => Self {
status: if cancelled {
BackgroundJobStatus::Cancelled
} else {
BackgroundJobStatus::Failed
},
exit_code: None,
output,
log_bytes,
error: Some(error.to_string()),
},
}
}
}
const BACKGROUND_ENV_PREFIX: &str = "_GALAXY_BACKGROUND_JOB_";
const POWERSHELL_SETUP_KEY: &str = "_GALAXY_BACKGROUND_JOB_SETUP_KEY";
const NONINTERACTIVE_ENVIRONMENT: &[(&str, &str)] = &[
("CI", "1"),
("TERM", "dumb"),
("NO_COLOR", "1"),
("PAGER", "cat"),
("GIT_PAGER", "cat"),
("GH_PAGER", "cat"),
("SYSTEMD_PAGER", "cat"),
("AWS_PAGER", ""),
("GIT_TERMINAL_PROMPT", "0"),
("GCM_INTERACTIVE", "Never"),
("SSH_ASKPASS_REQUIRE", "never"),
];
// Commands are passed as untouched data to constant setup scripts and execute
// noninteractively in the owned process tree. Bash/fish use a profile bootstrap;
// zsh keeps rc-defined shell state in its noninteractive command worker.
// Private environment shuttles preserve explicit overrides through rc files
// without exposing their values in argv, files, or captured environment output.
const BASH_PROFILE_SETUP: &str = r#"
builtin set +xv
builtin set +m
__galaxy_job_cwd=$1
__galaxy_job_prefix=$2
__galaxy_job_count=$3
builtin shift 3
while [ "$__galaxy_job_count" -gt 0 ]; do
__galaxy_job_key=$1
__galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key"
builtin declare -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126
builtin export "$__galaxy_job_key=${!__galaxy_job_slot}" || exit 126
builtin unset "$__galaxy_job_slot"
builtin shift
__galaxy_job_count=$((__galaxy_job_count - 1))
done
# BASH_ENV/ENV would initialize another profile in the noninteractive child.
builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
builtin cd -- "$__galaxy_job_cwd" || exit 126
builtin exec -- "$@"
"#;
const ZSH_PROFILE_SETUP: &str = r#"
builtin unsetopt XTRACE VERBOSE MONITOR
__galaxy_job_cwd=$1
__galaxy_job_prefix=$2
__galaxy_job_count=$3
builtin shift 3
__galaxy_job_keys=()
while [ "$__galaxy_job_count" -gt 0 ]; do
__galaxy_job_keys+=("$1")
builtin shift
__galaxy_job_count=$((__galaxy_job_count - 1))
done
__galaxy_job_command=$1
builtin set --
# Keep rc-defined functions, aliases and unexported state in the command worker.
# Login files are loaded by zsh; noninteractive shells need an explicit zshrc.
__galaxy_job_rc="${ZDOTDIR:-$HOME}/.zshrc"
if [[ -r "$__galaxy_job_rc" ]]; then
builtin source "$__galaxy_job_rc"
fi
builtin unsetopt XTRACE VERBOSE MONITOR
builtin set --
for __galaxy_job_key in "${__galaxy_job_keys[@]}"; do
__galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key"
builtin typeset -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126
builtin export "$__galaxy_job_key=${(P)__galaxy_job_slot}" || exit 126
builtin unset "$__galaxy_job_slot"
done
builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
builtin cd -- "$__galaxy_job_cwd" || exit 126
builtin eval -- "$__galaxy_job_command"
"#;
const FISH_PROFILE_SETUP: &str = r#"
builtin set -e fish_trace
builtin set -l __galaxy_job_cwd "$argv[1]"
builtin set -l __galaxy_job_prefix "$argv[2]"
builtin set -l __galaxy_job_count "$argv[3]"
builtin set -e argv[1..3]
while builtin test "$__galaxy_job_count" -gt 0
builtin set -l __galaxy_job_key "$argv[1]"
builtin set -l __galaxy_job_slot "$__galaxy_job_prefix$__galaxy_job_key"
builtin set -q "$__galaxy_job_slot"; or exit 126
builtin set -gx "$__galaxy_job_key" "$$__galaxy_job_slot"; or exit 126
builtin set -e "$__galaxy_job_slot"
builtin set -e argv[1]
builtin set __galaxy_job_count (builtin math "$__galaxy_job_count - 1")
end
for __galaxy_job_key in SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
builtin set -e "$__galaxy_job_key"
end
builtin cd -- "$__galaxy_job_cwd"; or exit 126
exec $argv
"#;
// PowerShell loads profiles in the noninteractive bootstrap, then starts a
// profile-free command host. A real host boundary preserves the original
// command's exit semantics, including nonterminating errors and explicit exit.
const POWERSHELL_PROFILE_SETUP: &str = r#"
Microsoft.PowerShell.Core\Set-PSDebug -Off
try {
$__galaxyJobKey = [Environment]::GetEnvironmentVariable('_GALAXY_BACKGROUND_JOB_SETUP_KEY', 'Process')
if ([String]::IsNullOrEmpty($__galaxyJobKey)) { throw 'Missing background job setup' }
$__galaxyJobPayload = [Environment]::GetEnvironmentVariable($__galaxyJobKey, 'Process')
[Environment]::SetEnvironmentVariable($__galaxyJobKey, $null, 'Process')
[Environment]::SetEnvironmentVariable('_GALAXY_BACKGROUND_JOB_SETUP_KEY', $null, 'Process')
$__galaxyJob = ConvertFrom-Json -InputObject $__galaxyJobPayload -ErrorAction Stop
$__galaxyJobPayload = $null
foreach ($__galaxyJobEntry in $__galaxyJob.environment.PSObject.Properties) {
[Environment]::SetEnvironmentVariable($__galaxyJobEntry.Name, [String]$__galaxyJobEntry.Value, 'Process')
}
[Environment]::SetEnvironmentVariable('SSH_ASKPASS', $null, 'Process')
[Environment]::SetEnvironmentVariable('SUDO_ASKPASS', $null, 'Process')
Set-Location -LiteralPath $__galaxyJob.cwd -ErrorAction Stop
$__galaxyJobProgram = [String]$__galaxyJob.program
$__galaxyJobCommand = [String]$__galaxyJob.encoded_command
$__galaxyJob = $null
} catch {
[Console]::Error.WriteLine('Galaxy could not initialize the background command shell.')
exit 126
}
$PSNativeCommandUseErrorActionPreference = $false
$LASTEXITCODE = $null
& $__galaxyJobProgram -NoLogo -NoProfile -NonInteractive -EncodedCommand $__galaxyJobCommand
if ($null -eq $LASTEXITCODE) { exit 126 }
exit $LASTEXITCODE
"#;
pub(super) fn command_for_launch(
launch: &BackgroundJobLaunch,
approved_directory: &Path,
) -> Result<Command, String> {
let (program, shell_type) = match &launch.shell {
Some(ShellLaunchData::Executable { executable_path, shell_type })
| Some(ShellLaunchData::MSYS2 { executable_path, shell_type }) => (executable_path.clone(), Some(*shell_type)),
Some(ShellLaunchData::WSL { .. }) => return Err("Background jobs cannot yet launch in this WSL session. Run the command yourself in that session.".to_owned()),
Some(ShellLaunchData::DockerSandbox { .. }) => return Err("Background jobs cannot run container commands on the host. Run this command yourself inside the sandbox.".to_owned()),
None => {
#[cfg(unix)]
{ fallback_unix_shell(std::env::var_os("SHELL")) }
#[cfg(windows)]
{ (std::path::PathBuf::from(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())), None) }
#[cfg(not(any(unix, windows)))]
{ return Err("Background shell execution is unavailable on this platform".to_owned()); }
}
};
#[cfg(unix)]
let mut command = Command::new_with_session(&program);
#[cfg(not(unix))]
let mut command = Command::new_with_process_group(&program);
#[cfg(windows)]
command.creation_flags(windows::Win32::System::Threading::CREATE_SUSPENDED.0);
let environment = background_environment(launch.environment.as_ref(), cfg!(windows))?;
// Precmd currently provides partial metadata (for example KUBECONFIG), not
// a complete export snapshot. Preserve the host's HOME, PATH and other vars.
if let Some(overrides) = &launch.environment {
command.envs(overrides);
}
// CI/TERM guards can deliberately skip rc initialization. Apply the full
// job environment after profiles, while suppressing auth prompts throughout.
command
.env("SSH_ASKPASS_REQUIRE", "never")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "Never");
match shell_type {
Some(kind @ (ShellType::Bash | ShellType::Zsh | ShellType::Fish)) => {
configure_profile_bootstrap(
&mut command,
launch,
approved_directory,
&program,
kind,
&environment,
)?;
}
Some(ShellType::PowerShell) => {
configure_powershell_bootstrap(
&mut command,
launch,
approved_directory,
&program,
&environment,
)?;
}
None => {
command.envs(&environment);
#[cfg(unix)]
command.arg("-c").arg(&launch.command);
#[cfg(windows)]
{
use command::windows::CommandExt;
command.args(["/D", "/Q", "/C"]).raw_arg(&launch.command);
}
}
}
command.env_remove("SSH_ASKPASS").env_remove("SUDO_ASKPASS");
command.stdin(command::Stdio::null());
// On Unix ProcessGuard kills the group before dropping/reaping Child. A separate
// kill-on-drop path could otherwise signal a PID after ownership was lost (ECHILD).
#[cfg(not(unix))]
command.kill_on_drop(true);
Ok(command)
}
#[cfg(unix)]
fn fallback_unix_shell(host_shell: Option<OsString>) -> (PathBuf, Option<ShellType>) {
if let Some(shell) = host_shell.filter(|shell| Path::new(shell).is_absolute()) {
if let Some(kind) = shell.to_str().and_then(ShellType::from_name) {
return (PathBuf::from(shell), Some(kind));
}
}
(PathBuf::from("/bin/sh"), None)
}
fn background_environment(
overrides: Option<&HashMap<String, String>>,
case_insensitive: bool,
) -> Result<HashMap<String, String>, String> {
let mut environment = overrides.cloned().unwrap_or_default();
if environment
.iter()
.any(|(name, value)| !valid_override_name(name) || value.contains('\0'))
{
return Err("The background command has an invalid environment override".to_owned());
}
for (name, value) in NONINTERACTIVE_ENVIRONMENT {
if case_insensitive {
environment.retain(|key, _| !key.eq_ignore_ascii_case(name));
}
environment.insert((*name).to_owned(), (*value).to_owned());
}
Ok(environment)
}
fn valid_override_name(name: &str) -> bool {
let mut bytes = name.bytes();
let valid_start = bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
valid_start
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
&& !name.to_ascii_uppercase().starts_with(BACKGROUND_ENV_PREFIX)
&& !name.to_ascii_lowercase().starts_with("__galaxy_job_")
&& name != "argv"
&& name != "fish_trace"
}
fn configure_profile_bootstrap(
command: &mut Command,
launch: &BackgroundJobLaunch,
approved_directory: &Path,
program: &Path,
shell_type: ShellType,
environment: &HashMap<String, String>,
) -> Result<(), String> {
let msys = matches!(&launch.shell, Some(ShellLaunchData::MSYS2 { .. }));
let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple());
let mut keys = environment.keys().collect::<Vec<_>>();
keys.sort();
let (setup, inner_flags): (&str, &[&str]) = match shell_type {
ShellType::Bash => (BASH_PROFILE_SETUP, &["--noprofile", "--norc", "-c"]),
ShellType::Zsh => (ZSH_PROFILE_SETUP, &[]),
ShellType::Fish => (FISH_PROFILE_SETUP, &["--no-config", "-c"]),
ShellType::PowerShell => return Err("Invalid shell bootstrap selection".to_owned()),
};
if shell_type == ShellType::Zsh {
// Source zshrc and execute in one noninteractive process. A second zsh
// would discard rc-defined functions such as user command wrappers.
command.args(["-l", "-c", setup]);
command.arg(shell_path_argument(program, msys)?);
} else {
command.args(["-i", "-l"]);
if shell_type == ShellType::Bash {
// No terminal is attached: profile initialization must not create
// separate interactive job-control groups before the owned exec.
command.arg("+m");
}
command.args(["-c", setup]);
// Bash reserves this as $0; fish uses every following argument as $argv.
if shell_type == ShellType::Bash {
command.arg("galaxy-background-job");
}
}
command.arg(shell_path_argument(approved_directory, msys)?);
command.arg(&prefix).arg(keys.len().to_string());
for key in keys {
command.arg(key);
command.env(format!("{prefix}{key}"), &environment[key]);
}
if shell_type != ShellType::Zsh {
command.arg(shell_path_argument(program, msys)?);
command.args(inner_flags);
}
command.arg(&launch.command);
Ok(())
}
fn shell_path_argument(path: &Path, msys: bool) -> Result<OsString, String> {
if !msys {
return Ok(path.as_os_str().to_owned());
}
let value = path
.to_str()
.ok_or("The MSYS2 background command path is not valid Unicode")?;
let value = if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
format!(r"\\{unc}")
} else {
value.strip_prefix(r"\\?\").unwrap_or(value).to_owned()
};
Ok(galaxy_util::path::convert_windows_path_to_msys2(&value).into())
}
fn configure_powershell_bootstrap(
command: &mut Command,
launch: &BackgroundJobLaunch,
approved_directory: &Path,
program: &Path,
environment: &HashMap<String, String>,
) -> Result<(), String> {
let cwd = approved_directory
.to_str()
.ok_or("The PowerShell command directory is not valid Unicode")?;
let program = program
.to_str()
.ok_or("The PowerShell executable path is not valid Unicode")?;
let payload = serde_json::to_string(&serde_json::json!({
"program": program,
"encoded_command": encode_powershell_command(&launch.command),
"cwd": cwd,
"environment": environment,
}))
.map_err(|_| "Could not prepare the background command environment".to_owned())?;
let key = format!(
"{BACKGROUND_ENV_PREFIX}{}_SETUP",
uuid::Uuid::new_v4().simple()
);
command.env(POWERSHELL_SETUP_KEY, &key).env(&key, payload);
let encoded = encode_powershell_command(POWERSHELL_PROFILE_SETUP);
command.args(["-NoLogo", "-NonInteractive", "-EncodedCommand", &encoded]);
Ok(())
}
fn encode_powershell_command(script: &str) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(
script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>(),
)
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;
+368
View File
@@ -0,0 +1,368 @@
use super::*;
#[test]
fn partial_environment_overrides_preserve_inheritance_and_protected_guards() {
let overrides = HashMap::from([
("KUBECONFIG".to_owned(), "/session/config".to_owned()),
("CI".to_owned(), "profile-value".to_owned()),
]);
let environment = background_environment(Some(&overrides), false).unwrap();
assert_eq!(
environment.get("KUBECONFIG").map(String::as_str),
Some("/session/config")
);
for (key, value) in NONINTERACTIVE_ENVIRONMENT {
assert_eq!(environment.get(*key).map(String::as_str), Some(*value));
}
// These remain inherited from the execution host, not fabricated or cleared
// when shell integration supplies an empty/partial map.
assert!(!background_environment(Some(&HashMap::new()), false)
.unwrap()
.contains_key("HOME"));
assert!(!environment.contains_key("PATH"));
let windows =
background_environment(Some(&HashMap::from([("ci".into(), "wrong".into())])), true)
.unwrap();
assert_eq!(windows.get("CI").map(String::as_str), Some("1"));
assert!(!windows.contains_key("ci"));
}
#[test]
fn environment_override_names_cannot_inject_shell_syntax_or_private_setup() {
for name in [
"",
"1BAD",
"A;echo bad",
"A=B",
"A\0B",
"_GALAXY_BACKGROUND_JOB_SETUP_KEY",
"__galaxy_job_count",
"argv",
] {
let overrides = HashMap::from([(name.to_owned(), "test-value".to_owned())]);
assert!(background_environment(Some(&overrides), false).is_err());
}
}
#[cfg(unix)]
#[test]
fn fallback_shell_uses_only_a_recognized_absolute_host_shell() {
assert_eq!(
fallback_unix_shell(Some("/usr/bin/zsh".into())),
(PathBuf::from("/usr/bin/zsh"), Some(ShellType::Zsh))
);
assert_eq!(
fallback_unix_shell(Some("/usr/bin/fish".into())),
(PathBuf::from("/usr/bin/fish"), Some(ShellType::Fish))
);
for value in [None, Some("bash".into()), Some("/bin/unknown-shell".into())] {
assert_eq!(fallback_unix_shell(value), (PathBuf::from("/bin/sh"), None));
}
}
#[test]
fn msys_bootstrap_paths_use_posix_drive_and_unc_forms() {
assert_eq!(
shell_path_argument(Path::new(r"C:\Program Files\Git\bin\bash.exe"), true).unwrap(),
OsString::from("/c/Program Files/Git/bin/bash.exe")
);
assert_eq!(
shell_path_argument(Path::new(r"\\?\C:\Users\name\project"), true).unwrap(),
OsString::from("/c/Users/name/project")
);
assert_eq!(
shell_path_argument(Path::new(r"\\?\UNC\server\share\project"), true).unwrap(),
OsString::from("//server/share/project")
);
}
#[cfg(unix)]
fn fixture_launch(
shell: PathBuf,
shell_type: ShellType,
command: &str,
cwd: &Path,
) -> BackgroundJobLaunch {
BackgroundJobLaunch {
conversation_id: crate::ai::agent::conversation::AIConversationId::new(),
action_id: "profile-test-action".to_owned().into(),
command: command.to_owned(),
working_directory: cwd.to_path_buf(),
shell: Some(ShellLaunchData::Executable {
executable_path: shell,
shell_type,
}),
environment: Some(HashMap::new()),
host_id: None,
}
}
#[cfg(unix)]
fn write_executable(path: &Path, contents: &str) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, contents).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn profile_bootstrap_keeps_command_noninteractive_and_restores_overrides_cwd_and_guards() {
let root = tempfile::tempdir().unwrap();
let cwd = std::fs::canonicalize(root.path()).unwrap();
let shell = cwd.join("fixture-bash");
// Exercise actual bash initialization state and argv forwarding without
// sourcing /etc/profile, the developer's HOME, or any real user rc files.
write_executable(
&shell,
r#"#!/bin/sh
printf '%s\n' "$@" >> "$GALAXY_TEST_ARGV"
if [ "$1" = '-i' ] && [ "$2" = '-l' ] && [ "$3" = '+m' ] && [ "$4" = '-c' ]; then
printf '%s' "$$" > "$GALAXY_TEST_PID"
shift 4
GALAXY_TEST_SETUP=$1
export GALAXY_TEST_SETUP
shift
exec /bin/bash --noprofile --norc -i +m -c '. "$GALAXY_TEST_PROFILE"; builtin eval "$GALAXY_TEST_SETUP"' "$@"
fi
exec /bin/bash "$@"
"#,
);
let profile = cwd.join("fixture-profile");
std::fs::write(
&profile,
r#"
case $- in *i*) ;; *) return ;; esac
if [ "$CI" = 1 ] || [ "$TERM" = dumb ]; then return; fi
export GALAXY_TEST_PROFILE_LOADED=loaded
export GALAXY_TEST_OVERRIDE=profile
export CI=profile
export SSH_ASKPASS=profile
export BASH_ENV="$GALAXY_TEST_BAD_STARTUP"
export PATH="$GALAXY_TEST_TOOLS:$PATH"
cd /
"#,
)
.unwrap();
let tools = cwd.join("tools");
std::fs::create_dir(&tools).unwrap();
write_executable(
&tools.join("galaxy-fixture-tool"),
"#!/bin/sh\nprintf 'tool-found\\n'\n",
);
let bad_startup = cwd.join("unexpected-noninteractive-startup");
std::fs::write(&bad_startup, "exit 97\n").unwrap();
let text = r#"printf '%s\n' "$PWD" "$GALAXY_TEST_PROFILE_LOADED" "$GALAXY_TEST_OVERRIDE" "$CI" "${SSH_ASKPASS-unset}" "$$"
case $- in *i*) exit 99 ;; esac
if IFS= read -r ignored; then exit 98; fi
galaxy-fixture-tool
printf '%s\n' "a'b \"quoted\"" "line1
line2"
"#;
let mut launch = fixture_launch(shell, ShellType::Bash, text, &cwd);
let secret = "synthetic-session-value-not-for-argv";
launch.environment = Some(HashMap::from([
("GALAXY_TEST_OVERRIDE".into(), secret.into()),
("CI".into(), "profile-startup".into()),
("TERM".into(), "profile-startup".into()),
]));
let mut command = command_for_launch(&launch, &cwd).unwrap();
let argv_log = cwd.join("argv");
let pid_file = cwd.join("pid");
command
.current_dir(&cwd)
.env("GALAXY_TEST_PROFILE", profile)
.env("GALAXY_TEST_TOOLS", tools)
.env("GALAXY_TEST_ARGV", &argv_log)
.env("GALAXY_TEST_PID", &pid_file)
.env("GALAXY_TEST_BAD_STARTUP", bad_startup)
.env_remove("BASH_ENV");
let output = command.output().await.unwrap();
assert!(output.status.success());
let original_pid = std::fs::read_to_string(pid_file).unwrap();
assert_eq!(String::from_utf8(output.stdout).unwrap(), format!("{}\nloaded\n{secret}\n1\nunset\n{original_pid}\ntool-found\na'b \"quoted\"\nline1\nline2\n", cwd.display()));
assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret));
}
#[cfg(unix)]
#[tokio::test]
async fn zsh_worker_retains_profile_functions_aliases_and_state_without_interactivity() {
let Some(zsh) = std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|path| path.join("zsh"))
.find(|path| path.is_file())
}) else {
// Optional host tool: fixture coverage never sources real user profiles.
return;
};
let root = tempfile::tempdir().unwrap();
let cwd = std::fs::canonicalize(root.path()).unwrap();
let shell = cwd.join("fixture-zsh");
let home = cwd.join("home");
let rc_directory = cwd.join("rc");
let bin = home.join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::create_dir(&rc_directory).unwrap();
// Exercise the selected zsh worker and constant setup script while
// disabling automatic user/login profiles in this isolated fixture.
write_executable(
&shell,
r#"#!/bin/sh
printf '%s\n' "$@" >> "$GALAXY_TEST_ARGV"
printf '%s' "$$" > "$GALAXY_TEST_PID"
if [ "$1" != '-l' ] || [ "$2" != '-c' ]; then exit 95; fi
shift
exec "$GALAXY_TEST_ZSH" -f "$@"
"#,
);
write_executable(
&bin.join("galaxy-fixture-tool"),
r#"#!/bin/sh
if [ "${GALAXY_TEST_UNEXPORTED+x}" = x ]; then exit 94; fi
printf 'tool:%s:%s\n' "$1" "$2"
"#,
);
std::fs::write(
bin.join("worktree.zsh"),
r#"wt() {
"$HOME/bin/galaxy-fixture-tool" "$GALAXY_TEST_UNEXPORTED" "$@"
}
alias galaxy-fixture-alias='wt alias'
"#,
)
.unwrap();
std::fs::write(
rc_directory.join(".zshrc"),
r#"if [[ -o interactive || $# -ne 0 ]]; then return 93; fi
printf '%s\n' loaded >> "$GALAXY_TEST_PROFILE_COUNT"
source "$HOME/bin/worktree.zsh"
GALAXY_TEST_UNEXPORTED='worker-state'
GALAXY_TEST_PROFILE_PID=$$
export PATH="$HOME/bin:$PATH"
export GALAXY_TEST_OVERRIDE=profile
export CI=profile
export TERM=profile
export SSH_ASKPASS=profile
export SUDO_ASKPASS=profile
export BASH_ENV=profile
export ENV=profile
builtin set -- 'rc changed argv' 'discard these arguments'
builtin cd /
"#,
)
.unwrap();
let script = r#"printf '%s\n' "$PWD" "$0" "$GALAXY_TEST_PROFILE_PID" "$GALAXY_TEST_OVERRIDE" "$CI" "$TERM" "${SSH_ASKPASS-unset}" "${SUDO_ASKPASS-unset}" "${BASH_ENV-unset}" "${ENV-unset}" "$#"
if [[ -o interactive || -t 0 || -t 1 || -t 2 ]]; then exit 99; fi
if IFS= read -r ignored; then exit 98; fi
wt direct
galaxy-fixture-alias
galaxy-fixture-tool lookup via-path
printf '%s\n' "a'b \"quoted\"" "line1
line2"
# trailing comment
"#;
let secret = "synthetic-zsh-session-value-not-for-argv";
let argv_log = cwd.join("argv");
let pid_file = cwd.join("pid");
let profile_count = cwd.join("profile-count");
for (index, (text, expected_code, expected_tail)) in [
(script, 0, None),
(
"wt exit-check\nexit 17\n# trailing comment",
17,
Some("tool:worker-state:exit-check\n"),
),
(
"wt failure-check\nfalse\n# trailing comment",
1,
Some("tool:worker-state:failure-check\n"),
),
]
.into_iter()
.enumerate()
{
let mut launch = fixture_launch(shell.clone(), ShellType::Zsh, text, &cwd);
launch.environment = Some(HashMap::from([(
"GALAXY_TEST_OVERRIDE".into(),
secret.into(),
)]));
let mut command = command_for_launch(&launch, &cwd).unwrap();
command
.current_dir(&cwd)
.env("HOME", &home)
.env("ZDOTDIR", &rc_directory)
.env("GALAXY_TEST_ZSH", &zsh)
.env("GALAXY_TEST_ARGV", &argv_log)
.env("GALAXY_TEST_PID", &pid_file)
.env("GALAXY_TEST_PROFILE_COUNT", &profile_count)
.env_remove("GALAXY_TEST_UNEXPORTED")
.env_remove("GALAXY_TEST_PROFILE_PID");
let output = command.output().await.unwrap();
assert_eq!(
output.status.code(),
Some(expected_code),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let expected = if let Some(tail) = expected_tail {
tail.to_owned()
} else {
let original_pid = std::fs::read_to_string(&pid_file).unwrap();
format!(
"{}\n{}\n{original_pid}\n{secret}\n1\ndumb\nunset\nunset\nunset\nunset\n0\ntool:worker-state:direct\ntool:worker-state:alias\ntool:lookup:via-path\na'b \"quoted\"\nline1\nline2\n",
cwd.display(),
shell.display()
)
};
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
assert_eq!(
std::fs::read_to_string(&profile_count).unwrap(),
"loaded\n".repeat(index + 1)
);
}
assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret));
}
#[cfg(unix)]
#[tokio::test]
async fn powershell_setup_preserves_explicit_exit_native_failure_and_script_text() {
let Some(pwsh) = std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|path| path.join("pwsh"))
.find(|path| path.is_file())
}) else {
// Optional host tool: the Unix bootstrap coverage does not require PowerShell.
return;
};
let root = tempfile::tempdir().unwrap();
let shell = root.path().join("fixture-pwsh");
// Disable real profiles in the test host only. Production configuration
// intentionally omits -NoProfile and uses the same constant setup script.
write_executable(
&shell,
"#!/bin/sh\nexec \"$GALAXY_TEST_PWSH\" -NoProfile \"$@\"\n",
);
for (script, expected_code, expected_stdout) in [
("exit 17", 17, ""),
("& /bin/sh -c 'exit 9'", 1, ""),
("Write-Error 'failure'", 1, ""),
(
"Write-Error 'failure'; Write-Output 'success'",
0,
"success\n",
),
(
"param()\nWrite-Output 'quoted \"value\"'\n# trailing comment",
0,
"quoted \"value\"\n",
),
] {
let launch = fixture_launch(shell.clone(), ShellType::PowerShell, script, root.path());
let mut command = command_for_launch(&launch, root.path()).unwrap();
command
.current_dir(root.path())
.env("GALAXY_TEST_PWSH", &pwsh);
let output = command.output().await.unwrap();
assert_eq!(output.status.code(), Some(expected_code));
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected_stdout);
}
}
+231
View File
@@ -0,0 +1,231 @@
use std::fs;
use chrono::Utc;
use super::{load_history, read_tail, BackgroundJob, BackgroundJobStatus, SavedHistory};
#[test]
fn history_restore_cancels_unfinished_records_without_restoring_completion_work() {
let root = tempfile::tempdir().unwrap();
let job = BackgroundJob {
id: uuid::Uuid::new_v4(),
conversation_id: crate::ai::agent::conversation::AIConversationId::new(),
action_id: "job-test-action".to_owned().into(),
command: "echo hello".to_owned(),
working_directory: root.path().to_path_buf(),
project_root: None,
host_id: None,
started_at: Utc::now(),
finished_at: None,
status: BackgroundJobStatus::Running,
exit_code: None,
error: None,
completion_delivered: false,
log_path: root.path().join("untrusted-log-path"),
output: "not persisted".to_owned(),
output_truncated: false,
log_bytes: 0,
};
let id = job.id;
fs::write(
root.path().join("history.json"),
serde_json::to_vec(&SavedHistory {
version: 1,
jobs: vec![job],
})
.unwrap(),
)
.unwrap();
let (loaded, interrupted, error) = load_history(root.path(), &root.path().join("logs"));
assert!(error.is_none());
let restored = &loaded[&id];
assert_eq!(restored.status, BackgroundJobStatus::Cancelled);
assert!(restored.completion_delivered);
assert!(interrupted.contains(&restored.conversation_id));
assert!(restored.output.is_empty());
assert_eq!(
restored.log_path,
root.path().join("logs").join(format!("{id}.log"))
);
}
#[test]
fn remote_history_preserves_unknown_process_state_until_its_host_reconnects() {
let root = tempfile::tempdir().unwrap();
let logs = root.path().join("logs");
fs::create_dir(&logs).unwrap();
let id = uuid::Uuid::new_v4();
fs::write(logs.join(format!("{id}.log")), "previous output\n").unwrap();
let conversation_id = crate::ai::agent::conversation::AIConversationId::new();
let job = BackgroundJob {
id,
conversation_id,
action_id: "remote-job-test-action".to_owned().into(),
command: "make build".to_owned(),
working_directory: "/remote/project".into(),
project_root: None,
host_id: Some(galaxy_util::host_id::HostId::new("test-host".to_owned())),
started_at: Utc::now(),
finished_at: None,
status: BackgroundJobStatus::Running,
exit_code: None,
error: None,
completion_delivered: false,
log_path: logs.join(format!("{id}.log")),
output: String::new(),
output_truncated: false,
log_bytes: 0,
};
fs::write(
root.path().join("history.json"),
serde_json::to_vec(&SavedHistory {
version: 1,
jobs: vec![job],
})
.unwrap(),
)
.unwrap();
let (loaded, interrupted, error) = load_history(root.path(), &logs);
assert!(error.is_none());
assert!(!interrupted.contains(&conversation_id));
let restored = &loaded[&id];
assert_eq!(restored.status, BackgroundJobStatus::Running);
assert!(restored.finished_at.is_none());
assert!(restored.completion_delivered);
assert!(restored.error.as_deref().unwrap().contains("reconnect"));
assert_eq!(restored.output, "previous output\n");
assert_eq!(restored.log_bytes, 16);
}
#[test]
fn log_tail_is_bounded_and_reports_the_full_byte_count() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("job.log");
fs::write(&path, b"prefix\nlast output").unwrap();
let (tail, size) = read_tail(&path, 11).unwrap();
assert_eq!(tail, "last output");
assert_eq!(size, 18);
}
#[cfg(unix)]
#[test]
fn completion_capture_keeps_output_written_after_temporary_log_is_removed() {
use std::io::Write;
let root = tempfile::tempdir().unwrap();
let path = root.path().join("job.log");
let mut writer = super::create_private_log(&path).unwrap();
let mut reader = super::open_log_reader(&path).unwrap();
writer.write_all(b"before\n").unwrap();
assert_eq!(
super::read_tail_from_open_file(&mut reader, 64).unwrap(),
("before\n".to_owned(), 7)
);
fs::remove_file(&path).unwrap();
writer.write_all(b"after\n").unwrap();
assert!(read_tail(&path, 64).is_err());
assert_eq!(
super::read_tail_from_open_file(&mut reader, 64).unwrap(),
("before\nafter\n".to_owned(), 13)
);
}
#[test]
fn corrupt_history_is_reported_instead_of_replaced() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("history.json"), b"broken").unwrap();
let (jobs, interrupted, error) = load_history(root.path(), root.path());
assert!(jobs.is_empty());
assert!(interrupted.is_empty());
assert!(error.is_some());
assert_eq!(
fs::read(root.path().join("history.json")).unwrap(),
b"broken"
);
}
fn cancellation_fixture(status: BackgroundJobStatus) -> BackgroundJob {
BackgroundJob {
id: uuid::Uuid::new_v4(),
conversation_id: crate::ai::agent::conversation::AIConversationId::new(),
action_id: "cancellation-test-action".to_owned().into(),
command: "already completed command".to_owned(),
working_directory: std::path::PathBuf::new(),
project_root: None,
host_id: None,
started_at: Utc::now(),
finished_at: Some(Utc::now()),
status,
exit_code: Some(17),
error: Some("original outcome".to_owned()),
completion_delivered: true,
log_path: std::path::PathBuf::new(),
output: "original output".to_owned(),
output_truncated: false,
log_bytes: 15,
}
}
#[test]
fn cancelling_a_completed_job_preserves_its_outcome_without_signalling_or_redelivery() {
galaxyui::App::test((), |mut app| async move {
let jobs = app.add_model(super::BackgroundJobsModel::new);
for status in [
BackgroundJobStatus::Succeeded,
BackgroundJobStatus::Failed,
BackgroundJobStatus::Cancelled,
] {
jobs.update(&mut app, |jobs, ctx| {
let job = cancellation_fixture(status);
let id = job.id;
let owner = job.conversation_id;
let finished_at = job.finished_at;
let (cancel, cancellations) = async_channel::bounded(1);
jobs.jobs.insert(id, job);
// Even a stale remote cancellation handle must not be used.
jobs.remote_cancellations.insert(id, cancel);
assert_eq!(jobs.cancel(id, ctx), Ok(()));
assert_eq!(jobs.cancel(id, ctx), Ok(()));
assert_eq!(
cancellations.try_recv(),
Err(async_channel::TryRecvError::Empty)
);
let job = jobs.job(id).unwrap();
assert_eq!(job.status, status);
assert_eq!(job.exit_code, Some(17));
assert_eq!(job.finished_at, finished_at);
assert_eq!(job.error.as_deref(), Some("original outcome"));
assert_eq!(job.output, "original output");
assert_eq!(job.completion_delivered, true);
assert_eq!(jobs.pending_completions(owner).len(), 0);
assert_eq!(jobs.unpublished_completions.contains(&id), false);
});
}
});
}
#[test]
fn cancellation_still_rejects_unknown_and_unowned_running_processes() {
galaxyui::App::test((), |mut app| async move {
let jobs = app.add_model(super::BackgroundJobsModel::new);
jobs.update(&mut app, |jobs, ctx| {
let unknown = uuid::Uuid::new_v4();
let (cancel, cancellations) = async_channel::bounded(1);
jobs.remote_cancellations.insert(unknown, cancel);
assert_eq!(jobs.cancel(unknown, ctx), Err("Background job not found".to_owned()));
assert_eq!(cancellations.try_recv(), Err(async_channel::TryRecvError::Empty));
let mut job = cancellation_fixture(BackgroundJobStatus::Running);
job.finished_at = None;
let id = job.id;
jobs.jobs.insert(id, job);
// A known Running record alone never authorizes signalling a PID.
assert_eq!(jobs.cancel(id, ctx), Err("This background job is no longer running".to_owned()));
jobs.unreconciled_remote_jobs.insert(id);
assert_eq!(jobs.cancel(id, ctx), Err("Waiting to reconnect and confirm this job's state on its host before cancellation is available".to_owned()));
assert_eq!(jobs.job(id).unwrap().status, BackgroundJobStatus::Running);
});
});
}
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
use std::path::{Path, PathBuf};
use chrono::Utc;
use galaxy_core::HostId;
use uuid::Uuid;
use super::{job_matches_scope, JobScopeFilter};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentActionId;
use crate::ai::background_jobs::{BackgroundJob, BackgroundJobStatus};
fn job(directory: &str, project: Option<&str>) -> BackgroundJob {
BackgroundJob {
id: Uuid::new_v4(),
conversation_id: AIConversationId::new(),
action_id: AIAgentActionId::from("background-job-test".to_owned()),
command: "cargo build".to_owned(),
working_directory: PathBuf::from(directory),
project_root: project.map(PathBuf::from),
host_id: None,
started_at: Utc::now(),
finished_at: None,
status: BackgroundJobStatus::Running,
exit_code: None,
error: None,
completion_delivered: false,
log_path: PathBuf::new(),
output: String::new(),
output_truncated: false,
log_bytes: 0,
}
}
#[test]
fn project_history_includes_sibling_directories_but_keeps_hosts_separate() {
let root = Path::new("/project");
let cwd = Path::new("/project/app");
let sibling = job("/project/server", Some("/project"));
assert!(job_matches_scope(
&sibling,
JobScopeFilter::Project,
Some(cwd),
Some(root),
None
));
assert!(!job_matches_scope(
&sibling,
JobScopeFilter::Directory,
Some(cwd),
Some(root),
None
));
assert!(!job_matches_scope(
&job("/other", Some("/other")),
JobScopeFilter::Project,
Some(cwd),
Some(root),
None
));
let remote = BackgroundJob {
host_id: Some(HostId::new("remote-host".to_owned())),
..sibling
};
assert!(!job_matches_scope(
&remote,
JobScopeFilter::Project,
Some(cwd),
Some(root),
None
));
assert!(job_matches_scope(
&remote,
JobScopeFilter::Project,
Some(cwd),
Some(root),
remote.host_id.as_ref()
));
assert!(job_matches_scope(
&remote,
JobScopeFilter::All,
Some(cwd),
Some(root),
None
));
}
#[test]
fn directory_history_is_exact_when_no_git_project_exists() {
let cwd = Path::new("/scratch");
assert!(job_matches_scope(
&job("/scratch", None),
JobScopeFilter::Directory,
Some(cwd),
None,
None
));
assert!(!job_matches_scope(
&job("/scratch/other", None),
JobScopeFilter::Directory,
Some(cwd),
None,
None
));
assert!(!job_matches_scope(
&job("/scratch", None),
JobScopeFilter::Project,
Some(cwd),
None,
None
));
}
+138 -5
View File
@@ -418,19 +418,76 @@ fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bo
} else { } else {
ToolResultStatus::Success ToolResultStatus::Success
}; };
let content = action_result.result.model_content(); let (content, images) = computer_use_model_content(&action_result.result)
.unwrap_or_else(|| (action_result.result.model_content(), Vec::new()));
let content = if permission_denied { let content = if permission_denied {
format!("Permission denied by the user. {content}") format!("Permission denied by the user. {content}")
} else { } else {
content content
}; };
ToolResult { ToolResult {
images: if permission_denied {
Vec::new()
} else {
images
},
call_id: action_result.id.to_string(), call_id: action_result.id.to_string(),
content, content,
status, status,
} }
} }
fn computer_use_model_content(
result: &AIAgentActionResultType,
) -> Option<(String, Vec<galaxy_agent_core::ToolResultImage>)> {
let (screenshot, mut metadata) = match result {
AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Approved {
screenshot,
platform,
windows,
}) => (
Some(screenshot),
serde_json::json!({
"status": "approved",
"platform": format!("{platform:?}"),
"windows": windows,
}),
),
AIAgentActionResultType::UseComputer(UseComputerResult::Success(result)) => (
result.screenshot.as_ref(),
serde_json::json!({
"status": "success",
"cursor_position": result.cursor_position.map(|point| serde_json::json!({
"x": point.x(), "y": point.y(),
})),
"windows": result.windows,
"captured_window": result.captured_window.map(|window| serde_json::json!({
"window_id": window.window_id,
"width_px": window.width_px,
"height_px": window.height_px,
})),
}),
),
_ => return None,
};
let images = screenshot
.map(|screenshot| {
metadata["screenshot"] = serde_json::json!({
"width": screenshot.width,
"height": screenshot.height,
"original_width": screenshot.original_width,
"original_height": screenshot.original_height,
"coordinate_space": "Use original physical pixels; scale image coordinates by original_width/width and original_height/height.",
});
vec![galaxy_agent_core::ToolResultImage {
data: screenshot.data.clone(),
mime_type: screenshot.mime_type.to_string(),
}]
})
.unwrap_or_default();
Some((metadata.to_string(), images))
}
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
fn action_tool_name(action: &AIAgentAction) -> String { fn action_tool_name(action: &AIAgentAction) -> String {
action action
@@ -481,7 +538,17 @@ fn action_result_type_name(result: &AIAgentActionResultType) -> &'static str {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
fn action_result_status(result: &AIAgentActionResultType) -> &'static str { fn action_result_status(result: &AIAgentActionResultType) -> &'static str {
if action_result_is_success_for_remote_log(result) { if matches!(
result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::BackgroundJobStarted { .. },
) | AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested { .. },
)
) {
// Launch/cancellation acceptance does not establish the command's exit outcome.
"accepted"
} else if action_result_is_success_for_remote_log(result) {
"success" "success"
} else if result.is_failed() || action_result_failure_summary(result).is_some() { } else if result.is_failed() || action_result_failure_summary(result).is_some() {
"error" "error"
@@ -506,7 +573,8 @@ fn action_result_is_success_for_remote_log(result: &AIAgentActionResultType) ->
WriteToLongRunningShellCommandResult::CommandFinished { exit_code, .. }, WriteToLongRunningShellCommandResult::CommandFinished { exit_code, .. },
) => exit_code.was_successful(), ) => exit_code.was_successful(),
AIAgentActionResultType::WriteToLongRunningShellCommand( AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Cancelled WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested { .. }
| WriteToLongRunningShellCommandResult::Cancelled
| WriteToLongRunningShellCommandResult::Error(_), | WriteToLongRunningShellCommandResult::Error(_),
) )
| AIAgentActionResultType::RequestCommandOutput(_) | AIAgentActionResultType::RequestCommandOutput(_)
@@ -644,12 +712,14 @@ fn action_result_failure_summary(result: &AIAgentActionResultType) -> Option<Str
RequestCommandOutputResult::ExecutionError { message, .. }, RequestCommandOutputResult::ExecutionError { message, .. },
) => Some(message.clone()), ) => Some(message.clone()),
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { .. } RequestCommandOutputResult::BackgroundJobStarted { .. }
| RequestCommandOutputResult::Completed { .. }
| RequestCommandOutputResult::CancelledBeforeExecution | RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::LongRunningCommandSnapshot { .. }, | RequestCommandOutputResult::LongRunningCommandSnapshot { .. },
) )
| AIAgentActionResultType::WriteToLongRunningShellCommand( | AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Cancelled WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested { .. }
| WriteToLongRunningShellCommandResult::Cancelled
| WriteToLongRunningShellCommandResult::CommandFinished { .. } | WriteToLongRunningShellCommandResult::CommandFinished { .. }
| WriteToLongRunningShellCommandResult::Snapshot { .. }, | WriteToLongRunningShellCommandResult::Snapshot { .. },
) )
@@ -751,6 +821,18 @@ fn log_tool_event(
); );
} }
/// Stream the app's projected tool arguments into the inspector's bounded
/// writer instead of allocating an unbounded Debug string first.
#[cfg(not(target_family = "wasm"))]
struct ActivityToolArguments<'a>(&'a AIAgentActionType);
#[cfg(not(target_family = "wasm"))]
impl serde::Serialize for ActivityToolArguments<'_> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&format_args!("{:#?}", self.0))
}
}
pub struct BlocklistAIActionModel { pub struct BlocklistAIActionModel {
executor: ModelHandle<BlocklistAIActionExecutor>, executor: ModelHandle<BlocklistAIActionExecutor>,
active_session: ModelHandle<ActiveSession>, active_session: ModelHandle<ActiveSession>,
@@ -1959,6 +2041,22 @@ impl BlocklistAIActionModel {
action.id, action.id,
std::mem::discriminant(&action.action) std::mem::discriminant(&action.action)
); );
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"tool_call",
format!("Tool requested: {}", action_tool_name(action)),
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action.id.to_string(),
"task_id": action.task_id.to_string(),
"tool_name": action_tool_name(action),
"payload_kind": "projected_tool_arguments",
"requires_result": action.requires_result,
}),
&ActivityToolArguments(&action.action),
);
}
} }
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
log_tool_event( log_tool_event(
@@ -2546,6 +2644,41 @@ impl BlocklistAIActionModel {
{ {
tool_result.content = reason; tool_result.content = reason;
} }
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
#[derive(serde::Serialize)]
struct ResultPayload<'a> {
content: &'a str,
status: &'a ToolResultStatus,
image_count: usize,
image_bytes_omitted: usize,
}
crate::ai::activity::record_serializable(
"tool_result",
format!(
"Tool result: {}",
action_result_type_name(&action_result.result)
),
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action_result.id.to_string(),
"task_id": action_result.task_id.to_string(),
"result_type": action_result_type_name(&action_result.result),
"permission_denied": permission_denied,
"payload_kind": "provider_neutral_tool_result",
}),
&ResultPayload {
content: &tool_result.content,
status: &tool_result.status,
image_count: tool_result.images.len(),
image_bytes_omitted: tool_result
.images
.iter()
.map(|image| image.data.len())
.sum(),
},
);
}
if execution_ref.is_none() { if execution_ref.is_none() {
self.finished_tool_results self.finished_tool_results
.entry(conversation_id) .entry(conversation_id)
@@ -93,7 +93,7 @@ impl GrepError {
error: GrepErrorType::NonZeroExitCode, error: GrepErrorType::NonZeroExitCode,
output: Some(output), output: Some(output),
.. ..
} => format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}"), } => actionable_grep_error(format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}")),
GrepError { GrepError {
error: GrepErrorType::NonZeroExitCode, error: GrepErrorType::NonZeroExitCode,
output: None, output: None,
@@ -102,11 +102,43 @@ impl GrepError {
GrepError { GrepError {
error: GrepErrorType::Other(error), error: GrepErrorType::Other(error),
.. ..
} => error.clone(), } => actionable_grep_error(error.clone()),
} }
} }
} }
/// Preserve backend regex semantics and report recovery guidance only after
/// that backend has diagnosed invalid syntax. Never retry with different
/// semantics or reject valid backend-specific expressions in a local parser.
fn actionable_grep_error(message: String) -> String {
let lower = message.to_ascii_lowercase();
let invalid_regex = [
"invalid regular expression",
"invalid pattern",
"regex parse error",
"unmatched [",
"unmatched (",
"unmatched )",
"unbalanced parenth",
"parentheses not balanced",
"brackets ([ ]) not balanced",
"unclosed group",
"unclosed character class",
"unterminated character class",
"unterminated [",
"missing closing parenthesis",
"not enough )",
"not enough ]",
]
.iter()
.any(|marker| lower.contains(marker));
if invalid_regex {
format!("{message}\nGrep uses regex by default. For literal source text, retry the same queries with fixed_strings: true. For an intended regex, correct or escape the invalid syntax; do not repeat the same malformed pattern.")
} else {
message
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn create_redacted_grep_error_event( fn create_redacted_grep_error_event(
should_collect_ugc: bool, should_collect_ugc: bool,
@@ -125,3 +125,45 @@ fn parse_grep_output_reports_when_results_reach_the_per_file_limit() {
); );
assert!(truncated); assert!(truncated);
} }
#[test]
fn backend_regex_syntax_errors_explain_literal_and_regex_recovery() {
for output in [
"fatal: -e option, 'variantResolutions: []': brackets ([ ]) not balanced",
"fatal: -e option, 'indexAsset(context': parentheses not balanced",
"fatal: command line, 'variantResolutions: []': Unmatched [, [^, [:, [., or [=",
r"fatal: command line, 'indexAsset(context': Unmatched ( or \(",
"regex parse error: unclosed group",
"Select-String: Invalid pattern 'indexAsset(context': Not enough )'s.",
] {
let error = GrepError::new_for_non_zero_exit_code().with_output(output.to_owned());
let message = error.error_for_conversation();
assert!(message.contains(output));
assert!(message.contains("fixed_strings: true"));
assert!(message.contains("correct or escape"));
assert_eq!(error.error_message(), NON_ZERO_EXIT_CODE_ERROR);
}
}
#[test]
fn ripgrep_regex_errors_also_include_literal_mode_guidance() {
let error =
GrepError::new("Ripgrep search failed: regex parse error: unclosed group".to_owned());
assert!(error
.error_for_conversation()
.contains("fixed_strings: true"));
}
#[test]
fn unrelated_grep_failures_keep_the_original_diagnostic() {
let output = "fatal: cannot access '/project': Permission denied";
let error = GrepError::new_for_non_zero_exit_code().with_output(output.to_owned());
assert_eq!(
error.error_for_conversation(),
format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}")
);
assert_eq!(
GrepError::new("Grep operation timed out".to_owned()).error_for_conversation(),
"Grep operation timed out"
);
}
@@ -173,57 +173,7 @@ impl ReadFilesExecutor {
.await .await
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?; .map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
if !response.failed_files.is_empty() && response.file_contexts.is_empty() { Ok(remote_read_files_result(response))
let failed = response
.failed_files
.iter()
.map(|f| {
let reason = f
.error
.as_ref()
.map(|e| e.message.as_str())
.unwrap_or("unknown error");
format!("{}: {reason}", f.path)
})
.collect::<Vec<_>>()
.join(", ");
return Ok(ReadFilesResult::Error(format!(
"Failed to read files: {failed}"
)));
}
let file_contexts = response
.file_contexts
.into_iter()
.filter_map(|fc| {
let content = match fc.content? {
remote_server::proto::file_context_proto::Content::TextContent(
text,
) => crate::ai::agent::AnyFileContent::StringContent(text),
remote_server::proto::file_context_proto::Content::BinaryContent(
bytes,
) => crate::ai::agent::AnyFileContent::BinaryContent(bytes),
};
let line_range = match (fc.line_range_start, fc.line_range_end) {
(Some(start), Some(end)) => Some(start as usize..end as usize),
_ => None,
};
let last_modified = fc.last_modified_epoch_millis.map(|ms| {
std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms)
});
Some(crate::ai::agent::FileContext {
file_name: fc.file_name,
content,
line_range,
last_modified,
line_count: fc.line_count as usize,
})
})
.collect();
Ok(ReadFilesResult::Success {
files: file_contexts,
})
}), }),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| { on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result = let action_result =
@@ -254,16 +204,7 @@ impl ReadFilesExecutor {
result.file_contexts.extend(file_result.file_contexts); result.file_contexts.extend(file_result.file_contexts);
result.missing_files.extend(file_result.missing_files); result.missing_files.extend(file_result.missing_files);
} }
if result.missing_files.is_empty() { Ok(local_read_files_result(result))
Ok(ReadFilesResult::Success {
files: result.file_contexts,
})
} else {
let missing_files = result.missing_files.join(", ");
Ok(ReadFilesResult::Error(format!(
"These files do not exist: {missing_files}"
)))
}
}), }),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| { on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string())); let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string()));
@@ -284,3 +225,125 @@ impl ReadFilesExecutor {
impl Entity for ReadFilesExecutor { impl Entity for ReadFilesExecutor {
type Event = (); type Event = ();
} }
/// The shared action/protobuf contract has no partial-success representation.
/// Do not claim a remote batch succeeded while silently losing failed paths.
fn remote_read_files_result(
response: remote_server::proto::ReadFileContextResponse,
) -> ReadFilesResult {
if !response.failed_files.is_empty()
|| response
.file_contexts
.iter()
.any(|file| file.content.is_none())
{
let failed = response.failed_files.iter().map(|failure| {
(
failure.path.as_str(),
failure.error.as_ref().map(|error| error.message.as_str()),
)
});
let missing_content = response
.file_contexts
.iter()
.filter(|file| file.content.is_none())
.map(|file| {
let name = if file.file_name.is_empty() {
"<unnamed remote file>"
} else {
file.file_name.as_str()
};
(name, Some("remote host returned no file content"))
});
return read_files_failure(failed.chain(missing_content));
}
let file_contexts = response
.file_contexts
.into_iter()
.filter_map(|fc| {
let content = match fc.content? {
remote_server::proto::file_context_proto::Content::TextContent(text) => {
crate::ai::agent::AnyFileContent::StringContent(text)
}
remote_server::proto::file_context_proto::Content::BinaryContent(bytes) => {
crate::ai::agent::AnyFileContent::BinaryContent(bytes)
}
};
let line_range = match (fc.line_range_start, fc.line_range_end) {
(Some(start), Some(end)) => Some(start as usize..end as usize),
_ => None,
};
let last_modified = fc
.last_modified_epoch_millis
.map(|ms| std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms));
Some(crate::ai::agent::FileContext {
file_name: fc.file_name,
content,
line_range,
last_modified,
line_count: fc.line_count as usize,
})
})
.collect();
ReadFilesResult::Success {
files: file_contexts,
}
}
fn local_read_files_result(result: super::ReadFileContextResult) -> ReadFilesResult {
if result.missing_files.is_empty() {
ReadFilesResult::Success {
files: result.file_contexts,
}
} else {
read_files_failure(
result
.missing_files
.iter()
.map(|path| (path.as_str(), None)),
)
}
}
const MAX_REPORTED_READ_FAILURES: usize = 8;
const MAX_READ_FAILURE_FIELD_CHARS: usize = 256;
fn read_files_failure<'a>(
failures: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
) -> ReadFilesResult {
let mut message = "Could not read all requested files. No file contents were returned for this batch.\nFailed paths:".to_owned();
let mut total = 0;
for (path, reason) in failures {
total += 1;
if total > MAX_REPORTED_READ_FAILURES {
continue;
}
message.push_str("\n- ");
append_failure_field(&mut message, path);
message.push_str(": ");
append_failure_field(
&mut message,
reason.unwrap_or("missing, unreadable, or exceeds the read limit"),
);
}
if total > MAX_REPORTED_READ_FAILURES {
message.push_str(&format!(
"\n{} additional failed paths omitted.",
total - MAX_REPORTED_READ_FAILURES
));
}
message.push_str("\nUse file discovery to confirm exact paths before retrying. For large files, request a smaller line range or fewer files. A file may exist but be unreadable or exceed the read limit.");
ReadFilesResult::Error(message)
}
fn append_failure_field(message: &mut String, field: &str) {
let mut characters = field.chars();
message.extend(characters.by_ref().take(MAX_READ_FAILURE_FIELD_CHARS));
if characters.next().is_some() {
message.push('…');
}
}
#[cfg(test)]
#[path = "read_files_tests.rs"]
mod tests;
@@ -0,0 +1,143 @@
use super::*;
use crate::ai::agent::{AnyFileContent, FileContext};
fn remote_file() -> remote_server::proto::FileContextProto {
remote_server::proto::FileContextProto {
file_name: "/project/present.rs".to_owned(),
content: Some(
remote_server::proto::file_context_proto::Content::TextContent(
"returned file content".to_owned(),
),
),
line_range_start: Some(3),
line_range_end: Some(5),
last_modified_epoch_millis: Some(42),
line_count: 20,
}
}
#[test]
fn partial_remote_reads_report_failed_paths_instead_of_claiming_success() {
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![remote_file()],
failed_files: vec![remote_server::proto::FailedFileRead {
path: "/project/restricted.rs".to_owned(),
error: Some(remote_server::proto::FileOperationError {
message: "Permission denied".to_owned(),
}),
}],
});
let ReadFilesResult::Error(message) = result else {
panic!("A partial remote read must not claim full success");
};
assert!(message.contains("/project/restricted.rs: Permission denied"));
assert!(message.contains("No file contents were returned for this batch"));
assert!(!message.contains("returned file content"));
assert!(message.contains("confirm exact paths"));
}
#[test]
fn remote_file_context_without_content_is_an_explicit_failure() {
let mut missing = remote_file();
missing.file_name = "/project/no-content.rs".to_owned();
missing.content = None;
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![remote_file(), missing.clone()],
failed_files: vec![],
});
let ReadFilesResult::Error(message) = result else {
panic!("A remote context without content must not disappear into Success");
};
assert!(message.contains("/project/no-content.rs: remote host returned no file content"));
assert!(!message.contains("returned file content"));
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![missing],
failed_files: vec![remote_server::proto::FailedFileRead {
path: "/project/restricted.rs".to_owned(),
error: Some(remote_server::proto::FileOperationError {
message: "Permission denied".to_owned(),
}),
}],
});
let ReadFilesResult::Error(message) = result else {
panic!("Both remote failure categories must remain visible");
};
assert!(message.contains("/project/no-content.rs: remote host returned no file content"));
assert!(message.contains("/project/restricted.rs: Permission denied"));
}
#[test]
fn successful_remote_reads_preserve_content_ranges_and_file_metadata() {
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![remote_file()],
failed_files: vec![],
});
let ReadFilesResult::Success { files } = result else {
panic!("A complete remote read must succeed");
};
assert_eq!(files.len(), 1);
assert_eq!(files[0].file_name, "/project/present.rs");
assert_eq!(
files[0].content,
AnyFileContent::StringContent("returned file content".to_owned())
);
assert_eq!(files[0].line_range, Some(3..5));
assert_eq!(files[0].line_count, 20);
assert_eq!(
files[0].last_modified,
Some(std::time::UNIX_EPOCH + std::time::Duration::from_millis(42))
);
}
#[test]
fn local_failures_do_not_assert_that_oversized_or_unreadable_files_are_missing() {
let result = local_read_files_result(super::super::ReadFileContextResult {
file_contexts: vec![],
missing_files: vec!["/project/large.bin".to_owned()],
});
let ReadFilesResult::Error(message) = result else {
panic!("An unread file must remain an error");
};
assert!(message.contains("/project/large.bin"));
assert!(message.contains("exceeds the read limit"));
assert!(message.contains("smaller line range or fewer files"));
assert!(!message.contains("do not exist"));
}
#[test]
fn successful_local_reads_preserve_their_original_content() {
let file = FileContext {
file_name: "/project/present.rs".to_owned(),
content: AnyFileContent::StringContent("unchanged".to_owned()),
line_range: None,
last_modified: None,
line_count: 1,
};
assert_eq!(
local_read_files_result(super::super::ReadFileContextResult {
file_contexts: vec![file.clone()],
missing_files: vec![],
}),
ReadFilesResult::Success { files: vec![file] },
);
}
#[test]
fn failure_details_bound_unicode_paths_reasons_and_the_number_of_reported_files() {
let long_field = "界".repeat(10_000);
let result = read_files_failure(
(0..MAX_REPORTED_READ_FAILURES + 3)
.map(|_| (long_field.as_str(), Some(long_field.as_str()))),
);
let ReadFilesResult::Error(message) = result else {
panic!("Failed paths must produce an error");
};
assert_eq!(message.matches("\n- ").count(), MAX_REPORTED_READ_FAILURES);
assert!(message.contains("3 additional failed paths omitted"));
assert!(message.contains('…'));
assert!(
message.chars().count()
< MAX_REPORTED_READ_FAILURES * MAX_READ_FAILURE_FIELD_CHARS * 2 + 1_000
);
}
@@ -95,7 +95,9 @@ impl RequestComputerUseExecutor {
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled(); let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
ActionExecution::Async { ActionExecution::Async {
execute_future: Box::pin(async move { execute_future: Box::pin(async move {
let result = actor let result = async {
computer_use::request_screen_capture_access()?;
actor
.perform_actions( .perform_actions(
&[], &[],
computer_use::Options { computer_use::Options {
@@ -103,6 +105,8 @@ impl RequestComputerUseExecutor {
background_enabled, background_enabled,
}, },
) )
.await
}
.await; .await;
(result, platform) (result, platform)
}), }),
@@ -30,7 +30,7 @@ use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
AIAgentOutputMessage, AIAgentOutputMessageType, AIIdentifiers, AnyFileContent, FileContext, AIAgentOutputMessage, AIAgentOutputMessageType, AIIdentifiers, AnyFileContent, FileContext,
FileLocations, RequestFileEditsResult, UpdatedFileContext, FileEdit, FileLocations, RequestFileEditsResult, UpdatedFileContext,
}; };
use crate::ai::blocklist::inline_action::code_diff_view::{ use crate::ai::blocklist::inline_action::code_diff_view::{
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff, CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
@@ -321,6 +321,17 @@ impl RequestFileEditsExecutor {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
let files = file_edits.clone(); let files = file_edits.clone();
let creation_scopes = files
.iter()
.filter_map(|edit| match edit {
FileEdit::Create {
file: Some(path),
creation_scope: Some(scope),
..
} => Some((path.clone(), scope.clone())),
_ => None,
})
.collect::<HashMap<_, _>>();
let id = id.clone(); let id = id.clone();
let apply_future = self.apply_diff_model.update(ctx, |model, ctx| { let apply_future = self.apply_diff_model.update(ctx, |model, ctx| {
@@ -333,7 +344,7 @@ impl RequestFileEditsExecutor {
(applied_diffs, id, tx) (applied_diffs, id, tx)
}, },
|me, (diffs, id, tx), ctx| { |me, (diffs, id, tx), ctx| {
me.on_diffs_applied(diffs, id, tx, ctx); me.on_diffs_applied(diffs, id, tx, creation_scopes, ctx);
}, },
); );
@@ -348,6 +359,7 @@ impl RequestFileEditsExecutor {
applied_diffs: Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>, applied_diffs: Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>,
id: AIAgentActionId, id: AIAgentActionId,
tx: oneshot::Sender<()>, tx: oneshot::Sender<()>,
creation_scopes: HashMap<String, PathBuf>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
match applied_diffs { match applied_diffs {
@@ -361,12 +373,19 @@ impl RequestFileEditsExecutor {
let diffs = applied_diffs let diffs = applied_diffs
.into_iter() .into_iter()
.map(|diff| { .map(|diff| {
let path = host_native_absolute_path( let path = if creation_scopes.contains_key(&diff.file_name) {
diff.file_name.clone()
} else {
host_native_absolute_path(
diff.file_name.as_str(), diff.file_name.as_str(),
&shell_launch_data, &shell_launch_data,
&current_working_directory, &current_working_directory,
); )
FileDiff::new(diff.original_content, path, diff.diff_type) };
let mut file_diff =
FileDiff::new(diff.original_content, path, diff.diff_type);
file_diff.creation_scope = creation_scopes.get(&diff.file_name).cloned();
file_diff
}) })
.collect(); .collect();
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) { let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
@@ -279,6 +279,7 @@ where
let mut search_replace_deltas: HashMap<String, Vec<SearchAndReplace>> = HashMap::new(); let mut search_replace_deltas: HashMap<String, Vec<SearchAndReplace>> = HashMap::new();
let mut v4a_deltas: HashMap<String, Vec<V4AHunk>> = HashMap::new(); let mut v4a_deltas: HashMap<String, Vec<V4AHunk>> = HashMap::new();
let mut new_files: HashMap<String, String> = HashMap::new(); let mut new_files: HashMap<String, String> = HashMap::new();
let mut native_new_file_paths = HashSet::new();
let mut deleted_files: HashSet<String> = HashSet::new(); let mut deleted_files: HashSet<String> = HashSet::new();
let mut file_renames: HashMap<String, String> = HashMap::new(); let mut file_renames: HashMap<String, String> = HashMap::new();
let mut result = DiffResult::default(); let mut result = DiffResult::default();
@@ -317,8 +318,15 @@ where
} }
}; };
} }
FileEdit::Create { file, content } => { FileEdit::Create {
file,
content,
creation_scope,
} => {
let Some(file_path) = file else { continue }; let Some(file_path) = file else { continue };
if creation_scope.is_some() {
native_new_file_paths.insert(file_path.clone());
}
match new_files.entry(file_path) { match new_files.entry(file_path) {
Entry::Occupied(entry) => { Entry::Occupied(entry) => {
@@ -392,7 +400,16 @@ where
} else if replacement_file_paths.contains(&file) { } else if replacement_file_paths.contains(&file) {
apply_replace_file(file, content, session_context, read_file, &mut result).await; apply_replace_file(file, content, session_context, read_file, &mut result).await;
} else { } else {
apply_create_file(file, content, session_context, read_file, &mut result).await; let path_is_host_native = native_new_file_paths.contains(&file);
apply_create_file(
file,
content,
path_is_host_native,
session_context,
read_file,
&mut result,
)
.await;
} }
} }
@@ -477,6 +494,7 @@ async fn apply_replace_file<F, Fut>(
async fn apply_create_file<F, Fut>( async fn apply_create_file<F, Fut>(
file_path: String, file_path: String,
content: String, content: String,
path_is_host_native: bool,
session_context: &SessionContext, session_context: &SessionContext,
read_file: &F, read_file: &F,
result: &mut DiffResult, result: &mut DiffResult,
@@ -484,11 +502,15 @@ async fn apply_create_file<F, Fut>(
F: Fn(String) -> Fut, F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>, Fut: Future<Output = FileReadResult>,
{ {
let absolute_path = host_native_absolute_path( let absolute_path = if path_is_host_native {
file_path.clone()
} else {
host_native_absolute_path(
&file_path, &file_path,
session_context.shell(), session_context.shell(),
session_context.current_working_directory(), session_context.current_working_directory(),
); )
};
match read_file(absolute_path.clone()).await { match read_file(absolute_path.clone()).await {
FileReadResult::Found(_) => { FileReadResult::Found(_) => {
@@ -389,10 +389,12 @@ fn test_multiple_file_create_edits_for_same_path() {
let create_edit1 = FileEdit::Create { let create_edit1 = FileEdit::Create {
file: Some(file_path.clone()), file: Some(file_path.clone()),
content: Some("First content".to_string()), content: Some("First content".to_string()),
creation_scope: None,
}; };
let create_edit2 = FileEdit::Create { let create_edit2 = FileEdit::Create {
file: Some(file_path.clone()), file: Some(file_path.clone()),
content: Some("Second content".to_string()), content: Some("Second content".to_string()),
creation_scope: None,
}; };
let ai_identifiers = &AIIdentifiers::default(); let ai_identifiers = &AIIdentifiers::default();
@@ -431,6 +433,7 @@ fn test_mixed_create_and_edit_for_same_path() {
let create_edit = FileEdit::Create { let create_edit = FileEdit::Create {
file: Some(file_path.clone()), file: Some(file_path.clone()),
content: Some("New file content".to_string()), content: Some("New file content".to_string()),
creation_scope: None,
}; };
let edit_diff = ParsedDiff::StrReplaceEdit { let edit_diff = ParsedDiff::StrReplaceEdit {
file: Some(file_path.clone()), file: Some(file_path.clone()),
@@ -479,6 +482,7 @@ fn test_delete_and_create_same_path_replaces_existing_file() {
let create_edit = FileEdit::Create { let create_edit = FileEdit::Create {
file: Some(file_path.clone()), file: Some(file_path.clone()),
content: Some("New file content".to_string()), content: Some("New file content".to_string()),
creation_scope: None,
}; };
let result = apply_edits( let result = apply_edits(
@@ -515,6 +519,7 @@ fn test_create_then_delete_same_path_replaces_existing_file() {
let create_edit = FileEdit::Create { let create_edit = FileEdit::Create {
file: Some(file_path.clone()), file: Some(file_path.clone()),
content: Some("New file content".to_string()), content: Some("New file content".to_string()),
creation_scope: None,
}; };
let delete_edit = FileEdit::Delete { let delete_edit = FileEdit::Delete {
file: Some(file_path.clone()), file: Some(file_path.clone()),
@@ -556,6 +561,7 @@ fn test_delete_create_and_edit_same_path_still_fails() {
let create_edit = FileEdit::Create { let create_edit = FileEdit::Create {
file: Some(file_path.clone()), file: Some(file_path.clone()),
content: Some("New file content".to_string()), content: Some("New file content".to_string()),
creation_scope: None,
}; };
let edit_diff = ParsedDiff::StrReplaceEdit { let edit_diff = ParsedDiff::StrReplaceEdit {
file: Some(file_path.clone()), file: Some(file_path.clone()),
@@ -596,6 +602,7 @@ fn test_create_edit_for_existing_file() {
let create_edit = FileEdit::Create { let create_edit = FileEdit::Create {
file: Some(file_path.clone()), file: Some(file_path.clone()),
content: Some("New content".to_string()), content: Some("New content".to_string()),
creation_scope: None,
}; };
let ai_identifiers = &AIIdentifiers::default(); let ai_identifiers = &AIIdentifiers::default();
@@ -54,7 +54,19 @@ impl ShellCommandExecutor {
&self, &self,
conversation_id: crate::AIConversationId, conversation_id: crate::AIConversationId,
block_id: &BlockId, block_id: &BlockId,
app: &galaxyui::AppContext,
) -> bool { ) -> bool {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
let value = block_id.to_string();
if let Ok(id) =
uuid::Uuid::parse_str(value.strip_prefix("background-job:").unwrap_or(&value))
{
return crate::ai::background_jobs::BackgroundJobsModel::as_ref(app)
.job(id)
.is_some_and(|job| job.conversation_id == conversation_id);
}
}
let model = self.terminal_model.lock(); let model = self.terminal_model.lock();
let Some(block) = model.block_list().block_with_id(block_id) else { let Some(block) = model.block_list().block_with_id(block_id) else {
return false; return false;
@@ -286,339 +298,201 @@ impl ShellCommandExecutor {
&mut self, &mut self,
input: ExecuteActionInput, input: ExecuteActionInput,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> { ) -> AnyActionExecution {
crate::ai::tool_diagnostics::tool_debug!(
"ShellCommandExecutor::execute: action_type={:?}",
std::mem::discriminant(&input.action.action)
);
let model = self.terminal_model.lock();
// Determine the action we want to take based on the input.
let action_id = input.action.id.clone();
let diagnostic_action_id = action_id.clone();
let conversation_id = input.conversation_id;
let handle = ctx.handle();
match &input.action.action { match &input.action.action {
AIAgentActionType::RequestCommandOutput { AIAgentActionType::RequestCommandOutput { command, .. } => {
command, self.start_background_command(input, command, ctx)
wait_until_completion,
..
} => {
if model
.block_list()
.active_block()
.is_active_and_long_running()
{
let running_command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false);
return ActionExecution::Sync(terminal_busy_execution_error(
command,
&running_command,
));
}
// If another conversation has taken over the agent view since this command
// was requested, cancel instead of executing.
let is_displaced_by_other_conversation = model
.block_list()
.agent_view_state()
.active_conversation_id()
.is_some_and(|active_id| active_id != input.conversation_id);
if is_displaced_by_other_conversation {
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::CancelledBeforeExecution,
));
}
// A command expected to finish must not enter an implicit pager. Do not trust the
// model-provided pager hint: commands such as `git log` can page implicitly.
let decorated_command = command_for_execution(
command,
self.active_session.as_ref(ctx).shell_type(ctx),
*wait_until_completion,
);
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
action_id: action_id.clone(),
command: decorated_command,
});
let block_selector = BlockSelector::RequestedCommandId(action_id.clone());
let command = command.clone();
let delay = if *wait_until_completion {
Some(ShellCommandDelay::OnCompletion)
} else {
None
};
drop(model);
ActionExecution::new_async(
self.action_result_future(block_selector.clone(), delay),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, ctx| {
log_shell_wait_result(
conversation_id,
&diagnostic_action_id,
&result,
ctx,
);
me.prune_closed_senders(&block_selector);
if matches!(
result,
ActionResult::BlockNotFound | ActionResult::Cancelled
) {
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: action_id.clone(),
});
}
});
}
action_result_for_requested_command(command, result)
},
)
} }
AIAgentActionType::WriteToLongRunningShellCommand { AIAgentActionType::WriteToLongRunningShellCommand {
block_id, block_id,
input, input: bytes,
mode, mode,
} => { } => {
let Some(block) = model.block_list().block_with_id(block_id) else { let result = if *mode == AIAgentPtyWriteMode::Raw && bytes.as_ref() == [3] {
return ActionExecution::Sync( self.cancel_background_job(input.conversation_id, block_id, ctx)
AIAgentActionResultType::WriteToLongRunningShellCommand( } else {
WriteToLongRunningShellCommandResult::Error( WriteToLongRunningShellCommandResult::Error(
ShellCommandError::BlockNotFound, ShellCommandError::NonInteractiveOnly,
),
),
);
};
if block.finished() {
let output: String = block.output_with_secrets_unobfuscated();
let exit_code = block.exit_code();
let start_ts = block.start_ts().cloned();
let completed_ts = block.completed_ts().cloned();
let result = ActionResult::CommandFinished {
block_id: block.id().clone(),
output,
exit_code,
start_ts,
completed_ts,
};
drop(model);
log_shell_wait_result(conversation_id, &action_id, &result, ctx);
return ActionExecution::Sync(
action_result_for_write_to_long_running_shell_command(result),
);
}
// Drop immutable borrow.
drop(model);
let mut model = self.terminal_model.lock();
if let Some(block) = model.block_list_mut().mut_block_from_id(block_id) {
block.mark_agent_written_to_block();
}
drop(model);
ctx.emit(ShellCommandExecutorEvent::WriteToPty {
input: input.clone(),
mode: *mode,
});
let block_selector = BlockSelector::Id(block_id.clone());
ActionExecution::new_async(
self.action_result_future(
block_selector.clone(),
Some(ShellCommandDelay::Duration(Duration::from_millis(200))),
),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, ctx| {
log_shell_wait_result(
conversation_id,
&diagnostic_action_id,
&result,
ctx,
);
me.prune_closed_senders(&block_selector);
});
}
action_result_for_write_to_long_running_shell_command(result)
},
) )
}
AIAgentActionType::ReadShellCommandOutput { block_id, delay } => {
let Some(block) = model.block_list().block_with_id(block_id) else {
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::Error(ShellCommandError::BlockNotFound),
));
}; };
if block.finished() { AnyActionExecution::Sync(AIAgentActionResultType::WriteToLongRunningShellCommand(
let command = block.command_with_secrets_unobfuscated(false); result,
let output: String = block.output_with_secrets_unobfuscated(); ))
let exit_code = block.exit_code();
let start_ts = block.start_ts().cloned();
let completed_ts = block.completed_ts().cloned();
let result = ActionResult::CommandFinished {
block_id: block_id.clone(),
output,
exit_code,
start_ts,
completed_ts,
};
drop(model);
log_shell_wait_result(conversation_id, &action_id, &result, ctx);
return ActionExecution::Sync(action_result_for_read_shell_command_output(
command, result,
));
} }
let command = block.command_with_secrets_unobfuscated(false); AIAgentActionType::ReadShellCommandOutput { .. } => {
drop(model); AnyActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::Error(ShellCommandError::NonInteractiveOnly),
let block_selector = BlockSelector::Id(block_id.clone()); ))
ActionExecution::new_async(
self.action_result_future(block_selector.clone(), delay.clone()),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, ctx| {
log_shell_wait_result(
conversation_id,
&diagnostic_action_id,
&result,
ctx,
);
me.prune_closed_senders(&block_selector);
});
} }
AIAgentActionType::TransferShellCommandControlToUser { .. } => {
action_result_for_read_shell_command_output(command.clone(), result) AnyActionExecution::Sync(
},
)
}
AIAgentActionType::TransferShellCommandControlToUser { reason } => {
let active_block = model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return ActionExecution::Sync(
AIAgentActionResultType::TransferShellCommandControlToUser( AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Error( TransferShellCommandControlToUserResult::Error(
ShellCommandError::NonInteractiveOnly,
),
),
)
}
_ => AnyActionExecution::InvalidAction,
}
}
fn start_background_command(
&self,
input: ExecuteActionInput,
command: &str,
ctx: &mut ModelContext<Self>,
) -> AnyActionExecution {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
use std::path::PathBuf;
use crate::ai::background_jobs::{BackgroundJobLaunch, BackgroundJobsModel};
use crate::ai::blocklist::SessionContext;
let session = self.active_session.as_ref(ctx);
let context = SessionContext::from_session(session, ctx);
let Some(cwd) = context.current_working_directory().as_deref() else {
return background_launch_error(
command,
"The session working directory is unavailable".to_owned(),
);
};
let directory = if context.is_remote() {
cwd.to_owned()
} else {
ai::paths::host_native_absolute_path(
cwd,
context.shell(),
context.current_working_directory(),
)
};
let session_id = session.session(ctx).map(|session| session.id());
let launch = BackgroundJobLaunch {
conversation_id: input.conversation_id,
action_id: input.action.id.clone(),
command: command.to_owned(),
working_directory: PathBuf::from(directory),
shell: context.shell().clone(),
environment: session.environment_variables(ctx),
host_id: context.host_id().cloned(),
};
if context.is_remote() {
let Some(host_id) = context.host_id() else {
return background_launch_error(
command,
"Connect to the command's remote host before running it".to_owned(),
);
};
let Some(session_id) = session_id else {
return background_launch_error(
command,
"The remote shell session is unavailable".to_owned(),
);
};
let Some(client) = remote_server::manager::RemoteServerManager::as_ref(ctx)
.client_for_host(host_id)
.cloned()
else {
return background_launch_error(
command,
"The command's remote host is disconnected".to_owned(),
);
};
if !client.supports_background_jobs() {
return background_launch_error(
command,
"Update Galaxy on the remote host to run agent background jobs".to_owned(),
);
}
let transport = Arc::new(
remote_server::manager::RemoteServerManager::as_ref(ctx)
.host_request_handle(host_id),
);
let result = BackgroundJobsModel::handle(ctx).update(ctx, |jobs, ctx| {
crate::ai::background_jobs::remote::start(
transport, launch, session_id, jobs, ctx,
)
});
return AnyActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
match result {
Ok(id) => RequestCommandOutputResult::BackgroundJobStarted {
job_id: id.to_string(),
command: command.to_owned(),
},
Err(message) => RequestCommandOutputResult::ExecutionError {
command: command.to_owned(),
message,
},
},
));
}
let result =
BackgroundJobsModel::handle(ctx).update(ctx, |jobs, ctx| jobs.start(launch, ctx));
AnyActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
match result {
Ok(id) => RequestCommandOutputResult::BackgroundJobStarted {
job_id: id.to_string(),
command: command.to_owned(),
},
Err(message) => RequestCommandOutputResult::ExecutionError {
command: command.to_owned(),
message,
},
},
))
}
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
{
let _ = (input, ctx);
background_launch_error(
command,
"Background commands are unavailable in this session".to_owned(),
)
}
}
fn cancel_background_job(
&self,
conversation_id: crate::AIConversationId,
block_id: &BlockId,
ctx: &mut ModelContext<Self>,
) -> WriteToLongRunningShellCommandResult {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
use crate::ai::background_jobs::BackgroundJobsModel;
let value = block_id.to_string();
let parsed =
uuid::Uuid::parse_str(value.strip_prefix("background-job:").unwrap_or(&value));
let Ok(id) = parsed else {
return WriteToLongRunningShellCommandResult::Error(
ShellCommandError::NonInteractiveOnly,
);
};
if !BackgroundJobsModel::as_ref(ctx)
.job(id)
.is_some_and(|job| job.conversation_id == conversation_id)
{
return WriteToLongRunningShellCommandResult::Error(
ShellCommandError::BlockNotFound, ShellCommandError::BlockNotFound,
),
),
); );
} }
return match BackgroundJobsModel::handle(ctx)
let block_id = active_block.id().clone(); .update(ctx, |jobs, ctx| jobs.cancel(id, ctx))
drop(model); {
Ok(()) => {
// Emit event to transfer control to user. WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
ctx.emit(ShellCommandExecutorEvent::TransferControlToUser { job_id: id.to_string(),
action_id: action_id.clone(), }
reason: reason.clone(), }
}); Err(message) => WriteToLongRunningShellCommandResult::Error(
ShellCommandError::BackgroundJobError(message),
// Create a channel to wait for control handback. ),
let (handback_tx, handback_rx) = oneshot::channel();
self.control_handback_sender = Some(handback_tx);
let block_selector = BlockSelector::Id(block_id.clone());
// Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders
.entry(block_selector.clone())
.or_default()
.push(block_finished_tx);
// Build the future that captures terminal model and block data.
let transfer_future = {
let terminal_model = self.terminal_model.clone();
let block_id = block_id.clone();
async move {
pin!(handback_rx);
pin!(block_finished_rx);
// Wait for either control handback or block completion.
let transfer_result = select! {
val = handback_rx => match val {
Ok(_) => TransferControlResult::ControlHandedBack,
Err(_) => TransferControlResult::Cancelled,
},
val = block_finished_rx => match val {
Ok(_) => TransferControlResult::BlockFinished,
Err(_) => TransferControlResult::Cancelled,
},
}; };
// Convert to ActionResult
let model = terminal_model.lock();
match transfer_result {
TransferControlResult::ControlHandedBack
| TransferControlResult::BlockFinished => {
match model.block_list().block_with_id(&block_id) {
Some(block) => {
if block.finished() {
ActionResult::CommandFinished {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
start_ts: block.start_ts().cloned(),
completed_ts: block.completed_ts().cloned(),
} }
} else { #[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
let grid_contents = if model.is_alt_screen_active() { {
formatted_terminal_contents_for_input( let _ = (conversation_id, block_id, ctx);
model.alt_screen().grid_handler(), WriteToLongRunningShellCommandResult::Error(ShellCommandError::NonInteractiveOnly)
None,
CURSOR_MARKER,
)
} else {
formatted_terminal_contents_for_input(
block.output_grid().grid_handler(),
Some(1000),
CURSOR_MARKER,
)
};
ActionResult::LongRunningCommandSnapshot {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_preempted: false,
wake_reason: "control_handed_back",
}
}
}
None => ActionResult::BlockNotFound,
}
}
TransferControlResult::Cancelled => ActionResult::Cancelled,
}
}
};
ActionExecution::new_async(transfer_future, move |result, ctx| {
// Clean up.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, ctx| {
log_shell_wait_result(
conversation_id,
&diagnostic_action_id,
&result,
ctx,
);
me.prune_closed_senders(&block_selector);
me.control_handback_sender = None;
});
}
action_result_for_transfer_shell_command_control_to_user(result)
})
}
_ => ActionExecution::InvalidAction,
} }
} }
@@ -629,140 +503,6 @@ impl ShellCommandExecutor {
} }
} }
/// Produces a future which resolves when the action is complete and
/// we have a result to send to the agent.
fn action_result_future(
&mut self,
block_selector: BlockSelector,
delay: Option<ShellCommandDelay>,
) -> impl Spawnable<Output = ActionResult> {
// Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders
.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
// the timeout and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders
.entry(block_selector.clone())
.or_default()
.push(force_refresh_tx);
// Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone();
#[derive(Debug, Clone, Copy)]
enum WakeReason {
BlockFinished,
Timeout,
/// The pending poll was explicitly refreshed before its agent-set timer elapsed.
/// Treated as a preemption so the provider does not interpret the early snapshot as
/// a completion.
ForceRefresh,
}
async move {
// If we support long-running commands, set up a timeout after which we'll
// treat the command as long-running and give the agent a snapshot of the
// current state. Otherwise, we'll wait indefinitely for the command to
// finish executing.
let mut timeout = match delay {
Some(ShellCommandDelay::Duration(duration)) => {
// Enforce a maximum allowed delay that the agent may request, never waiting longer than MAX_AGENT_DELAY_DURATION.
// If the requested duration exceeds this cap, we'll still behave as if the agent may expect a running command,
// so there's no need to signal preemption (the agent already anticipates an incomplete command state).
Timer::after(duration.min(Self::MAX_AGENT_DELAY_DURATION))
}
Some(ShellCommandDelay::OnCompletion) => {
Timer::after(Self::MAX_AGENT_DELAY_DURATION)
}
None => Timer::after(Self::MAX_WAIT_DURATION),
}
.fuse();
pin!(block_metadata_received_rx);
pin!(force_refresh_rx);
let wake_reason = select! {
val = block_metadata_received_rx => match val {
Ok(_) => WakeReason::BlockFinished,
Err(_) => return ActionResult::Cancelled,
},
val = force_refresh_rx => match val {
// An explicit refresh was requested; fall through to the snapshot code path.
// Treat it as a preemption because it arrived before the agent's timer.
Ok(_) => WakeReason::ForceRefresh,
// Sender was dropped (e.g. because the executor is being torn down).
Err(_) => return ActionResult::Cancelled,
},
_ = timeout => WakeReason::Timeout,
};
// Mark the snapshot as preempted if woken early, allowing the server to distinguish
// true completion from a forced client poll (`ForceRefresh`) or a timeout during `on_completion`.
let is_preempted = matches!(wake_reason, WakeReason::ForceRefresh)
|| matches!(
(&wake_reason, &delay),
(WakeReason::Timeout, Some(ShellCommandDelay::OnCompletion))
);
// At this point, we've either received block metadata or we've timed out.
// Check the current state of the block and produce a result accordingly.
let model = terminal_model.lock();
let result = match block_selector.get_block(&model) {
Some(block) => {
if block.finished() {
ActionResult::CommandFinished {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
start_ts: block.start_ts().cloned(),
completed_ts: block.completed_ts().cloned(),
}
} else {
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(
model.alt_screen().grid_handler(),
None,
CURSOR_MARKER,
)
} else {
formatted_terminal_contents_for_input(
block.output_grid().grid_handler(),
// TODO(vorporeal): This is probably too large.
Some(1000),
CURSOR_MARKER,
)
};
ActionResult::LongRunningCommandSnapshot {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: selected_block_owns_alt_screen,
is_preempted,
wake_reason: match wake_reason {
WakeReason::BlockFinished => "block_finished",
WakeReason::Timeout => "timeout",
WakeReason::ForceRefresh => "force_refresh",
},
}
}
}
None => ActionResult::BlockNotFound,
};
result
}
}
pub(super) fn cancel_execution( pub(super) fn cancel_execution(
&mut self, &mut self,
id: &AIAgentActionId, id: &AIAgentActionId,
@@ -933,6 +673,15 @@ fn terminal_busy_execution_error(command: &str, running_command: &str) -> AIAgen
}) })
} }
fn background_launch_error(command: &str, message: String) -> AnyActionExecution {
AnyActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::ExecutionError {
command: command.to_owned(),
message,
},
))
}
fn selected_block_owns_alt_screen( fn selected_block_owns_alt_screen(
is_alt_screen_active: bool, is_alt_screen_active: bool,
active_block_id: &BlockId, active_block_id: &BlockId,
@@ -24,10 +24,8 @@ impl UseComputerExecutor {
return false; return false;
}; };
// We unconditionally return true here because this action is only executed by // The shared action boundary applies the active permission mode to every action.
// the computer use subagent, which cannot begin without the user approving it via // Once that check passes, the executor has no additional approval requirement.
// a `RequestComputerUse` action, and the approval extends to all computer use
// actions within that computer use subagent.
true true
} }
@@ -46,8 +44,14 @@ impl UseComputerExecutor {
// Gate per-window targeting behind the client feature flag. When off, the actor forces the // Gate per-window targeting behind the client feature flag. When off, the actor forces the
// legacy full-screen path so results are identical to the pre-existing implementation. // legacy full-screen path so results are identical to the pre-existing implementation.
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled(); let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
// Actor initialization reads the macOS keyboard layout through Carbon APIs that require
// the main thread. Construct it here, just like RequestComputerUseExecutor, before
// moving the Send actor into the background action future.
#[cfg(target_os = "macos")]
let mut actor = computer_use::create_actor();
ActionExecution::new_async( ActionExecution::new_async(
async move { async move {
#[cfg(not(target_os = "macos"))]
let mut actor = computer_use::create_actor(); let mut actor = computer_use::create_actor();
match actor match actor
.perform_actions( .perform_actions(
@@ -507,12 +507,12 @@ impl BlocklistAIActionModel {
.as_ref(ctx) .as_ref(ctx)
.shell_command_executor() .shell_command_executor()
.as_ref(ctx) .as_ref(ctx)
.can_continue_authorized_command(conversation_id, block_id); .can_continue_authorized_command(conversation_id, block_id, ctx);
if owns_command { if owns_command {
return Some(true); return Some(true);
} }
self.reject_action_for_mode(action, conversation_id, self.reject_action_for_mode(action, conversation_id,
"PTY input is only authorized for this conversation's active, approved long-running command while the agent retains control. This input was not sent.", ctx); "Only this conversation's owned background job can be cancelled. No process input was sent.", ctx);
return None; return None;
} }
if mode == PermissionMode::ReadOnly && !is_read_only_inspection(&action.action) { if mode == PermissionMode::ReadOnly && !is_read_only_inspection(&action.action) {
@@ -692,7 +692,18 @@ impl BlocklistAIActionModel {
request.max_output_tokens = Some(u64::from(REVIEW_MAX_OUTPUT_TOKENS)); request.max_output_tokens = Some(u64::from(REVIEW_MAX_OUTPUT_TOKENS));
let started = Instant::now(); let started = Instant::now();
ctx.spawn( ctx.spawn(
assess(provider_config, request, cancel, control), assess(
provider_config,
request,
cancel,
control,
#[cfg(not(target_family = "wasm"))]
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action.id.to_string(),
"request_id": token.to_string(),
}),
),
move |me, result, ctx| me.finish_action_review(key, token, started, result, ctx), move |me, result, ctx| me.finish_action_review(key, token, started, result, ctx),
); );
None None
@@ -1010,18 +1021,41 @@ async fn assess(
request: TurnRequest, request: TurnRequest,
sender: TurnCommandSender, sender: TurnCommandSender,
control: TurnControl, control: TurnControl,
#[cfg(not(target_family = "wasm"))] activity_identity: serde_json::Value,
) -> anyhow::Result<Assessment> { ) -> anyhow::Result<Assessment> {
anyhow::ensure!( anyhow::ensure!(
serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES, serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES,
"action is too large for a reliable bounded assessment" "action is too large for a reliable bounded assessment"
); );
#[cfg(not(target_family = "wasm"))]
let started = Instant::now();
#[cfg(not(target_family = "wasm"))]
let activity_model_id = request.model.as_str().to_string();
#[cfg(not(target_family = "wasm"))]
let activity_context = || {
let mut context = activity_identity.clone();
context["model_id"] = serde_json::json!(activity_model_id);
context["elapsed_ms"] = serde_json::json!(started.elapsed().as_millis());
context
};
let review = async { let review = async {
let model_id = request.model.as_str().to_string(); let model_id = request.model.as_str().to_string();
let runtime = provider_runtime_for_request(provider_config, &request).await?; let runtime = provider_runtime_for_request(provider_config, &request).await?;
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"risk_review_request",
"Permission assessment request",
activity_context(),
&request,
);
}
let mut stream = runtime.start_turn(request, control).await?; let mut stream = runtime.start_turn(request, control).await?;
let mut text = String::new(); let mut text = String::new();
let mut reasoning_bytes = 0usize; let mut reasoning_bytes = 0usize;
let mut output_tokens = None; let mut output_tokens = None;
#[cfg(not(target_family = "wasm"))]
let mut response_usage = None;
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
match event? { match event? {
AgentEvent::TextDelta { text: delta } => { AgentEvent::TextDelta { text: delta } => {
@@ -1031,12 +1065,25 @@ async fn assess(
); );
text.push_str(&delta); text.push_str(&delta);
} }
AgentEvent::TurnStopped {
reason: StopReason::Completed,
} => return Assessment::parse(&text).map_err(|error| anyhow::anyhow!(
"{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()
)),
AgentEvent::TurnStopped { reason } => { AgentEvent::TurnStopped { reason } => {
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"risk_review_response",
"Permission assessment response",
activity_context(),
&serde_json::json!({
"text": text,
"usage": response_usage,
"stop_reason": reason,
}),
);
}
if reason == StopReason::Completed {
return Assessment::parse(&text).map_err(|error| anyhow::anyhow!(
"{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()
));
}
anyhow::bail!("permission reviewer stopped with {reason:?} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()) anyhow::bail!("permission reviewer stopped with {reason:?} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len())
} }
AgentEvent::Tool { .. } => { AgentEvent::Tool { .. } => {
@@ -1050,6 +1097,10 @@ async fn assess(
} }
AgentEvent::UsageUpdated { usage } => { AgentEvent::UsageUpdated { usage } => {
output_tokens = Some(usage.output_tokens); output_tokens = Some(usage.output_tokens);
#[cfg(not(target_family = "wasm"))]
{
response_usage = Some(usage);
}
} }
AgentEvent::TurnStarted { .. } AgentEvent::TurnStarted { .. }
| AgentEvent::KeepAlive | AgentEvent::KeepAlive
@@ -1066,6 +1117,18 @@ async fn assess(
let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT)); let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT));
futures::pin_mut!(review, timeout); futures::pin_mut!(review, timeout);
let result = futures::select_biased! { result = review => result, _ = timeout => Err(anyhow::anyhow!("assessment timed out")) }; let result = futures::select_biased! { result = review => result, _ = timeout => Err(anyhow::anyhow!("assessment timed out")) };
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
if let Err(error) = &result {
let mut context = activity_context();
context["error"] = serde_json::json!(crate::ai::remote_logging::sanitize_error(error));
crate::ai::activity::record(
"risk_review_error",
"Permission assessment failed",
context,
);
}
}
let _ = sender.try_send(TurnCommand::Cancel); let _ = sender.try_send(TurnCommand::Cancel);
result result
} }
@@ -40,7 +40,10 @@ fn accepts_a_single_fenced_decision_but_not_empty_output() {
let response = format!("{prefix}\n{{\"risk\":1,\"reason\":\"Scoped local edit\"}}\n```"); let response = format!("{prefix}\n{{\"risk\":1,\"reason\":\"Scoped local edit\"}}\n```");
assert_eq!(Assessment::parse(&response).unwrap().risk, 1); assert_eq!(Assessment::parse(&response).unwrap().risk, 1);
} }
assert!(Assessment::parse(" \n").unwrap_err().to_string().contains("no decision text")); assert!(Assessment::parse(" \n")
.unwrap_err()
.to_string()
.contains("no decision text"));
} }
fn with_pending_review( fn with_pending_review(
@@ -406,34 +409,61 @@ fn request_history_does_not_drop_intermediate_revocations() {
fn allow_all_skips_model_review_for_unrestricted_actions() { fn allow_all_skips_model_review_for_unrestricted_actions() {
with_pending_review(|model, conversation, action, ctx| { with_pending_review(|model, conversation, action, ctx| {
set_fixture_mode(model, PermissionMode::AllowAll, ctx); set_fixture_mode(model, PermissionMode::AllowAll, ctx);
assert_eq!(model.review_pending_action(&action, conversation, false, ctx), Some(true)); assert_eq!(
model.review_pending_action(&action, conversation, false, ctx),
Some(true)
);
assert!(model.action_reviews.is_empty()); assert!(model.action_reviews.is_empty());
}); });
} }
#[test] #[test]
fn explicit_command_approval_applies_in_every_mode_and_denylist_wins() { fn explicit_command_approval_applies_in_every_mode_and_denylist_wins() {
for mode in [PermissionMode::ReadOnly, PermissionMode::AskFirst, PermissionMode::GalaxyDecides, PermissionMode::AllowAll] { for mode in [
PermissionMode::ReadOnly,
PermissionMode::AskFirst,
PermissionMode::GalaxyDecides,
PermissionMode::AllowAll,
] {
with_pending_review(move |model, conversation, mut action, ctx| { with_pending_review(move |model, conversation, mut action, ctx| {
set_fixture_mode(model, mode, ctx); set_fixture_mode(model, mode, ctx);
action.action = AIAgentActionType::RequestCommandOutput { action.action = AIAgentActionType::RequestCommandOutput {
command: "cargo test".to_string(), is_read_only: None, is_risky: None, command: "cargo test".to_string(),
rationale: None, uses_pager: None, wait_until_completion: true, citations: vec![], is_read_only: None,
is_risky: None,
rationale: None,
uses_pager: None,
wait_until_completion: true,
citations: vec![],
}; };
model.pending_actions.insert(conversation, VecDeque::from([action.clone()])); model
.pending_actions
.insert(conversation, VecDeque::from([action.clone()]));
model.action_reviews.clear(); model.action_reviews.clear();
let predicate = crate::settings::AgentModeCommandExecutionPredicate::new_regex("^cargo test$").unwrap(); let predicate =
crate::settings::AgentModeCommandExecutionPredicate::new_regex("^cargo test$")
.unwrap();
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
let id = *profiles.active_profile(Some(model.terminal_view_id), ctx).id(); let id = *profiles
.active_profile(Some(model.terminal_view_id), ctx)
.id();
profiles.add_to_command_allowlist(id, &predicate, ctx); profiles.add_to_command_allowlist(id, &predicate, ctx);
}); });
assert_eq!(model.review_pending_action(&action, conversation, false, ctx), Some(true)); assert_eq!(
model.review_pending_action(&action, conversation, false, ctx),
Some(true)
);
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
let id = *profiles.active_profile(Some(model.terminal_view_id), ctx).id(); let id = *profiles
.active_profile(Some(model.terminal_view_id), ctx)
.id();
profiles.add_to_command_denylist(id, &predicate, ctx); profiles.add_to_command_denylist(id, &predicate, ctx);
}); });
assert_eq!(model.is_explicitly_allowlisted_command(&action, ctx), false); assert!(!model.is_explicitly_allowlisted_command(&action, ctx));
assert_ne!(model.review_pending_action(&action, conversation, false, ctx), Some(true)); assert_ne!(
model.review_pending_action(&action, conversation, false, ctx),
Some(true)
);
}); });
} }
} }
@@ -366,6 +366,26 @@ fn domain_read_result_contains_the_file_contents_for_the_next_model_turn() {
assert!(result.content.contains("pub fn answer() -> u8 { 42 }")); assert!(result.content.contains("pub fn answer() -> u8 { 42 }"));
} }
#[test]
fn computer_results_keep_real_image_bytes_and_coordinate_metadata() {
let screenshot = computer_use::Screenshot {
width: 500, height: 300, original_width: 1000, original_height: 600,
data: vec![1, 2, 3, 4], mime_type: "image/png".into(),
};
let action = action_result("desktop", AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Approved {
screenshot, platform: computer_use::Platform::Mac, windows: Vec::new(),
}));
let result = domain_tool_result(&action, false);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].data, vec![1, 2, 3, 4]);
assert_eq!(result.images[0].mime_type, "image/png");
let metadata: serde_json::Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(metadata["platform"], "Mac");
assert_eq!(metadata["screenshot"]["width"], 500);
assert_eq!(metadata["screenshot"]["original_width"], 1000);
assert!(domain_tool_result(&action, true).images.is_empty());
}
#[test] #[test]
fn action_permission_kinds_match_the_safety_boundary() { fn action_permission_kinds_match_the_safety_boundary() {
assert_eq!( assert_eq!(
@@ -52,6 +52,7 @@ pub(crate) use self::environment_selector::{
use crate::ai::blocklist::agent_view::is_in_cloud_context; use crate::ai::blocklist::agent_view::is_in_cloud_context;
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::blocklist::prompt::prompt_alert::{PromptAlertEvent, PromptAlertView}; use crate::ai::blocklist::prompt::prompt_alert::{PromptAlertEvent, PromptAlertView};
use crate::ai::blocklist::usage::conversation_usage_view::provider_context_usage_tooltip;
use crate::ai::blocklist::usage::icon_for_context_window_usage; use crate::ai::blocklist::usage::icon_for_context_window_usage;
use crate::ai::blocklist::BlocklistAIInputModel; use crate::ai::blocklist::BlocklistAIInputModel;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
@@ -443,7 +444,7 @@ impl AgentInputFooter {
ActionButton::new("Enable notifications", InstallPluginButtonTheme) ActionButton::new("Enable notifications", InstallPluginButtonTheme)
.with_icon(Icon::Download) .with_icon(Icon::Download)
.with_tooltip( .with_tooltip(
"Install the Warp plugin to enable rich agent notifications within Warp", "Install the Galaxy plugin to enable rich agent notifications within Galaxy",
) )
.with_size(cli_button_size) .with_size(cli_button_size)
.with_tooltip_alignment(TooltipAlignment::Left) .with_tooltip_alignment(TooltipAlignment::Left)
@@ -456,7 +457,7 @@ impl AgentInputFooter {
let plugin_instructions_button = ctx.add_typed_action_view(|_ctx| { let plugin_instructions_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Notifications setup instructions", InstallPluginButtonTheme) ActionButton::new("Notifications setup instructions", InstallPluginButtonTheme)
.with_icon(Icon::Info) .with_icon(Icon::Info)
.with_tooltip("View instructions to install the Warp plugin") .with_tooltip("View instructions to install the Galaxy plugin")
.with_size(cli_button_size) .with_size(cli_button_size)
.with_tooltip_alignment(TooltipAlignment::Left) .with_tooltip_alignment(TooltipAlignment::Left)
.with_adjoined_side(AdjoinedSide::Right) .with_adjoined_side(AdjoinedSide::Right)
@@ -468,9 +469,9 @@ impl AgentInputFooter {
}); });
let update_plugin_button = ctx.add_typed_action_view(|_ctx| { let update_plugin_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Update Warp plugin", InstallPluginButtonTheme) ActionButton::new("Update Galaxy plugin", InstallPluginButtonTheme)
.with_icon(Icon::Download) .with_icon(Icon::Download)
.with_tooltip("A new version of the Warp plugin is available") .with_tooltip("A new version of the Galaxy plugin is available")
.with_size(cli_button_size) .with_size(cli_button_size)
.with_tooltip_alignment(TooltipAlignment::Left) .with_tooltip_alignment(TooltipAlignment::Left)
.with_adjoined_side(AdjoinedSide::Right) .with_adjoined_side(AdjoinedSide::Right)
@@ -482,7 +483,7 @@ impl AgentInputFooter {
let update_instructions_button = ctx.add_typed_action_view(|_ctx| { let update_instructions_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Plugin update instructions", InstallPluginButtonTheme) ActionButton::new("Plugin update instructions", InstallPluginButtonTheme)
.with_icon(Icon::Info) .with_icon(Icon::Info)
.with_tooltip("View instructions to update the Warp plugin") .with_tooltip("View instructions to update the Galaxy plugin")
.with_size(cli_button_size) .with_size(cli_button_size)
.with_tooltip_alignment(TooltipAlignment::Left) .with_tooltip_alignment(TooltipAlignment::Left)
.with_adjoined_side(AdjoinedSide::Right) .with_adjoined_side(AdjoinedSide::Right)
@@ -802,6 +803,18 @@ impl AgentInputFooter {
} }
match event { match event {
BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated {
conversation_id,
} => {
if BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(me.terminal_view_id)
.is_some_and(|conversation| conversation.id() == *conversation_id)
{
me.update_context_window_button(ctx);
me.update_llm_usage_buttons(ctx);
ctx.notify();
}
}
BlocklistAIHistoryEvent::StartedNewConversation { .. } BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. } | BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. } | BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
@@ -1396,10 +1409,10 @@ impl AgentInputFooter {
.cli_agent(ctx) .cli_agent(ctx)
.and_then(plugin_manager_for) .and_then(plugin_manager_for)
.map(|m| m.install_success_message()) .map(|m| m.install_success_message())
.unwrap_or("Warp plugin installed. Please restart the session to activate."); .unwrap_or("Galaxy plugin installed. Please restart the session to activate.");
self.handle_plugin_operation( self.handle_plugin_operation(
"Installing Warp plugin...", "Installing Galaxy plugin...",
"Failed to install Warp plugin", "Failed to install Galaxy plugin",
success_msg, success_msg,
PluginChipTelemetryKind::Install, PluginChipTelemetryKind::Install,
|manager| async move { manager.install().await }, |manager| async move { manager.install().await },
@@ -1413,10 +1426,10 @@ impl AgentInputFooter {
.cli_agent(ctx) .cli_agent(ctx)
.and_then(plugin_manager_for) .and_then(plugin_manager_for)
.map(|m| m.update_success_message()) .map(|m| m.update_success_message())
.unwrap_or("Warp plugin updated. Please restart the session to activate."); .unwrap_or("Galaxy plugin updated. Please restart the session to activate.");
self.handle_plugin_operation( self.handle_plugin_operation(
"Updating Warp plugin...", "Updating Galaxy plugin...",
"Failed to update Warp plugin", "Failed to update Galaxy plugin",
success_msg, success_msg,
PluginChipTelemetryKind::Update, PluginChipTelemetryKind::Update,
|manager| async move { manager.update().await }, |manager| async move { manager.update().await },
@@ -1985,7 +1998,11 @@ impl AgentInputFooter {
if let Some(conversation) = if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).active_conversation(self.terminal_view_id) BlocklistAIHistoryModel::as_ref(ctx).active_conversation(self.terminal_view_id)
{ {
let usage = conversation.context_window_usage().clamp(0.0, 1.0); let provider_context = conversation.provider_context_usage();
let usage = provider_context
.map(|usage| usage.compaction_usage())
.unwrap_or_else(|| conversation.context_window_usage())
.clamp(0.0, 1.0);
let icon = icon_for_context_window_usage(usage); let icon = icon_for_context_window_usage(usage);
let remaining_pct = ((1.0 - usage) * 100.0).round() as i32; let remaining_pct = ((1.0 - usage) * 100.0).round() as i32;
@@ -1995,7 +2012,11 @@ impl AgentInputFooter {
}); });
let is_cache_expired = FeatureFlag::PromptCacheExpiryWarning.is_enabled() let is_cache_expired = FeatureFlag::PromptCacheExpiryWarning.is_enabled()
&& expiry.is_some_and(|expiry| expiry <= Local::now()); && expiry.is_some_and(|expiry| expiry <= Local::now());
let context_remaining_tooltip = format!("{remaining_pct}% context remaining"); let context_remaining_tooltip = if provider_context.is_some() {
format!("~{remaining_pct}% compaction history budget remaining")
} else {
format!("{remaining_pct}% context remaining")
};
let tooltip = if is_cache_expired { let tooltip = if is_cache_expired {
format!("{context_remaining_tooltip} · prompt cache expired") format!("{context_remaining_tooltip} · prompt cache expired")
} else { } else {
@@ -2022,10 +2043,20 @@ impl AgentInputFooter {
conversation.latest_model_call_cache_read_tokens(), conversation.latest_model_call_cache_read_tokens(),
conversation.latest_model_call_cache_write_tokens(), conversation.latest_model_call_cache_write_tokens(),
conversation.latest_model_call_cache_miss_tokens(), conversation.latest_model_call_cache_miss_tokens(),
conversation.provider_context_usage().cloned(),
conversation.latest_model_call_reported_context_tokens(),
) )
}); });
let Some((current_context, reported_usage, cache_read, cache_write, cache_miss)) = metrics let Some((
current_context,
reported_usage,
cache_read,
cache_write,
cache_miss,
provider_context,
last_reported_input,
)) = metrics
else { else {
self.llm_context_usage_button.update(ctx, |button, ctx| { self.llm_context_usage_button.update(ctx, |button, ctx| {
button.set_label("Ctx —", ctx); button.set_label("Ctx —", ctx);
@@ -2051,29 +2082,48 @@ impl AgentInputFooter {
.active_profile(Some(self.terminal_view_id), ctx) .active_profile(Some(self.terminal_view_id), ctx)
.data() .data()
.context_window_display_value_for_model(active_model, ctx); .context_window_display_value_for_model(active_model, ctx);
let model_max_context = active_model let model_context = [
.context_window active_model.context_window.default_max,
.default_max active_model.context_window.max,
.max(active_model.context_window.max); ]
let max_context = profile_context .into_iter()
.or((model_max_context > 0).then_some(model_max_context)) .find(|limit| *limit > 0);
.unwrap_or(200_000); let max_context = profile_context.or(model_context).unwrap_or(200_000);
let context_usage = if max_context > 0 && current_context > 0 { let context_usage = if let Some(usage) = &provider_context {
usage.compaction_usage().clamp(0.0, 1.0)
} else if max_context > 0 && current_context > 0 {
(current_context as f32 / max_context as f32).clamp(0.0, 1.0) (current_context as f32 / max_context as f32).clamp(0.0, 1.0)
} else { } else {
reported_usage.clamp(0.0, 1.0) reported_usage.clamp(0.0, 1.0)
}; };
let context_pct = context_usage * 100.0; let context_pct = provider_context
let context_label = format!( .as_ref()
.map(|usage| usage.compaction_usage())
.unwrap_or(context_usage)
* 100.0;
let (context_label, context_tooltip) = if let Some(usage) = &provider_context {
(
format!(
"Ctx budget ~{context_pct:.1}% · ~{}/{}",
format_llm_token_count(usage.compaction.history_tokens),
format_llm_token_count(usage.compaction.history_capacity_tokens),
),
provider_context_usage_tooltip(usage, last_reported_input),
)
} else {
(
format!(
"Ctx {context_pct:.1}% · {}/{}", "Ctx {context_pct:.1}% · {}/{}",
format_llm_token_count(current_context), format_llm_token_count(current_context),
format_llm_token_count(max_context), format_llm_token_count(max_context),
); ),
let context_tooltip = format!( format!(
"Current LLM context: {} of {} tokens ({context_pct:.1}%). This is not cumulative.", "Current LLM context: {} of {} tokens ({context_pct:.1}%). This is not cumulative.",
format_llm_token_count(current_context), format_llm_token_count(current_context),
format_llm_token_count(max_context), format_llm_token_count(max_context),
); ),
)
};
let context_icon = icon_for_context_window_usage(context_usage); let context_icon = icon_for_context_window_usage(context_usage);
self.llm_context_usage_button.update(ctx, |button, ctx| { self.llm_context_usage_button.update(ctx, |button, ctx| {
button.set_label(context_label, ctx); button.set_label(context_label, ctx);
@@ -2823,7 +2873,7 @@ impl ActionButtonTheme for ActiveMicButtonTheme {
} }
} }
/// Green-accented theme for the "Install Warp plugin" chip. /// Green-accented theme for the "Install Galaxy plugin" chip.
struct InstallPluginButtonTheme; struct InstallPluginButtonTheme;
impl ActionButtonTheme for InstallPluginButtonTheme { impl ActionButtonTheme for InstallPluginButtonTheme {
@@ -2863,7 +2913,7 @@ async fn write_install_log(agent: CLIAgent, err: &PluginInstallError) -> Option<
let log_path = env::temp_dir().join("warp-plugin-install.log"); let log_path = env::temp_dir().join("warp-plugin-install.log");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
let contents = format!( let contents = format!(
"Warp plugin installation — {agent:?}\n\ "Galaxy plugin installation — {agent:?}\n\
{now}\n\ {now}\n\
\n\ \n\
{log}", {log}",
@@ -129,7 +129,7 @@ impl AgentToolbarItemKind {
Self::NLDToggle => "Autodetection", Self::NLDToggle => "Autodetection",
Self::VoiceInput => "Voice Input", Self::VoiceInput => "Voice Input",
Self::FileAttach => "Attach File", Self::FileAttach => "Attach File",
Self::ContextWindowUsage => "Warp Context Indicator", Self::ContextWindowUsage => "Galaxy Context Indicator",
Self::LLMContextUsage => "LLM Context Usage", Self::LLMContextUsage => "LLM Context Usage",
Self::LLMCacheDetails => "LLM Cache Details", Self::LLMCacheDetails => "LLM Cache Details",
Self::FileExplorer => "File Explorer", Self::FileExplorer => "File Explorer",
+10 -14
View File
@@ -3998,12 +3998,7 @@ impl AIBlock {
}); });
self.ask_user_question_view = Some(view.clone()); self.ask_user_question_view = Some(view.clone());
if self if view.as_ref(ctx).is_waiting_on_user_answers(ctx) {
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_blocked())
{
ctx.focus(&view); ctx.focus(&view);
} }
if self.sync_ask_user_question_speedbump_footer(ctx) if self.sync_ask_user_question_speedbump_footer(ctx)
@@ -4439,7 +4434,7 @@ impl AIBlock {
} }
let ai_settings = AISettings::as_ref(ctx); let ai_settings = AISettings::as_ref(ctx);
let login_command = ai_settings.bedrock_auth_refresh_command.value().clone(); let login_command = crate::ai::provider::authentication::BedrockAuthConnection::current(ctx).command;
// Recreating a historical error view must not restart authentication. // Recreating a historical error view must not restart authentication.
let auto_login_enabled = attempt_auto_login && *ai_settings.bedrock_auto_login.value(); let auto_login_enabled = attempt_auto_login && *ai_settings.bedrock_auto_login.value();
@@ -4452,7 +4447,7 @@ impl AIBlock {
let model_name = model_name.clone(); let model_name = model_name.clone();
let view = ctx.add_typed_action_view(|ctx| { let view = ctx.add_typed_action_view(|ctx| {
AwsBedrockCredentialsErrorView::new(model_name, login_command, auto_login_enabled, ctx) AwsBedrockCredentialsErrorView::new(model_name, login_command, ctx)
}); });
// Subscribe to events from the view and emit AIBlockEvents directly // Subscribe to events from the view and emit AIBlockEvents directly
@@ -4826,9 +4821,13 @@ impl AIBlock {
self.model.status(app).is_complete() self.model.status(app).is_complete()
} }
/// Returns `true` if the block contains any actions that are blocked on user confirmation. /// Returns `true` when actions need confirmation or a question is waiting for an answer.
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.ask_user_question_view
.as_ref()
.is_some_and(|view| view.as_ref(app).is_waiting_on_user_answers(app))
|| self
.requested_action_ids
.iter() .iter()
.filter_map(|id| { .filter_map(|id| {
self.action_model self.action_model
@@ -5369,10 +5368,7 @@ impl AIBlock {
did_focus_subview = true; did_focus_subview = true;
} else if let Some(ask_user_question_view) = } else if let Some(ask_user_question_view) =
self.ask_user_question_view.as_ref().filter(|view| { self.ask_user_question_view.as_ref().filter(|view| {
pending_action_id.is_some_and(|id| { view.as_ref(ctx).is_waiting_on_user_answers(ctx)
let view = view.as_ref(ctx);
view.action_id() == id && view.is_editing()
})
}) })
{ {
ctx.focus(ask_user_question_view); ctx.focus(ask_user_question_view);
+4 -2
View File
@@ -1102,13 +1102,15 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
TransferShellCommandControlToUserResult::CommandFinished { block_id, .. }, TransferShellCommandControlToUserResult::CommandFinished { block_id, .. },
) => Some(block_id), ) => Some(block_id),
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. } RequestCommandOutputResult::BackgroundJobStarted { .. }
| RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution | RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. } | RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. }, | RequestCommandOutputResult::Denylisted { .. },
) )
| AIAgentActionResultType::WriteToLongRunningShellCommand( | AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Snapshot { .. } WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested { .. }
| WriteToLongRunningShellCommandResult::Snapshot { .. }
| WriteToLongRunningShellCommandResult::Cancelled | WriteToLongRunningShellCommandResult::Cancelled
| WriteToLongRunningShellCommandResult::Error(_), | WriteToLongRunningShellCommandResult::Error(_),
) )
@@ -1,5 +1,26 @@
use super::*; use super::*;
#[test]
fn background_job_acknowledgements_never_attach_a_cli_monitor() {
for result in [
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::BackgroundJobStarted {
job_id: "independent-job".to_owned(),
command: "cargo build".to_owned(),
},
),
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
job_id: "independent-job".to_owned(),
},
),
] {
assert_eq!(snapshot_block_id_for_action_result(&result), None);
assert_eq!(command_finished_block_id(&result), None);
assert!(!result.triggers_server_subagent());
}
}
#[test] #[test]
fn stop_takeover_does_not_request_a_completion_assessment() { fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User { let state = LongRunningCommandControlState::User {
@@ -107,7 +107,7 @@ pub const WAITING_FOR_USER_INPUT_MESSAGE: &str = "Agent waiting for instructions
const IMAGE_SOURCE_LINK_LINE_INDEX: usize = 1; const IMAGE_SOURCE_LINK_LINE_INDEX: usize = 1;
const ERROR_APOLOGY_TEXT: &str = "I'm sorry, I couldn't complete that request."; const ERROR_APOLOGY_TEXT: &str = "I'm sorry, I couldn't complete that request.";
const INTERNAL_WARP_ERROR: &str = "Internal Warp error."; const INTERNAL_WARP_ERROR: &str = "Internal Galaxy error.";
const GALAXY_STATUS_MESSAGES: &[&str] = &[ const GALAXY_STATUS_MESSAGES: &[&str] = &[
"Warping through the Galaxy...", "Warping through the Galaxy...",
@@ -3133,7 +3133,7 @@ pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<d
// While an automatic retry/resume is still in flight, don't surface the underlying // While an automatic retry/resume is still in flight, don't surface the underlying
// transport failure at all. These are typically transient and recover on their own, // transport failure at all. These are typically transient and recover on their own,
// so showing the alarming "Warp lost connection" banner (plus debug info) for every // so showing the alarming "Galaxy lost connection" banner (plus debug info) for every
// blip is noisy and misleading. Render nothing during in-flight recovery; the full // blip is noisy and misleading. Render nothing during in-flight recovery; the full
// error banner is only shown once recovery has actually failed. Dogfood builds // error banner is only shown once recovery has actually failed. Dogfood builds
// (Local/Dev) opt out so developers still see every transport failure aggressively. // (Local/Dev) opt out so developers still see every transport failure aggressively.
@@ -3160,7 +3160,7 @@ pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<d
} }
} }
RenderableAIError::ServerOverloaded => { RenderableAIError::ServerOverloaded => {
"Warp is currently overloaded. Please try again later.".to_string() "The AI service is currently overloaded. Please try again later.".to_string()
} }
RenderableAIError::InternalWarpError => { RenderableAIError::InternalWarpError => {
format!("{ERROR_APOLOGY_TEXT}\n\n{INTERNAL_WARP_ERROR}") format!("{ERROR_APOLOGY_TEXT}\n\n{INTERNAL_WARP_ERROR}")
File diff suppressed because it is too large Load Diff
@@ -170,6 +170,7 @@ impl ResponseStream {
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) { if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig { return ProviderConfig::OpenAI(OpenAIClientConfig {
kind: client_config.kind, kind: client_config.kind,
connection_identity: client_config.connection_identity.clone(),
base_url: client_config.base_url.clone(), base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(), api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(), project_id: client_config.project_id.clone(),
@@ -187,30 +188,14 @@ impl ResponseStream {
// Fall back to Bedrock // Fall back to Bedrock
if *settings.bedrock_enabled.value() { if *settings.bedrock_enabled.value() {
let auth_method = *settings.bedrock_auth_method.value();
let region = settings.bedrock_region.value().clone();
let cross_region_inference = *settings.bedrock_cross_region_inference.value();
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx); let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
let mut config = BedrockClientConfig { let config = BedrockClientConfig::from_settings(settings)
auth_method, .with_model_route(model_id, settings.bedrock_models.value())
profile: settings.bedrock_profile.value().clone(), .with_managed_credentials(
region, &api_key_manager.aws_credentials_refresh_strategy(),
access_key_id: settings.bedrock_access_key_id.value().clone(), api_key_manager.aws_credentials_state(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(), );
session_token: None, return ProviderConfig::Bedrock(config);
cross_region_inference,
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
api_key_manager.aws_credentials_state()
{
config.auth_method = crate::settings::BedrockAuthMethod::StaticKeys;
config.access_key_id = credentials.access_key().to_string();
config.secret_access_key = credentials.secret_key().to_string();
config.session_token = credentials.session_token().map(|s| s.to_string());
}
return ProviderConfig::Bedrock(config.with_external_fallbacks());
} }
ProviderConfig::None ProviderConfig::None
@@ -445,11 +430,19 @@ impl ResponseStream {
&self, &self,
request_id: Uuid, request_id: Uuid,
payload_kind: &str, payload_kind: &str,
raw_payload: impl AsRef<str>, raw_payload: &impl std::fmt::Debug,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| { let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| {
remote_logging::tail_limited_payload_context(raw_payload.as_ref(), max_chars) struct DebugPayload<'a, T>(&'a T);
impl<T: std::fmt::Debug> serde::Serialize for DebugPayload<'_, T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&format_args!("{:#?}", self.0))
}
}
remote_logging::prefix_bounded_payload_context(max_chars, |writer| {
serde_json::to_writer_pretty(writer, &DebugPayload(raw_payload))
})
}) else { }) else {
return; return;
}; };
@@ -971,7 +964,7 @@ impl ResponseStream {
self.log_raw_model_response( self.log_raw_model_response(
request_id, request_id,
event_type_name, event_type_name,
format!("{response_event:#?}"), response_event,
ctx, ctx,
); );
if let Some(event_type) = &response_event.r#type { if let Some(event_type) = &response_event.r#type {
@@ -1074,7 +1067,7 @@ impl ResponseStream {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> serde_json::Value { fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> serde_json::Value {
use serde::ser::{SerializeMap, Serializer}; use serde::ser::{SerializeMap, SerializeSeq, Serializer};
// collect_str writes debug output directly through serde_json's escaping // collect_str writes debug output directly through serde_json's escaping
// writer, without format! allocating another copy of potentially large input. // writer, without format! allocating another copy of potentially large input.
@@ -1085,7 +1078,18 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
} }
} }
remote_logging::bounded_payload_context(max_chars, |writer| { struct NewestFirst<'a, T>(&'a [T]);
impl<T: serde::Serialize> serde::Serialize for NewestFirst<'_, T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
for message in self.0.iter().rev() {
sequence.serialize_element(message)?;
}
sequence.end()
}
}
remote_logging::prefix_bounded_payload_context(max_chars, |writer| {
let mut serializer = serde_json::Serializer::pretty(writer); let mut serializer = serde_json::Serializer::pretty(writer);
let mut map = serializer.serialize_map(None)?; let mut map = serializer.serialize_map(None)?;
map.serialize_entry("model_id", &params.model.as_str())?; map.serialize_entry("model_id", &params.model.as_str())?;
@@ -1094,10 +1098,14 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
map.serialize_entry("computer_use_model_id", &params.computer_use_model.as_str())?; map.serialize_entry("computer_use_model_id", &params.computer_use_model.as_str())?;
map.serialize_entry("input", &DebugPayload(&params.input))?; map.serialize_entry("input", &DebugPayload(&params.input))?;
map.serialize_entry("tool_results", &DebugPayload(&params.tool_results))?; map.serialize_entry("tool_results", &DebugPayload(&params.tool_results))?;
map.serialize_entry("tasks", &DebugPayload(&params.tasks))?;
map.serialize_entry("message_history", &params.message_history)?;
map.serialize_entry("progressive_summary", &params.progressive_summary)?; map.serialize_entry("progressive_summary", &params.progressive_summary)?;
map.serialize_entry("tool_result_archive", &params.tool_result_archive)?; // Preserve the newest request context before old task/debug archives can
// consume the diagnostic budget. This ordering affects diagnostics only.
map.serialize_entry("message_history_order", "newest_first")?;
map.serialize_entry(
"message_history",
&NewestFirst(params.message_history.as_slice()),
)?;
map.serialize_entry("global_rules", &params.global_rules)?; map.serialize_entry("global_rules", &params.global_rules)?;
map.serialize_entry("mcp_context", &DebugPayload(&params.mcp_context))?; map.serialize_entry("mcp_context", &DebugPayload(&params.mcp_context))?;
map.serialize_entry("autonomy_level", &DebugPayload(&params.autonomy_level))?; map.serialize_entry("autonomy_level", &DebugPayload(&params.autonomy_level))?;
@@ -1130,7 +1138,9 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
"omitted_sensitive_fields", "omitted_sensitive_fields",
&["api_keys", "custom_model_providers", "custom_model_routers"], &["api_keys", "custom_model_providers", "custom_model_routers"],
)?; )?;
map.end() map.serialize_entry("tasks", &DebugPayload(&params.tasks))?;
map.serialize_entry("tool_result_archive", &params.tool_result_archive)?;
SerializeMap::end(map)
}) })
} }
+614 -7
View File
@@ -4,16 +4,16 @@ use std::sync::{Arc, Mutex};
use ai::agent::action::{AskUserQuestionItem, AskUserQuestionType}; use ai::agent::action::{AskUserQuestionItem, AskUserQuestionType};
use chrono::Local; use chrono::Local;
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentError, AgentErrorKind, CompletedModelTurn, ContentPart, ConversationMessage, turn_control, AgentError, AgentErrorKind, CompletedModelTurn, ContentPart, ConversationMessage,
ExternalWorkId, MessageContent, MessageRole, PermissionKind, PermissionRequest, ProviderRun, ExternalWorkId, MessageContent, MessageRole, PermissionKind, PermissionRequest, ProviderRun,
ProviderRunFailure, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunFailure, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits,
ProviderRunOutcome, ProviderRunState, ProviderRunStep, RunEpoch, RuntimeCapabilities, ProviderRunOutcome, ProviderRunState, ProviderRunStep, RunEpoch, RuntimeCapabilities,
StopReason, ToolCall, TurnRequest, Usage, StopReason, ToolCall, TurnCommand, TurnRequest, Usage,
}; };
use galaxy_core::command::ExitCode; use galaxy_core::command::ExitCode;
use uuid::Uuid; use uuid::Uuid;
use warp_multi_agent_api::response_event; use warp_multi_agent_api::response_event;
use warpui::{App, EntityId, SingletonEntity}; use warpui::{App, EntityId, ModelHandle, SingletonEntity};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
@@ -56,6 +56,7 @@ fn provider_lifecycle_logs_expose_llm_completion_and_sanitize_errors() {
retry_error.recoverable = true; retry_error.recoverable = true;
let projections = [ let projections = [
ProviderRunProjection::ModelTurnRequested { ProviderRunProjection::ModelTurnRequested {
context_usage: None,
work_id: work_id.clone(), work_id: work_id.clone(),
profile: "base".into(), profile: "base".into(),
runtime_id: "rig:openai".to_owned(), runtime_id: "rig:openai".to_owned(),
@@ -155,6 +156,7 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
&ProviderRunOutcome::Completed(galaxy_agent_core::ProviderRunCompletion { &ProviderRunOutcome::Completed(galaxy_agent_core::ProviderRunCompletion {
stop_reason: StopReason::Completed, stop_reason: StopReason::Completed,
}), }),
serde_json::json!({"observed_count": 0, "recent": []}),
); );
let failed = super::provider_run_terminal_remote_log_record( let failed = super::provider_run_terminal_remote_log_record(
conversation_id, conversation_id,
@@ -163,8 +165,14 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
&ProviderRunOutcome::Failed(ProviderRunFailure { &ProviderRunOutcome::Failed(ProviderRunFailure {
kind: ProviderRunFailureKind::RetryLimitExceeded, kind: ProviderRunFailureKind::RetryLimitExceeded,
message: "sk-secret timeout".to_owned(), message: "sk-secret timeout".to_owned(),
source: None, source: Some(galaxy_agent_core::AgentError {
kind: galaxy_agent_core::AgentErrorKind::Transport,
message: "connection failed".to_owned(),
user_message: None,
recoverable: true,
}), }),
}),
serde_json::json!({"observed_count": 1, "recent": [{"error": "connection failed"}]}),
); );
assert_eq!(completed.context["llm_finished"], true); assert_eq!(completed.context["llm_finished"], true);
@@ -175,6 +183,9 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
assert_eq!(failed.context["llm_finished"], false); assert_eq!(failed.context["llm_finished"], false);
assert_eq!(failed.context["outcome"], "failed"); assert_eq!(failed.context["outcome"], "failed");
assert_eq!(failed.context["failure_kind"], "RetryLimitExceeded"); assert_eq!(failed.context["failure_kind"], "RetryLimitExceeded");
assert_eq!(failed.context["error_kind"], "Transport");
assert_eq!(failed.context["error_recoverable"], true);
assert_eq!(failed.context["model_failure_diagnostics"]["observed_count"], 1);
assert_eq!(failed.context["error"], "[redacted] timeout"); assert_eq!(failed.context["error"], "[redacted] timeout");
} }
@@ -1444,7 +1455,8 @@ fn malformed_provider_tool_inputs_become_correlated_errors_without_dropping_vali
panic!("expected provider tool batch"); panic!("expected provider tool batch");
}; };
let (actions, errors) = super::convert_provider_tool_batch(&snapshot.action_context, &batch); let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, false);
assert_eq!(actions.len(), 1); assert_eq!(actions.len(), 1);
assert_eq!(actions[0].0.id.to_string(), "good-grep"); assert_eq!(actions[0].0.id.to_string(), "good-grep");
@@ -1481,7 +1493,8 @@ fn malformed_provider_tool_error_can_be_committed_and_run_continues() {
let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else { let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else {
panic!("expected provider tool batch"); panic!("expected provider tool batch");
}; };
let (actions, errors) = super::convert_provider_tool_batch(&snapshot.action_context, &batch); let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, false);
assert!(actions.is_empty()); assert!(actions.is_empty());
snapshot snapshot
@@ -2860,8 +2873,19 @@ fn provider_command_result_classifier_covers_snapshot_and_finished_variants() {
} }
#[test] #[test]
fn provider_command_result_classifier_ignores_cancelled_and_error_variants() { fn provider_command_result_classifier_ignores_background_jobs_cancelled_and_error_variants() {
let results = [ let results = [
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::BackgroundJobCancellationRequested {
job_id: "cancelled-background-job".to_owned(),
},
),
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::BackgroundJobStarted {
job_id: Uuid::new_v4().to_string(),
command: "cargo build".to_owned(),
},
),
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::CancelledBeforeExecution, RequestCommandOutputResult::CancelledBeforeExecution,
), ),
@@ -2890,6 +2914,34 @@ fn provider_command_result_classifier_ignores_cancelled_and_error_variants() {
.all(|result| super::classify_provider_command_result(result).is_none())); .all(|result| super::classify_provider_command_result(result).is_none()));
} }
#[test]
fn background_job_completions_are_hidden_observations_not_user_authorization() {
let mut input = AIAgentInput::CommandCompletionAssessment {
prompt: "The background build completed with exit code 0.".to_owned(),
context: Arc::from([]),
completed_command: RunningCommand {
command: "cargo build".to_owned(),
block_id: format!("background-job:{}", Uuid::new_v4()).into(),
grid_contents: String::new(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
};
assert!(input.is_background_job_completion());
assert!(!input.is_user_query());
assert_eq!(input.display_query(), None);
let AIAgentInput::CommandCompletionAssessment {
completed_command, ..
} = &mut input
else {
unreachable!();
};
completed_command.block_id = BlockId::new();
assert!(!input.is_background_job_completion());
}
#[test] #[test]
fn no_action_tool_error_recovery_detects_unfulfilled_tool_intent() { fn no_action_tool_error_recovery_detects_unfulfilled_tool_intent() {
assert_eq!( assert_eq!(
@@ -3915,6 +3967,389 @@ fn optimistic_cli_subagent_completion_with_in_flight_stream_reports_success() {
}); });
} }
#[test]
fn progressive_summary_completion_preserves_an_in_flight_model_turn() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal.id(), false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.unwrap()
.set_has_pending_progressive_summary(true);
conversation_id
});
let stream_id = ResponseStreamId::new_for_test();
let response_stream =
ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
let mut slot = progressive_summary_test_slot(conversation_id, stream_id, response_stream);
let identity = super::ActiveProviderRunIdentity {
conversation_id,
stream_id: slot.stream_id.clone(),
run_id: slot.run_id.clone(),
};
let (sender, foreground_control) = turn_control();
slot.turn_control = Some(sender);
let prefix = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("old project details".repeat(1_000)),
}];
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.active_provider_runs.insert(conversation_id, slot);
controller.active_provider_progressive_summaries.insert(
conversation_id,
super::ActiveProviderProgressiveSummaryState::InFlight {
identity: identity.clone(),
waiting_for_context: true,
},
);
controller.finish_active_provider_progressive_summary(
identity.clone(),
progressive_summary_test_plan(prefix),
Ok((
"checkpoint".to_owned(),
Usage::default(),
"summary-model".to_owned(),
)),
ctx,
);
let active = &controller.active_provider_runs[&conversation_id];
assert_eq!(active.stream_id, identity.stream_id);
assert_eq!(active.run_id, identity.run_id);
assert!(active.run.is_none());
assert!(active.checkpoint.is_none());
assert!(active.cancellation_reason.is_none());
let steer = TurnCommand::Steer {
display_text: "continue".to_owned(),
model_text: "continue".to_owned(),
};
assert!(foreground_control.try_receive().is_err());
active
.turn_control
.as_ref()
.unwrap()
.try_send(steer.clone())
.unwrap();
assert_eq!(foreground_control.try_receive().unwrap(), steer);
match &controller.active_provider_progressive_summaries[&conversation_id] {
super::ActiveProviderProgressiveSummaryState::Ready {
identity: ready_identity,
waiting_for_context,
result,
..
} => {
assert_eq!(ready_identity.run_id, identity.run_id);
assert!(*waiting_for_context);
assert_eq!(result.as_ref().unwrap().0, "checkpoint");
}
super::ActiveProviderProgressiveSummaryState::InFlight { .. } => {
panic!("completed summary must remain ready until the next model boundary");
}
}
});
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.unwrap();
assert!(conversation.has_pending_progressive_summary());
assert_eq!(conversation.progressive_summary(), None);
});
});
}
#[test]
fn stale_progressive_summary_completion_preserves_a_successor_generation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal.id(), false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.unwrap()
.set_has_pending_progressive_summary(true);
conversation_id
});
let stream_id = ResponseStreamId::new_for_test();
let response_stream =
ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
let slot = progressive_summary_test_slot(conversation_id, stream_id, response_stream);
let identity = super::ActiveProviderRunIdentity {
conversation_id,
stream_id: slot.stream_id.clone(),
run_id: slot.run_id.clone(),
};
let stale_identity = super::ActiveProviderRunIdentity {
conversation_id,
stream_id: ResponseStreamId::new_for_test(),
run_id: ProviderRunId::new("previous-generation"),
};
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.active_provider_runs.insert(conversation_id, slot);
controller.active_provider_progressive_summaries.insert(
conversation_id,
super::ActiveProviderProgressiveSummaryState::InFlight {
identity: identity.clone(),
waiting_for_context: false,
},
);
controller.finish_active_provider_progressive_summary(
stale_identity,
progressive_summary_test_plan(Vec::new()),
Ok((
"stale checkpoint".to_owned(),
Usage::default(),
"summary-model".to_owned(),
)),
ctx,
);
let active = &controller.active_provider_runs[&conversation_id];
assert_eq!(active.run_id, identity.run_id);
assert_eq!(active.stream_id, identity.stream_id);
match &controller.active_provider_progressive_summaries[&conversation_id] {
super::ActiveProviderProgressiveSummaryState::InFlight {
identity: pending_identity,
waiting_for_context,
} => {
assert_eq!(pending_identity.run_id, identity.run_id);
assert_eq!(pending_identity.stream_id, identity.stream_id);
assert!(!*waiting_for_context);
}
super::ActiveProviderProgressiveSummaryState::Ready { .. } => {
panic!("stale completion must not replace the successor summary");
}
}
});
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.unwrap();
assert!(conversation.has_pending_progressive_summary());
assert_eq!(conversation.progressive_summary(), None);
});
});
}
#[test]
fn idle_progressive_summary_preserves_appended_messages_and_archives_tool_results() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let prefix = vec![
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: "read-original".to_owned(),
name: "read_file".to_owned(),
input: serde_json::json!({"path": "src/original.rs"}),
},
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: "read-original".to_owned(),
content: "original file contents".repeat(1_000),
is_error: false,
},
},
];
let tail = vec![
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text("The change is ready".to_owned()),
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Appended while the summary was running".to_owned()),
},
];
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal.id(), false, false, false, ctx);
let conversation = history.conversation_mut(&conversation_id).unwrap();
conversation.append_to_bedrock_history(prefix.clone());
conversation.append_to_bedrock_history(tail.clone());
conversation.set_has_pending_progressive_summary(true);
conversation.set_status_for_test(ConversationStatus::Success);
conversation_id
});
let identity = super::ActiveProviderRunIdentity {
conversation_id,
stream_id: ResponseStreamId::new_for_test(),
run_id: ProviderRunId::new("finished-generation"),
};
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.active_provider_progressive_summaries.insert(
conversation_id,
super::ActiveProviderProgressiveSummaryState::InFlight {
identity: identity.clone(),
waiting_for_context: false,
},
);
controller.finish_active_provider_progressive_summary(
identity,
progressive_summary_test_plan(prefix.clone()),
Ok((
"checkpoint".to_owned(),
Usage::default(),
"summary-model".to_owned(),
)),
ctx,
);
assert!(!controller.active_provider_runs.contains_key(&conversation_id));
assert!(!controller
.active_provider_progressive_summaries
.contains_key(&conversation_id));
});
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.unwrap();
assert_eq!(conversation.progressive_summary(), Some("checkpoint"));
assert_eq!(conversation.bedrock_message_history(), tail.as_slice());
assert_eq!(conversation.tool_result_archive(), prefix.as_slice());
assert!(!conversation.has_pending_progressive_summary());
assert_eq!(conversation.status(), &ConversationStatus::Success);
assert_eq!(
conversation.current_context_tokens(),
100 + super::estimated_history_tokens(&super::progressive_summary_messages(
"checkpoint".to_owned()
)) + super::estimated_history_tokens(&tail)
);
});
});
}
#[test]
fn idle_progressive_summary_rejects_a_changed_prefix() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let prefix = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("original project context".repeat(1_000)),
}];
let replacement = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("replacement context that must survive".repeat(1_000)),
}];
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal.id(), false, false, false, ctx);
let conversation = history.conversation_mut(&conversation_id).unwrap();
conversation.append_to_bedrock_history(replacement.clone());
conversation.set_has_pending_progressive_summary(true);
conversation.set_status_for_test(ConversationStatus::Success);
conversation_id
});
let identity = super::ActiveProviderRunIdentity {
conversation_id,
stream_id: ResponseStreamId::new_for_test(),
run_id: ProviderRunId::new("finished-generation"),
};
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.active_provider_progressive_summaries.insert(
conversation_id,
super::ActiveProviderProgressiveSummaryState::InFlight {
identity: identity.clone(),
waiting_for_context: false,
},
);
controller.finish_active_provider_progressive_summary(
identity,
progressive_summary_test_plan(prefix),
Ok((
"stale checkpoint".to_owned(),
Usage::default(),
"summary-model".to_owned(),
)),
ctx,
);
assert!(!controller
.active_provider_progressive_summaries
.contains_key(&conversation_id));
});
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.unwrap();
assert_eq!(conversation.progressive_summary(), None);
assert_eq!(conversation.bedrock_message_history(), replacement.as_slice());
assert!(conversation.tool_result_archive().is_empty());
assert!(!conversation.has_pending_progressive_summary());
});
});
}
fn progressive_summary_test_plan(
summarized_prefix: Vec<ConversationMessage>,
) -> super::ActiveProgressiveSummaryPlan {
super::ActiveProgressiveSummaryPlan {
split_point: summarized_prefix.len(),
retained_messages_count: 1,
trigger_context_tokens: 10_000,
active_context_limit: 128_000,
persistent_context_tokens: 100,
candidates: Vec::new(),
summarize_messages: summarized_prefix.clone(),
summarized_prefix,
context_usage: None,
started_at: std::time::Instant::now(),
}
}
fn progressive_summary_test_slot(
conversation_id: AIConversationId,
stream_id: ResponseStreamId,
response_stream: ModelHandle<ResponseStream>,
) -> super::ActiveProviderRunSlot {
let snapshot = provider_snapshot(conversation_id);
super::ActiveProviderRunSlot {
stream_id,
response_stream,
did_input_contain_user_query: true,
run_id: snapshot.run.id().clone(),
root_task_id: snapshot.root_task_id,
projection_target: snapshot.projection_target,
run: None,
checkpoint: None,
turn_control: None,
cancellation_reason: None,
committed_provider_batch: None,
finished_provider_batch: None,
command_action_refs: HashMap::new(),
command_monitor: None,
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
live_command_output_tokens: 0,
command_output_poll_scheduled: false,
retry_status: None,
tool_call_progress: Vec::new(),
}
}
#[test] #[test]
fn progressive_summary_compacts_short_histories_to_the_token_budget() { fn progressive_summary_compacts_short_histories_to_the_token_budget() {
let messages = vec![ let messages = vec![
@@ -4162,3 +4597,175 @@ fn restored_provider_checkpoint_waits_for_explicit_resume() {
}); });
}); });
} }
fn dispatch_follow_up_tool_batch(
snapshot: &mut super::ActiveProviderRunSnapshot,
calls: Vec<ToolCall>,
) -> galaxy_agent_core::PendingToolBatch {
let Some(ProviderRunStep::CallModel(call)) = snapshot.run.next_step().unwrap() else {
panic!("expected model call");
};
let advertised_tools = calls.iter().map(|call| call.name.clone()).collect();
snapshot
.run
.accept_model_turn(
&call.work_id,
CompletedModelTurn {
assistant_content: Vec::new(),
tool_calls: calls,
usage: Usage::default(),
stop_reason: StopReason::Completed,
advertised_tools,
},
)
.unwrap();
let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else {
panic!("expected tool batch");
};
batch
}
#[test]
fn wait_without_an_event_source_returns_a_correlated_error_and_allows_another_model_turn() {
let mut snapshot = provider_snapshot(AIConversationId::new());
let batch = dispatch_follow_up_tool_batch(
&mut snapshot,
vec![ToolCall {
id: "unsupported-wait".to_owned(),
name: "wait_for_events".to_owned(),
arguments: serde_json::json!({}),
}],
);
let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, false);
assert_eq!(actions.len(), 0);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].call_id, "unsupported-wait");
assert_eq!(errors[0].status, galaxy_agent_core::ToolResultStatus::Error);
assert!(errors[0]
.content
.contains("No child-agent or orchestration notification source"));
assert!(errors[0].content.contains("cannot schedule a follow-up"));
snapshot
.run
.complete_tool(&batch.work_id, errors[0].clone())
.unwrap();
snapshot.run.commit_tool_batch(&batch.work_id).unwrap();
let MessageContent::MultiPart(parts) = &snapshot.run.transcript().last().unwrap().content
else {
panic!("expected committed tool result");
};
assert!(matches!(&parts[0], ContentPart::ToolResult {
tool_use_id, is_error: true, ..
} if tool_use_id == "unsupported-wait"));
assert!(matches!(
snapshot.run.next_step().unwrap(),
Some(ProviderRunStep::CallModel(_))
));
}
#[test]
fn sole_wait_with_an_expected_orchestration_notification_remains_executable() {
let mut snapshot = provider_snapshot(AIConversationId::new());
let batch = dispatch_follow_up_tool_batch(
&mut snapshot,
vec![ToolCall {
id: "child-wait".to_owned(),
name: "wait_for_events".to_owned(),
arguments: serde_json::json!({"idle_timeout_seconds": 45}),
}],
);
let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, true);
assert_eq!(errors.len(), 0);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].0.id.to_string(), "child-wait");
assert!(
matches!(&actions[0].0.action, AIAgentActionType::WaitForEvents {
tool_call_id, idle_timeout_seconds: 45,
} if tool_call_id == "child-wait")
);
assert!(
matches!(snapshot.run.state(), ProviderRunState::AwaitingTools { batch } if !batch.is_complete())
);
}
#[test]
fn mixed_shell_launch_and_wait_preserves_the_launch_and_yields_after_every_result_commits() {
let mut snapshot = provider_snapshot(AIConversationId::new());
let batch = dispatch_follow_up_tool_batch(
&mut snapshot,
vec![
ToolCall {
id: "job-launch".to_owned(),
name: "run_shell_command".to_owned(),
arguments: serde_json::json!({"command": "echo ready"}),
},
ToolCall {
id: "mixed-wait".to_owned(),
name: "wait_for_events".to_owned(),
arguments: serde_json::json!({}),
},
],
);
let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, true);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].0.id.to_string(), "job-launch");
assert!(
matches!(&actions[0].0.action, AIAgentActionType::RequestCommandOutput { command, .. } if command == "echo ready")
);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].call_id, "mixed-wait");
assert_eq!(errors[0].status, galaxy_agent_core::ToolResultStatus::Error);
assert!(errors[0].content.contains("only tool in its batch"));
snapshot
.run
.complete_tool(&batch.work_id, errors[0].clone())
.unwrap();
let error = snapshot.run.commit_tool_batch(&batch.work_id).unwrap_err();
assert!(error.to_string().contains("job-launch"));
assert!(matches!(
snapshot.run.state(),
ProviderRunState::AwaitingTools { .. }
));
snapshot
.run
.start_tool(&batch.work_id, "job-launch")
.unwrap();
snapshot
.run
.complete_tool(
&batch.work_id,
galaxy_agent_core::ToolResult {
call_id: "job-launch".to_owned(),
content: serde_json::json!({
"background_job_started": {"job_id": "job-1", "command": "echo ready"}
})
.to_string(),
status: galaxy_agent_core::ToolResultStatus::Success,
images: Vec::new(),
},
)
.unwrap();
snapshot.run.commit_tool_batch(&batch.work_id).unwrap();
let MessageContent::MultiPart(parts) = &snapshot.run.transcript().last().unwrap().content
else {
panic!("expected both committed tool results");
};
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], ContentPart::ToolResult {
tool_use_id, is_error: false, ..
} if tool_use_id == "job-launch"));
assert!(matches!(&parts[1], ContentPart::ToolResult {
tool_use_id, is_error: true, ..
} if tool_use_id == "mixed-wait"));
assert!(
matches!(snapshot.run.next_step().unwrap(), Some(ProviderRunStep::Done(
ProviderRunOutcome::Completed(completion)
)) if completion.stop_reason == StopReason::Completed)
);
}
@@ -226,6 +226,22 @@ impl AIConversationMetadata {
} }
impl BlocklistAIHistoryModel { impl BlocklistAIHistoryModel {
/// Restore saved local sessions without falling back to a hosted transcript.
pub fn load_local_conversation_data(
&self,
conversation_id: AIConversationId,
) -> galaxyui::r#async::BoxFuture<'static, Option<CloudConversationData>> {
let conversation = self
.load_conversation_from_db(&conversation_id)
.filter(|conversation| {
!conversation
.server_metadata()
.is_some_and(|metadata| metadata.ambient_agent_task_id.is_some())
})
.map(|conversation| CloudConversationData::Oz(Box::new(conversation)));
box_future(futures::future::ready(conversation))
}
/// Loads conversation data from the appropriate source (DB or server). /// Loads conversation data from the appropriate source (DB or server).
/// ///
/// This method automatically determines whether to load from the local database or /// This method automatically determines whether to load from the local database or
@@ -188,6 +188,17 @@ enum AskUserQuestionPhase<'a> {
}, },
} }
fn ask_user_question_is_waiting_on_user_answers(
phase: AskUserQuestionPhase<'_>,
status: Option<&AIActionStatus>,
) -> bool {
matches!(phase, AskUserQuestionPhase::Editing)
&& matches!(
status,
Some(AIActionStatus::Blocked | AIActionStatus::RunningAsync)
)
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
/// Editing-phase state for the questionnaire. /// Editing-phase state for the questionnaire.
/// Holds the active question index and one draft slot per question. /// Holds the active question index and one draft slot per question.
@@ -806,10 +817,6 @@ impl AskUserQuestionView {
&self.action_id &self.action_id
} }
pub fn is_editing(&self) -> bool {
self.session.is_editing()
}
/// Sets the settings-link mouse handle used in the speedbump footer. /// Sets the settings-link mouse handle used in the speedbump footer.
/// The footer is only actually drawn during the completed/finished render paths. /// The footer is only actually drawn during the completed/finished render paths.
pub fn set_speedbump_settings_link( pub fn set_speedbump_settings_link(
@@ -902,10 +909,12 @@ impl AskUserQuestionView {
}) })
} }
/// True when the action model reports this inline action is blocked waiting on user answers. /// Questions can wait before execution or in an approved async executor, as in Allow All.
fn is_waiting_on_user_answers(&self, app: &AppContext) -> bool { pub(crate) fn is_waiting_on_user_answers(&self, app: &AppContext) -> bool {
self.action_status(app) ask_user_question_is_waiting_on_user_answers(
.is_some_and(|status| status.is_blocked()) self.session.phase(),
self.action_status(app).as_ref(),
)
} }
pub fn should_render_inline(&self, app: &AppContext) -> bool { pub fn should_render_inline(&self, app: &AppContext) -> bool {
@@ -1170,8 +1179,8 @@ impl AskUserQuestionView {
self.abort_auto_advance(); self.abort_auto_advance();
let action_id = self.action_id().clone(); let action_id = self.action_id().clone();
let conversation_id = self.conversation_id; let conversation_id = self.conversation_id;
// Completing the executor only stages the result; execute_action drives the action model // A running executor receives the answer directly. A blocked question must first start
// to publish final status/events for this inline block. // execution; execute_action starts it and leaves an already-running receiver intact.
self.action_model.update(ctx, |action_model, ctx| { self.action_model.update(ctx, |action_model, ctx| {
action_model action_model
.ask_user_question_executor(ctx) .ask_user_question_executor(ctx)
@@ -1,10 +1,16 @@
use std::sync::Arc;
use ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType}; use ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType};
use ai::agent::action_result::AskUserQuestionAnswerItem; use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use super::{ use super::{
ask_user_question_view_state, AskUserQuestionAction, AskUserQuestionEffect, ask_user_question_is_waiting_on_user_answers, ask_user_question_view_state,
AskUserQuestionPhase, AskUserQuestionSession, AskUserQuestionViewState, AskUserQuestionAction, AskUserQuestionEffect, AskUserQuestionPhase,
AskUserQuestionSession, AskUserQuestionViewState,
}; };
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{AIAgentActionId, AIAgentActionResult, AIAgentActionResultType};
use crate::ai::blocklist::action_model::AIActionStatus;
fn build_question( fn build_question(
question_id: &str, question_id: &str,
@@ -42,6 +48,53 @@ fn current_draft(session: &AskUserQuestionSession) -> Option<&super::QuestionDra
session.current().and_then(|current| current.draft) session.current().and_then(|current| current.draft)
} }
#[test]
fn blocked_and_running_questions_accept_answers_until_the_session_completes() {
let mut session = build_session(vec![build_question("q1", "Destination", false, true, &[])]);
let blocked = AIActionStatus::Blocked;
let running = AIActionStatus::RunningAsync;
for status in [&blocked, &running] {
assert!(ask_user_question_is_waiting_on_user_answers(
session.phase(),
Some(status),
));
}
assert!(matches!(
session.apply(AskUserQuestionAction::SkipAll),
AskUserQuestionEffect::Submit(_)
));
for status in [&blocked, &running] {
assert!(!ask_user_question_is_waiting_on_user_answers(
session.phase(),
Some(status),
));
}
}
#[test]
fn queued_preprocessing_missing_and_finished_questions_do_not_accept_answers() {
let session = build_session(vec![build_question("q1", "Destination", false, true, &[])]);
let finished = AIActionStatus::Finished(Arc::new(AIAgentActionResult {
result: AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled),
task_id: TaskId::new("question-task".to_owned()),
id: AIAgentActionId::from("question-action".to_owned()),
}));
for status in [
None,
Some(AIActionStatus::Queued),
Some(AIActionStatus::Preprocessing),
Some(finished),
] {
assert!(!ask_user_question_is_waiting_on_user_answers(
session.phase(),
status.as_ref(),
));
}
}
#[test] #[test]
fn enter_on_other_row_focuses_the_other_input() { fn enter_on_other_row_focuses_the_other_input() {
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
@@ -35,8 +35,6 @@ pub struct AwsBedrockCredentialsErrorView {
model_name: String, model_name: String,
login_command: String, login_command: String,
/// Whether auto-login was triggered, which shows a simpler "running command" message.
auto_login_triggered: bool,
// Run button // Run button
run_button: ViewHandle<ActionButton>, run_button: ViewHandle<ActionButton>,
@@ -52,7 +50,6 @@ impl AwsBedrockCredentialsErrorView {
pub fn new( pub fn new(
model_name: String, model_name: String,
login_command: String, login_command: String,
auto_login_triggered: bool,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) -> Self { ) -> Self {
// Run button // Run button
@@ -83,7 +80,6 @@ impl AwsBedrockCredentialsErrorView {
Self { Self {
model_name, model_name,
login_command, login_command,
auto_login_triggered,
run_button, run_button,
configure_button, configure_button,
auto_login_checkbox_handle: MouseStateHandle::default(), auto_login_checkbox_handle: MouseStateHandle::default(),
@@ -104,23 +100,6 @@ impl View for AwsBedrockCredentialsErrorView {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
// If auto-login was triggered, show a simple "running command" message
if self.auto_login_triggered {
return Flex::row()
.with_spacing(8.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new(
format!("Running `{}`...", self.login_command),
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selectable(false)
.finish(),
)
.finish();
}
let auto_login_enabled = *AISettings::as_ref(app).bedrock_auto_login.value(); let auto_login_enabled = *AISettings::as_ref(app).bedrock_auto_login.value();
@@ -149,11 +128,19 @@ impl View for AwsBedrockCredentialsErrorView {
let make_detail_text = || { let make_detail_text = || {
Text::new( Text::new(
if self.login_command.is_empty() {
format!(
"Failed to authenticate with AWS Bedrock when using {}. \
Refresh credentials for the AWS profile selected in Providers, then retry.",
self.model_name
)
} else {
format!( format!(
"Failed to authenticate with AWS Bedrock when using {}. \ "Failed to authenticate with AWS Bedrock when using {}. \
Run `{}` to refresh credentials.", Run `{}` to refresh credentials.",
self.model_name, self.login_command self.model_name, self.login_command
), )
},
appearance.ui_font_family(), appearance.ui_font_family(),
14., 14.,
) )
@@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::ops::Range; use std::ops::Range;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
@@ -276,6 +276,8 @@ pub struct DiffBase {
pub struct FileDiff { pub struct FileDiff {
pub base: DiffBase, pub base: DiffBase,
pub diff_type: DiffType, pub diff_type: DiffType,
/// Exclusive local creation constrained to this canonical project or home directory.
pub creation_scope: Option<PathBuf>,
} }
impl FileDiff { impl FileDiff {
@@ -283,6 +285,7 @@ impl FileDiff {
FileDiff { FileDiff {
base: DiffBase { content, file_path }, base: DiffBase { content, file_path },
diff_type, diff_type,
creation_scope: None,
} }
} }
@@ -473,6 +476,8 @@ pub struct CodeDiffView {
action_id: AIAgentActionId, action_id: AIAgentActionId,
pending_diffs: Vec<PendingDiff>, pending_diffs: Vec<PendingDiff>,
pending_reverts: HashSet<usize>,
revert_failed: bool,
button_mouse_states: CodeDiffViewMouseStates, button_mouse_states: CodeDiffViewMouseStates,
scrollable_state: ClippedScrollStateHandle, scrollable_state: ClippedScrollStateHandle,
cancel_button: CompactibleActionButton, cancel_button: CompactibleActionButton,
@@ -656,6 +661,12 @@ impl CodeDiffView {
}); });
me.handle_save_completed(idx, Some(error.clone()), ctx); me.handle_save_completed(idx, Some(error.clone()), ctx);
} }
#[cfg(not(target_family = "wasm"))]
InlineDiffViewEvent::RevertCompleted => me.handle_revert_completed(idx, None, ctx),
#[cfg(not(target_family = "wasm"))]
InlineDiffViewEvent::FailedToRevert { error } => {
me.handle_revert_completed(idx, Some(error.clone()), ctx);
}
InlineDiffViewEvent::DiffAccepted { diff } => { InlineDiffViewEvent::DiffAccepted { diff } => {
me.accepted_file_diff_computed(idx, diff.clone(), ctx); me.accepted_file_diff_computed(idx, diff.clone(), ctx);
} }
@@ -949,6 +960,8 @@ impl CodeDiffView {
Self { Self {
action_id: action_id.clone(), action_id: action_id.clone(),
pending_diffs: Vec::new(), pending_diffs: Vec::new(),
pending_reverts: HashSet::new(),
revert_failed: false,
button_mouse_states: Default::default(), button_mouse_states: Default::default(),
cancel_button, cancel_button,
edit_button, edit_button,
@@ -1034,13 +1047,15 @@ impl CodeDiffView {
// Create the InlineDiffView which applies diffs to the editor buffer. // Create the InlineDiffView which applies diffs to the editor buffer.
let diff_viewer = ctx.add_typed_action_view(|ctx| { let diff_viewer = ctx.add_typed_action_view(|ctx| {
InlineDiffView::new( let mut view = InlineDiffView::new(
editor.clone(), editor.clone(),
Some(diff.diff_type), Some(diff.diff_type),
Some(display_mode), Some(display_mode),
standardized_path, standardized_path,
ctx, ctx,
) );
view.set_creation_scope(diff.creation_scope);
view
}); });
// On non-WASM, register the file with FileModel for save support. // On non-WASM, register the file with FileModel for save support.
@@ -1162,7 +1177,8 @@ impl CodeDiffView {
/// Revert all changes by replacing file contents with the base version. /// Revert all changes by replacing file contents with the base version.
/// For newly created files, this deletes them instead. /// For newly created files, this deletes them instead.
fn revert_changes(&mut self, ctx: &mut ViewContext<Self>) { fn revert_changes(&mut self, ctx: &mut ViewContext<Self>) {
if !matches!(self.state, CodeDiffState::Accepted(None)) { if !matches!(self.state, CodeDiffState::Accepted(None)) || !self.pending_reverts.is_empty()
{
log::warn!( log::warn!(
"Attempted to revert changes when not in Accepted(None) state - actual state: {:?}", "Attempted to revert changes when not in Accepted(None) state - actual state: {:?}",
self.state self.state
@@ -1170,12 +1186,26 @@ impl CodeDiffView {
return; return;
} }
self.revert_failed = false;
self.pending_reverts = self
.pending_diffs
.iter()
.enumerate()
.filter_map(|(idx, diff)| {
diff.diff_view
.as_ref(ctx)
.has_guarded_creation_to_revert()
.then_some(idx)
})
.collect();
let window_id = ctx.window_id(); let window_id = ctx.window_id();
for diff in &self.pending_diffs { for (idx, diff) in self.pending_diffs.iter().enumerate() {
if let Err(err) = diff if let Err(err) = diff
.diff_view .diff_view
.update(ctx, |v, ctx| v.restore_diff_base(ctx)) .update(ctx, |v, ctx| v.restore_diff_base(ctx))
{ {
self.pending_reverts.remove(&idx);
self.revert_failed = true;
log::error!("Failed to restore diff base: {err:?}"); log::error!("Failed to restore diff base: {err:?}");
let file_name = diff let file_name = diff
.diff_view .diff_view
@@ -1184,7 +1214,9 @@ impl CodeDiffView {
.unwrap_or_else(|| "file".to_string()); .unwrap_or_else(|| "file".to_string());
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast( toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!("Failed to revert changes to {file_name}")), DismissibleToast::error(format!(
"Failed to revert changes to {file_name}: {err}"
)),
window_id, window_id,
ctx, ctx,
); );
@@ -1192,8 +1224,38 @@ impl CodeDiffView {
} }
} }
if self.pending_reverts.is_empty() && !self.revert_failed {
self.state = CodeDiffState::Reverted; self.state = CodeDiffState::Reverted;
self.mark_action_as_reverted(ctx); self.mark_action_as_reverted(ctx);
}
ctx.notify();
}
#[cfg(not(target_family = "wasm"))]
fn handle_revert_completed(
&mut self,
idx: usize,
error: Option<Rc<FileSaveError>>,
ctx: &mut ViewContext<Self>,
) {
if !self.pending_reverts.remove(&idx) {
return;
}
if let Some(error) = error {
self.revert_failed = true;
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toasts, ctx| {
toasts.add_ephemeral_toast(
DismissibleToast::error(format!("Failed to revert skill creation: {error}")),
window_id,
ctx,
);
});
}
if self.pending_reverts.is_empty() && !self.revert_failed {
self.state = CodeDiffState::Reverted;
self.mark_action_as_reverted(ctx);
}
ctx.notify(); ctx.notify();
} }
@@ -14,8 +14,8 @@ use galaxyui::elements::new_scrollable::{
use galaxyui::elements::{ use galaxyui::elements::{
Align, Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container, Align, Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex, MainAxisSize, MouseStateHandle, CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex, MainAxisSize, MouseStateHandle,
NewScrollable, OffsetPositioning, ParentElement, Radius, ScrollbarWidth, SelectableArea, NewScrollable, OffsetPositioning, Padding, ParentElement, Radius, ScrollbarWidth,
SelectionHandle, Stack, Text, SelectableArea, SelectionHandle, Stack, Text,
}; };
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke}; use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
use galaxyui::ui_components::components::UiComponent as _; use galaxyui::ui_components::components::UiComponent as _;
@@ -379,6 +379,13 @@ impl RequestedCommandView {
ai_block_view_id: EntityId, ai_block_view_id: EntityId,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) -> Self { ) -> Self {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
ctx.subscribe_to_model(
&crate::ai::background_jobs::BackgroundJobsModel::handle(ctx),
|_, _, _, ctx| {
ctx.notify();
},
);
let cancel_button = CompactibleActionButton::new( let cancel_button = CompactibleActionButton::new(
REQUESTED_COMMAND_REJECT_LABEL.to_string(), REQUESTED_COMMAND_REJECT_LABEL.to_string(),
Some(KeystrokeSource::Fixed( Some(KeystrokeSource::Fixed(
@@ -877,7 +884,7 @@ impl RequestedCommandView {
) if show_for_action_id == &self.action_id => { ) if show_for_action_id == &self.action_id => {
*shown.lock() = true; *shown.lock() = true;
Some(render_autonomy_checkbox_setting_speedbump_footer( Some(render_autonomy_checkbox_setting_speedbump_footer(
"Always allow Oz to execute read-only commands (relies on model)", "Always allow Galaxy to execute read-only commands (relies on model)",
*checked, *checked,
AIBlockAction::ToggleAutoexecuteReadonlyCommandsSpeedbumpCheckbox, AIBlockAction::ToggleAutoexecuteReadonlyCommandsSpeedbumpCheckbox,
self.autoexecute_readonly_commands_speedbump_checkbox_handle self.autoexecute_readonly_commands_speedbump_checkbox_handle
@@ -1165,6 +1172,84 @@ impl RequestedCommandView {
} }
} }
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn render_background_job(
&self,
job_id: &str,
command: &str,
app: &AppContext,
) -> Box<dyn Element> {
use crate::ai::background_jobs::{BackgroundJobStatus, BackgroundJobsModel};
use crate::terminal::view::TerminalAction;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let id = uuid::Uuid::parse_str(job_id).ok();
let job = id
.and_then(|id| BackgroundJobsModel::as_ref(app).job(id))
.filter(|job| {
job.conversation_id == self.client_ids.conversation_id
&& job.action_id == self.action_id
});
let status = job.map_or(BackgroundJobStatus::Cancelled, |job| job.status);
let label = match job.map(|job| job.status) {
Some(BackgroundJobStatus::Running) => "Running in background",
Some(BackgroundJobStatus::Succeeded) => "Succeeded",
Some(BackgroundJobStatus::Failed) => "Failed",
Some(BackgroundJobStatus::Cancelled) => "Canceled",
None => "Job history unavailable",
};
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.);
row.add_child(crate::ai::background_jobs::view::status_icon(&status, app));
row.add_child(
Expanded::new(
1.,
Text::new(
label.to_owned(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish(),
)
.finish(),
);
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(8.)
.with_child(row.finish())
.with_child(
Text::new(
command.to_owned(),
appearance.monospace_font_family(),
appearance.monospace_font_size() - 1.,
)
.soft_wrap(true)
.with_color(theme.font_color(theme.surface_2()).into())
.finish(),
)
.finish();
let content = Container::new(content)
.with_padding(Padding::uniform(12.))
.finish();
let content = if job.is_some() {
let id = id.expect("a registered job has a valid identity");
galaxyui::elements::Hoverable::new(self.header_mouse_state.clone(), move |_| content)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::OpenBackgroundJobs {
job_id: Some(id),
})
})
.with_cursor(galaxyui::platform::Cursor::PointingHand)
.finish()
} else {
content
};
render_tool_pane_shell(content, false, false, false, app)
}
fn render_header( fn render_header(
&self, &self,
should_round_bottom_corners: bool, should_round_bottom_corners: bool,
@@ -1546,6 +1631,16 @@ impl View for RequestedCommandView {
app, app,
); );
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
if let Some(AIActionStatus::Finished(result)) = &action_status {
if let AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::BackgroundJobStarted { job_id, command },
) = &result.result
{
return self.render_background_job(job_id, command, app);
}
}
let is_last_output_message_in_output = self let is_last_output_message_in_output = self
.block_model .block_model
.status(app) .status(app)
@@ -29,6 +29,7 @@ use crate::ai::blocklist::usage::rollup::{
}; };
use crate::ai::blocklist::view_util::{format_credits, format_token_count}; use crate::ai::blocklist::view_util::{format_credits, format_token_count};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::runtime::{ProviderContextUsage, PROGRESSIVE_SUMMARY_TRIGGER_USAGE};
use crate::appearance::Appearance; use crate::appearance::Appearance;
use crate::persistence::model::{ use crate::persistence::model::{
token_usage_category_display_name, ContextWindowSegment, ContextWindowSegmentType, token_usage_category_display_name, ContextWindowSegment, ContextWindowSegmentType,
@@ -61,6 +62,10 @@ pub struct ConversationUsageInfo {
pub commands_executed: i32, pub commands_executed: i32,
/// Live context window token count (from most recent Bedrock response). /// Live context window token count (from most recent Bedrock response).
pub current_context_tokens: u32, pub current_context_tokens: u32,
/// Local estimate for the projected direct-provider request and its compaction budget.
pub provider_context_usage: Option<ProviderContextUsage>,
/// Input tokens reported by the last completed provider call, separate from local estimates.
pub latest_model_call_reported_context_tokens: Option<u32>,
/// Cumulative cost across all requests. /// Cumulative cost across all requests.
pub estimated_cost_cents: f32, pub estimated_cost_cents: f32,
/// Cumulative cache read tokens (session total). /// Cumulative cache read tokens (session total).
@@ -71,6 +76,44 @@ pub struct ConversationUsageInfo {
pub total_input_tokens: u32, pub total_input_tokens: u32,
} }
pub(crate) fn provider_context_usage_tooltip(
usage: &ProviderContextUsage,
last_reported_input_tokens: Option<u32>,
) -> String {
let active = &usage.active;
let compaction = &usage.compaction;
let last_reported = last_reported_input_tokens
.map(|tokens| format!("{tokens} tokens"))
.unwrap_or_else(|| "not yet available".to_string());
let trigger_percent = PROGRESSIVE_SUMMARY_TRIGGER_USAGE * 100.0;
format!(
"Local estimate for the projected request; provider tokenization may differ. Updates at model/tool boundaries.\n\
Active request: ~{} input tokens of {} effective context tokens ({:.1}%).\n\
Active profile: {} ({}).\n\
Fixed prompts and tools: ~{} input tokens. Reserved output: {} tokens.\n\
Compaction budget: ~{} history tokens of {} usable history tokens ({:.1}%).\n\
Limiting profile: {} ({}), {} effective context tokens, ~{} fixed input tokens and {} reserved output tokens.\n\
Compaction starts at {trigger_percent:.0}% of usable history.\n\
Last provider-reported input: {}.",
active.estimated_input_tokens,
active.context_limit,
active.input_usage() * 100.0,
active.profile,
active.model_id,
active.fixed_input_tokens,
active.reserved_output_tokens,
compaction.history_tokens,
compaction.history_capacity_tokens,
usage.compaction_usage() * 100.0,
compaction.profile,
compaction.model_id,
compaction.context_limit,
compaction.fixed_input_tokens,
compaction.reserved_output_tokens,
last_reported,
)
}
/// Timing information for the last set of agent responses /// Timing information for the last set of agent responses
/// (all blocks since the last user input, as this is the granularity /// (all blocks since the last user input, as this is the granularity
/// at which we show the usage footer) /// at which we show the usage footer)
@@ -139,6 +182,29 @@ pub struct ConversationUsageView {
} }
impl ConversationUsageView { impl ConversationUsageView {
fn provider_context_usage(
&self,
app: &AppContext,
) -> Option<(ProviderContextUsage, Option<u32>)> {
if let Some(conversation) = self
.parent_conversation_id
.and_then(|id| BlocklistAIHistoryModel::as_ref(app).conversation(&id))
{
if let Some(usage) = conversation.provider_context_usage() {
return Some((
usage.clone(),
conversation.latest_model_call_reported_context_tokens(),
));
}
}
self.usage_info.provider_context_usage.clone().map(|usage| {
(
usage,
self.usage_info.latest_model_call_reported_context_tokens,
)
})
}
pub fn new( pub fn new(
usage_info: ConversationUsageInfo, usage_info: ConversationUsageInfo,
display_mode: DisplayMode, display_mode: DisplayMode,
@@ -316,8 +382,14 @@ impl ConversationUsageView {
let theme = appearance.theme(); let theme = appearance.theme();
let font_size = appearance.ui_font_size() + 2.; let font_size = appearance.ui_font_size() + 2.;
let text_color = blended_colors::text_main(theme, theme.surface_2()); let text_color = blended_colors::text_main(theme, theme.surface_2());
let context_window_breakdown_enabled = FeatureFlag::ContextWindowUsageBreakdown let provider_context_usage = self.provider_context_usage(app);
.is_enabled() let context_window_usage = provider_context_usage
.as_ref()
.map(|(usage, _)| usage.compaction_usage())
.unwrap_or(self.usage_info.context_window_usage)
.clamp(0.0, 1.0);
let context_window_breakdown_enabled = provider_context_usage.is_none()
&& FeatureFlag::ContextWindowUsageBreakdown.is_enabled()
&& !context_window_segment_display_rows( && !context_window_segment_display_rows(
self.usage_info.context_window_usage, self.usage_info.context_window_usage,
&self.usage_info.context_window_segments, &self.usage_info.context_window_segments,
@@ -478,8 +550,53 @@ impl ConversationUsageView {
); );
} }
// Context tokens (live state — current context window size) if let Some((usage, last_reported)) = &provider_context_usage {
if self.usage_info.current_context_tokens > 0 { let active = &usage.active;
let compaction = &usage.compaction;
for (label, value) in [
(
"Projected input (local estimate)",
format!("~{} tokens", active.estimated_input_tokens),
),
(
"Effective request context",
format!("{} tokens", active.context_limit),
),
(
"Fixed prompts and tools (estimate)",
format!("~{} tokens", active.fixed_input_tokens),
),
(
"Reserved output",
format!("{} tokens", active.reserved_output_tokens),
),
(
"Compaction history (estimate)",
format!(
"~{} / {} tokens",
compaction.history_tokens, compaction.history_capacity_tokens,
),
),
("Compaction profile", compaction.profile.clone()),
("Compaction model", compaction.model_id.clone()),
(
"Compaction trigger",
format!(
"{:.0}% of usable history",
PROGRESSIVE_SUMMARY_TRIGGER_USAGE * 100.0
),
),
(
"Last provider-reported input",
last_reported
.map(|tokens| format!("{tokens} tokens"))
.unwrap_or_else(|| "Not yet available".to_string()),
),
] {
labels.push(render_label_text(label, appearance));
values.push(render_value_text(value, appearance));
}
} else if self.usage_info.current_context_tokens > 0 {
labels.push(render_label_text("Context tokens", appearance)); labels.push(render_label_text("Context tokens", appearance));
values.push(render_value_text( values.push(render_value_text(
format_token_count(self.usage_info.current_context_tokens), format_token_count(self.usage_info.current_context_tokens),
@@ -521,11 +638,22 @@ impl ConversationUsageView {
} }
} }
labels.push(render_label_text("Context window used", appearance)); labels.push(render_label_text(
let context_window_usage = self.usage_info.context_window_usage.clamp(0.0, 1.0); if provider_context_usage.is_some() {
let context_usage_pct = context_window_usage * 100.; "History budget used (estimate)"
let context_usage_str = if context_window_breakdown_enabled && self.context_window_expanded } else {
{ "Context window used"
},
appearance,
));
let context_usage_pct = provider_context_usage
.as_ref()
.map(|(usage, _)| usage.compaction_usage())
.unwrap_or(context_window_usage)
* 100.;
let context_usage_str = if provider_context_usage.is_some() {
format!("~{context_usage_pct:.1}%")
} else if context_window_breakdown_enabled && self.context_window_expanded {
format!("{context_usage_pct:.2}%") format!("{context_usage_pct:.2}%")
} else { } else {
format!("{}%", context_usage_pct.round()) format!("{}%", context_usage_pct.round())
+350 -218
View File
@@ -2,12 +2,17 @@
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine; use base64::Engine;
use futures::channel::oneshot; use futures::channel::oneshot;
use futures::StreamExt;
use galaxy_agent_rig::ChatGPTCredentialResolver;
use galaxyui::{Entity, ModelContext, SingletonEntity}; use galaxyui::{Entity, ModelContext, SingletonEntity};
use galaxyui_extras::secure_storage::AppContextExt as _;
use instant::Instant; use instant::Instant;
use rand::RngCore; use rand::RngCore;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -21,6 +26,10 @@ const CALLBACK_HOST: &str = "localhost";
const CALLBACK_PORTS: [u16; 2] = [1455, 1457]; const CALLBACK_PORTS: [u16; 2] = [1455, 1457];
const CALLBACK_PATH: &str = "/auth/callback"; const CALLBACK_PATH: &str = "/auth/callback";
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
const AUTH_STORAGE_KEY: &str = "galaxy.chatgpt.oauth";
const SIGN_IN_REQUIRED: &str = "ChatGPT sign-in required. Connect or reconnect ChatGPT in Providers.";
type Reply = oneshot::Sender<Result<ChatGPTAuthCredentials, String>>;
static RESOLVER: OnceLock<Mutex<Option<async_channel::Sender<Reply>>>> = OnceLock::new();
/// Current state of the local ChatGPT subscription connection. /// Current state of the local ChatGPT subscription connection.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -45,26 +54,42 @@ struct OAuthCallback {
/// Coordinates browser-based OAuth authorization for ChatGPT subscriptions. /// Coordinates browser-based OAuth authorization for ChatGPT subscriptions.
pub(crate) struct ChatGPTAuthModel { pub(crate) struct ChatGPTAuthModel {
state: ChatGPTAuthState, state: ChatGPTAuthState,
record: Option<AuthRecord>,
refresh: RefreshCoordinator,
sign_in_generation: u64,
sign_in_cancel: Option<Arc<AtomicBool>>,
} }
impl ChatGPTAuthModel { impl ChatGPTAuthModel {
pub(crate) fn new() -> Self { pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let state = match has_or_import_auth_credentials() { let (tx, rx) = async_channel::bounded(64);
Ok(()) => ChatGPTAuthState::Connected, *RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
Err(error) => { ctx.spawn_stream_local(rx, |model, reply, ctx| model.resolve(reply, ctx), |_, _| {});
log::debug!( let record = ctx.secure_storage().read_value(AUTH_STORAGE_KEY).ok()
"[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}" .and_then(|json| serde_json::from_str::<AuthRecord>(&json).ok())
); .filter(|record| record.credentials(now()).is_ok() || record.can_refresh());
let state = if record.is_some() {
ChatGPTAuthState::Connected
} else {
ChatGPTAuthState::NotConnected ChatGPTAuthState::NotConnected
}
}; };
Self { state } Self {
state,
record,
refresh: RefreshCoordinator::default(),
sign_in_generation: 0,
sign_in_cancel: None,
}
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn new_for_test() -> Self { pub(crate) fn new_for_test() -> Self {
Self { Self {
state: ChatGPTAuthState::NotConnected, state: ChatGPTAuthState::NotConnected,
record: None,
refresh: RefreshCoordinator::default(),
sign_in_generation: 0,
sign_in_cancel: None,
} }
} }
@@ -72,22 +97,26 @@ impl ChatGPTAuthModel {
&self.state &self.state
} }
/// Reuses persisted credentials when possible, otherwise starts browser OAuth with PKCE. fn begin_sign_in(&mut self) -> Option<u64> {
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
if matches!( if matches!(
self.state, self.state,
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
) { ) {
return; return None;
} }
if has_or_import_auth_credentials().is_ok() { self.refresh.invalidate();
self.state = ChatGPTAuthState::Connected; self.sign_in_generation += 1;
ctx.emit(ChatGPTAuthModelEvent::StateChanged); Some(self.sign_in_generation)
return;
} }
let (redirect_uri, callback_rx) = match start_oauth_callback_server() { /// Explicit connection always starts a fresh browser OAuth flow with PKCE.
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
let Some(generation) = self.begin_sign_in() else {
return;
};
let cancelled = Arc::new(AtomicBool::new(false));
let (redirect_uri, callback_rx) = match start_oauth_callback_server(cancelled.clone()) {
Ok(server) => server, Ok(server) => server,
Err(error) => { Err(error) => {
self.fail(error, ctx); self.fail(error, ctx);
@@ -106,8 +135,8 @@ impl ChatGPTAuthModel {
} }
}; };
self.state = ChatGPTAuthState::AwaitingBrowser; self.sign_in_cancel = Some(cancelled);
ctx.emit(ChatGPTAuthModelEvent::StateChanged); self.set_state(ChatGPTAuthState::AwaitingBrowser, ctx);
ctx.open_url(authorize_url.as_str()); ctx.open_url(authorize_url.as_str());
let _ = ctx.spawn( let _ = ctx.spawn(
@@ -117,6 +146,9 @@ impl ChatGPTAuthModel {
})? })?
}, },
move |model, callback, ctx| { move |model, callback, ctx| {
if model.sign_in_generation != generation {
return;
}
let callback = match callback { let callback = match callback {
Ok(callback) => callback, Ok(callback) => callback,
Err(error) => { Err(error) => {
@@ -129,19 +161,27 @@ impl ChatGPTAuthModel {
return; return;
} }
model.state = ChatGPTAuthState::ExchangingToken; model.set_state(ChatGPTAuthState::ExchangingToken, ctx);
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
let _ = ctx.spawn( let _ = ctx.spawn(
async move { async move {
exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri) exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri)
.await .await
}, },
|model, result, ctx| match result { move |model, result, ctx| {
Ok(()) => { if model.sign_in_generation != generation {
model.state = ChatGPTAuthState::Connected; return;
ctx.emit(ChatGPTAuthModelEvent::StateChanged); }
match result.and_then(|record| {
save_auth_record(&record, ctx)?;
Ok(record)
}) {
Ok(record) => {
model.record = Some(record);
model.sign_in_cancel = None;
model.set_state(ChatGPTAuthState::Connected, ctx);
} }
Err(error) => model.fail(error, ctx), Err(error) => model.fail(error, ctx),
}
}, },
); );
}, },
@@ -149,9 +189,78 @@ impl ChatGPTAuthModel {
} }
fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) { fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) {
self.state = ChatGPTAuthState::Failed(error); if let Some(cancelled) = self.sign_in_cancel.take() {
cancelled.store(true, Ordering::SeqCst);
}
self.set_state(ChatGPTAuthState::Failed(error), ctx);
}
fn set_state(&mut self, state: ChatGPTAuthState, ctx: &mut ModelContext<Self>) {
if self.state != state {
self.state = state;
ctx.emit(ChatGPTAuthModelEvent::StateChanged); ctx.emit(ChatGPTAuthModelEvent::StateChanged);
} }
}
pub(crate) fn cancel_sign_in(&mut self, ctx: &mut ModelContext<Self>) {
if let Some(cancelled) = self.sign_in_cancel.take() {
cancelled.store(true, Ordering::SeqCst);
self.sign_in_generation += 1;
self.refresh.invalidate();
self.set_state(ChatGPTAuthState::NotConnected, ctx);
}
}
fn resolve(&mut self, reply: Reply, ctx: &mut ModelContext<Self>) {
if reply.is_canceled() {
return;
}
if matches!(self.state, ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken) {
let _ = reply.send(Err("ChatGPT sign-in is in progress.".to_owned()));
return;
}
let Some(record) = self.record.as_ref() else {
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
return;
};
if let Ok(credentials) = record.credentials(now()) {
let _ = reply.send(Ok(credentials));
return;
}
if !record.can_refresh() {
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
self.fail(SIGN_IN_REQUIRED.to_owned(), ctx);
return;
}
let record = record.clone();
let Some(generation) = self.refresh.begin(reply) else {
return;
};
let _ = ctx.spawn(
async move { refresh_tokens(&record).await },
move |model, result, ctx| {
let completed = model.refresh.complete(generation, result, |record| {
save_auth_record(record, ctx)
});
match completed {
None => {}
Some(Ok(record)) => {
model.record = Some(record);
model.set_state(ChatGPTAuthState::Connected, ctx);
}
Some(Err(error)) => {
if error.reauthenticate {
model.record = None;
if save_auth_record(&AuthRecord::default(), ctx).is_err() {
log::warn!("[chatgpt/auth] Could not persist reconnect-required state");
}
}
model.fail(error.message, ctx);
}
}
},
);
}
} }
impl Entity for ChatGPTAuthModel { impl Entity for ChatGPTAuthModel {
@@ -194,6 +303,7 @@ fn random_hex_string(byte_count: usize) -> String {
} }
fn start_oauth_callback_server( fn start_oauth_callback_server(
cancelled: Arc<AtomicBool>,
) -> Result<(String, oneshot::Receiver<Result<OAuthCallback, String>>), String> { ) -> Result<(String, oneshot::Receiver<Result<OAuthCallback, String>>), String> {
let mut bind_errors = Vec::new(); let mut bind_errors = Vec::new();
let mut bound = None; let mut bound = None;
@@ -222,6 +332,10 @@ fn start_oauth_callback_server(
.spawn(move || { .spawn(move || {
let deadline = Instant::now() + CALLBACK_TIMEOUT; let deadline = Instant::now() + CALLBACK_TIMEOUT;
loop { loop {
if cancelled.load(Ordering::SeqCst) {
let _ = callback_tx.send(Err("ChatGPT sign-in was cancelled.".to_owned()));
return;
}
match listener.accept() { match listener.accept() {
Ok((stream, _)) => { Ok((stream, _)) => {
let _ = callback_tx.send(handle_callback_connection(stream)); let _ = callback_tx.send(handle_callback_connection(stream));
@@ -323,26 +437,19 @@ fn parse_callback_request_line(request_line: &str) -> Result<OAuthCallback, Stri
let mut code = None; let mut code = None;
let mut state = None; let mut state = None;
let mut oauth_error = None; let mut oauth_error = false;
let mut error_description = None;
for (key, value) in url.query_pairs() { for (key, value) in url.query_pairs() {
match key.as_ref() { match key.as_ref() {
"code" if !value.is_empty() => code = Some(value.into_owned()), "code" if !value.is_empty() => code = Some(value.into_owned()),
"state" if !value.is_empty() => state = Some(value.into_owned()), "state" if !value.is_empty() => state = Some(value.into_owned()),
"error" if !value.is_empty() => oauth_error = Some(value.into_owned()), "error" if !value.is_empty() => oauth_error = true,
"error_description" if !value.is_empty() => {
error_description = Some(value.into_owned())
}
"code" | "state" | "error" | "error_description" => {} "code" | "state" | "error" | "error_description" => {}
_ => {} _ => {}
} }
} }
if let Some(error) = oauth_error { if oauth_error {
return Err(format!( return Err("ChatGPT browser authorization failed. Please try signing in again.".to_owned());
"ChatGPT authorization failed: {error} ({})",
error_description.as_deref().unwrap_or("no description")
));
} }
Ok(OAuthCallback { Ok(OAuthCallback {
code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?, code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?,
@@ -358,157 +465,101 @@ fn callback_response_page(title: &str, message: &str, is_error: bool) -> String
.replace("__ACCENT__", accent) .replace("__ACCENT__", accent)
} }
#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) use galaxy_agent_rig::ChatGPTSubscriptionCredentials as ChatGPTAuthCredentials;
pub(crate) struct ChatGPTAuthCredentials {
pub(crate) access_token: String,
pub(crate) account_id: Option<String>,
}
/// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file. pub(crate) fn credential_resolver() -> ChatGPTCredentialResolver {
/// Returns `Ok(())` if credentials were found and successfully imported. Arc::new(|| Box::pin(load_or_refresh_auth_credentials()))
pub(crate) fn import_codex_credentials() -> Result<(), String> {
let codex_path = codex_auth_file_path().ok_or("Cannot determine codex auth path")?;
let bytes = std::fs::read(&codex_path).map_err(|e| format!("{e}"))?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{e}"))?;
let tokens = doc.get("tokens").ok_or("No tokens object")?;
let access_token = tokens
.get("access_token")
.and_then(|value| value.as_str())
.filter(|token| !token.is_empty());
let refresh_token = tokens
.get("refresh_token")
.and_then(|value| value.as_str())
.filter(|token| !token.is_empty())
.map(ToOwned::to_owned);
if access_token.is_none() && refresh_token.is_none() {
return Err("Codex auth file does not contain an access or refresh token".to_string());
}
let expires_at = access_token.and_then(extract_expiration_timestamp);
let id_token = tokens
.get("id_token")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let account_id = tokens
.get("account_id")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
.or_else(|| extract_account_id(id_token.as_deref()))
.or_else(|| extract_account_id(access_token));
let record = AuthRecord {
access_token: access_token.map(ToOwned::to_owned),
refresh_token,
id_token,
expires_at,
account_id,
};
write_auth_file(&record)
} }
pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> { pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
if let Ok(credentials) = load_auth_credentials() { let (tx, rx) = oneshot::channel();
return Ok(credentials); RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap()
.as_ref().ok_or("ChatGPT authorization service is unavailable.")?
.try_send(tx).map_err(|_| "ChatGPT authorization service is busy or unavailable.")?;
match futures::future::select(
Box::pin(rx),
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
).await {
futures::future::Either::Left((result, _)) => {
result.map_err(|_| "ChatGPT authorization service stopped.".to_owned())?
} }
futures::future::Either::Right(_) => {
let _ = import_codex_credentials(); Err("ChatGPT credential refresh timed out. Retry or sign in again.".to_owned())
if let Ok(credentials) = load_auth_credentials() {
return Ok(credentials);
} }
galaxy_agent_rig::refresh_chatgpt_subscription_credentials().await?;
load_auth_credentials()
}
fn has_or_import_auth_credentials() -> Result<(), String> {
if has_auth_credentials() {
return Ok(());
}
import_codex_credentials()?;
if has_auth_credentials() {
Ok(())
} else {
Err("ChatGPT credentials are missing an access or refresh token.".to_string())
} }
} }
fn has_auth_credentials() -> bool { #[derive(Clone, Debug)]
let Some(path) = auth_file_path() else { struct AuthFailure {
return false; message: String,
}; reauthenticate: bool,
let Ok(bytes) = std::fs::read(path) else {
return false;
};
let Ok(record) = serde_json::from_slice::<AuthRecord>(&bytes) else {
return false;
};
record
.refresh_token
.as_deref()
.is_some_and(|token| !token.trim().is_empty())
|| record.access_token.as_deref().is_some_and(|token| {
!token.trim().is_empty()
&& !token_is_expired(
record
.expires_at
.or_else(|| extract_expiration_timestamp(token)),
)
})
} }
fn load_auth_credentials() -> Result<ChatGPTAuthCredentials, String> { #[derive(Default)]
let path = auth_file_path().ok_or("Cannot determine ChatGPT auth file path")?; struct RefreshCoordinator {
let bytes = std::fs::read(&path) generation: u64,
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?; active: Option<(u64, Vec<Reply>)>,
let record: AuthRecord = serde_json::from_slice(&bytes) }
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
let access_token = record
.access_token
.as_deref()
.filter(|token| !token.trim().is_empty())
.ok_or("ChatGPT auth file does not contain an access token")?;
let expires_at = record impl RefreshCoordinator {
.expires_at fn begin(&mut self, reply: Reply) -> Option<u64> {
.or_else(|| extract_expiration_timestamp(access_token)); if let Some((_, replies)) = &mut self.active {
if token_is_expired(expires_at) { replies.push(reply);
return Err("ChatGPT access token is expired".to_string()); return None;
}
self.generation += 1;
self.active = Some((self.generation, vec![reply]));
Some(self.generation)
} }
let account_id = record fn invalidate(&mut self) {
.account_id self.generation += 1;
.clone() if let Some((_, replies)) = self.active.take() {
.filter(|account_id| !account_id.trim().is_empty()) for reply in replies {
.or_else(|| extract_account_id(record.id_token.as_deref())) let _ = reply.send(Err("ChatGPT session was replaced. Please sign in again.".to_owned()));
.or_else(|| extract_account_id(Some(access_token))); }
}
}
Ok(ChatGPTAuthCredentials { fn complete(
access_token: access_token.to_string(), &mut self,
account_id, generation: u64,
}) result: Result<AuthRecord, AuthFailure>,
} persist: impl FnOnce(&AuthRecord) -> Result<(), String>,
) -> Option<Result<AuthRecord, AuthFailure>> {
fn codex_auth_file_path() -> Option<std::path::PathBuf> { if self.active.as_ref().is_none_or(|(active, _)| *active != generation) {
if let Some(codex_home) = std::env::var_os("CODEX_HOME") { return None;
return Some(std::path::PathBuf::from(codex_home).join("auth.json")); }
let (_, replies) = self.active.take()?;
let result = result.and_then(|record| {
let credentials = record.access_context().map_err(|message| AuthFailure {
message, reauthenticate: true,
})?;
persist(&record).map_err(|_| AuthFailure {
message: "Could not securely save refreshed ChatGPT credentials. Please sign in again.".to_owned(),
reauthenticate: true,
})?;
Ok((record, credentials))
});
for reply in replies {
let response = result.as_ref().map(|(_, credentials)| credentials.clone())
.map_err(|error| error.message.clone());
let _ = reply.send(response);
}
Some(result.map(|(record, _)| record))
} }
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
} }
fn token_is_expired(expires_at: Option<i64>) -> bool { fn now() -> i64 {
let now = std::time::SystemTime::now() std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64).unwrap_or_default()
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default();
expires_at.is_none_or(|expires_at| now >= expires_at - 60)
} }
async fn exchange_code_for_tokens( async fn exchange_code_for_tokens(
code: &str, code: &str,
code_verifier: &str, code_verifier: &str,
redirect_uri: &str, redirect_uri: &str,
) -> Result<(), String> { ) -> Result<AuthRecord, String> {
let body = url::form_urlencoded::Serializer::new(String::new()) let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "authorization_code") .append_pair("grant_type", "authorization_code")
.append_pair("client_id", CHATGPT_CLIENT_ID) .append_pair("client_id", CHATGPT_CLIENT_ID)
@@ -516,7 +567,34 @@ async fn exchange_code_for_tokens(
.append_pair("redirect_uri", redirect_uri) .append_pair("redirect_uri", redirect_uri)
.append_pair("code_verifier", code_verifier) .append_pair("code_verifier", code_verifier)
.finish(); .finish();
let response = reqwest::Client::new() let tokens = token_request(body).await.map_err(|error| error.message)?;
record_from_tokens(tokens, None, now()).map_err(|error| error.message)
}
async fn refresh_tokens(previous: &AuthRecord) -> Result<AuthRecord, AuthFailure> {
let refresh_token = previous.refresh_token.as_deref().filter(|token| !token.trim().is_empty())
.ok_or_else(|| AuthFailure {
message: SIGN_IN_REQUIRED.to_owned(),
reauthenticate: true,
})?;
let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "refresh_token")
.append_pair("client_id", CHATGPT_CLIENT_ID)
.append_pair("refresh_token", refresh_token)
.finish();
let tokens = token_request(body).await?;
record_from_tokens(tokens, Some(previous), now())
}
async fn token_request(body: String) -> Result<TokenResponse, AuthFailure> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build().map_err(|_| AuthFailure {
message: "Could not create the ChatGPT authorization client.".to_owned(),
reauthenticate: false,
})?;
let response = client
.post(CHATGPT_TOKEN_URL) .post(CHATGPT_TOKEN_URL)
.header( .header(
reqwest::header::CONTENT_TYPE, reqwest::header::CONTENT_TYPE,
@@ -525,67 +603,83 @@ async fn exchange_code_for_tokens(
.body(body) .body(body)
.send() .send()
.await .await
.map_err(|error| format!("ChatGPT token exchange request failed: {error}"))?; .map_err(|_| AuthFailure {
message: "ChatGPT authorization request failed. Retry the connection.".to_owned(),
let status = response.status(); reauthenticate: false,
if !status.is_success() { })?;
let body = response.text().await.unwrap_or_default(); let status = response.status().as_u16();
return Err(format!("ChatGPT token exchange failed: {status} {body}")); let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| AuthFailure {
message: "Could not read the ChatGPT authorization response.".to_owned(),
reauthenticate: (200..300).contains(&status),
})?;
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
return Err(AuthFailure {
message: "ChatGPT authorization returned an oversized response.".to_owned(),
reauthenticate: (200..300).contains(&status),
});
} }
let tokens: TokenResponse = response bytes.extend_from_slice(&chunk);
.json() }
.await parse_token_response(status, &bytes)
.map_err(|error| format!("Could not parse the ChatGPT token response: {error}"))?; }
fn parse_token_response(status: u16, bytes: &[u8]) -> Result<TokenResponse, AuthFailure> {
if (200..300).contains(&status) {
return serde_json::from_slice(bytes).map_err(|_| AuthFailure {
message: "ChatGPT returned invalid credentials. Please sign in again.".to_owned(),
reauthenticate: true,
});
}
let body = serde_json::from_slice::<serde_json::Value>(bytes).unwrap_or_default();
let code = body.get("error").and_then(|error| {
error.as_str().or_else(|| error.get("code").and_then(|code| code.as_str()))
});
let reauthenticate = matches!(status, 401 | 403)
|| (status == 400 && matches!(code,
Some("invalid_grant" | "refresh_token_reused" | "refresh_token_expired" | "refresh_token_invalid")
));
let message = if reauthenticate {
format!("ChatGPT authorization expired or was rejected (HTTP {status}). Please sign in again.")
} else {
format!("ChatGPT authorization failed (HTTP {status}). Retry the connection.")
};
Err(AuthFailure { message, reauthenticate })
}
fn record_from_tokens(
tokens: TokenResponse,
previous: Option<&AuthRecord>,
now: i64,
) -> Result<AuthRecord, AuthFailure> {
let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| { let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| {
tokens.expires_in.map(|expires_in| { tokens.expires_in.and_then(|expires| i64::try_from(expires).ok())
std::time::SystemTime::now() .map(|expires| now.saturating_add(expires))
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default()
+ expires_in as i64
})
}); });
let account_id = extract_account_id(tokens.id_token.as_deref()) let account_id = extract_account_id(tokens.id_token.as_deref())
.or_else(|| extract_account_id(Some(&tokens.access_token))); .or_else(|| extract_account_id(Some(&tokens.access_token)))
write_auth_file(&AuthRecord { .or_else(|| previous.and_then(|record| record.account_id.clone()));
let record = AuthRecord {
access_token: Some(tokens.access_token), access_token: Some(tokens.access_token),
refresh_token: tokens.refresh_token, refresh_token: tokens.refresh_token.or_else(|| previous.and_then(|record| record.refresh_token.clone())),
id_token: tokens.id_token, id_token: tokens.id_token.or_else(|| previous.and_then(|record| record.id_token.clone())),
expires_at, expires_at,
account_id, account_id,
}) };
record.credentials(now).map_err(|_| AuthFailure {
message: "ChatGPT returned invalid or expired credentials. Please sign in again.".to_owned(),
reauthenticate: true,
})?;
Ok(record)
} }
fn write_auth_file(record: &AuthRecord) -> Result<(), String> { fn save_auth_record(record: &AuthRecord, ctx: &galaxyui::AppContext) -> Result<(), String> {
let path = auth_file_path().ok_or("Cannot determine auth file path")?; let json = serde_json::to_string(record)
if let Some(parent) = path.parent() { .map_err(|_| "Could not encode ChatGPT credentials.".to_owned())?;
std::fs::create_dir_all(parent) ctx.secure_storage().write_value_with_owner_only_fallback(AUTH_STORAGE_KEY, &json)
.map_err(|e| format!("Failed to create auth directory: {e}"))?; .map_err(|_| "Could not securely save ChatGPT credentials. Please sign in again.".to_owned())
}
let json =
serde_json::to_vec_pretty(record).map_err(|e| format!("Failed to serialize auth: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Failed to write auth file: {e}"))?;
Ok(())
}
fn auth_file_path() -> Option<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var_os("APPDATA").map(|d| {
std::path::PathBuf::from(d)
.join("chatgpt")
.join("auth.json")
})
}
#[cfg(not(target_os = "windows"))]
{
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})
.map(|d| d.join("chatgpt").join("auth.json"))
}
} }
fn extract_expiration_timestamp(token: &str) -> Option<i64> { fn extract_expiration_timestamp(token: &str) -> Option<i64> {
@@ -633,7 +727,7 @@ struct TokenResponse {
expires_in: Option<u64>, expires_in: Option<u64>,
} }
#[derive(serde::Deserialize, serde::Serialize)] #[derive(Clone, Default, serde::Deserialize, serde::Serialize)]
struct AuthRecord { struct AuthRecord {
access_token: Option<String>, access_token: Option<String>,
refresh_token: Option<String>, refresh_token: Option<String>,
@@ -641,3 +735,41 @@ struct AuthRecord {
expires_at: Option<i64>, expires_at: Option<i64>,
account_id: Option<String>, account_id: Option<String>,
} }
impl std::fmt::Debug for AuthRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTAuthRecord")
.field("credentials", &"<redacted>")
.field("expires_at", &self.expires_at)
.finish()
}
}
impl AuthRecord {
fn can_refresh(&self) -> bool {
self.refresh_token.as_deref().is_some_and(|token| !token.trim().is_empty())
}
fn access_context(&self) -> Result<ChatGPTAuthCredentials, String> {
let access_token = self.access_token.as_deref().filter(|token| !token.trim().is_empty())
.ok_or_else(|| SIGN_IN_REQUIRED.to_owned())?;
let account_id = self.account_id.clone().filter(|id| !id.trim().is_empty())
.or_else(|| extract_account_id(self.id_token.as_deref()))
.or_else(|| extract_account_id(Some(access_token)));
Ok(ChatGPTAuthCredentials { access_token: access_token.to_owned(), account_id })
}
fn credentials(&self, now: i64) -> Result<ChatGPTAuthCredentials, String> {
let expiry = self.expires_at.or_else(|| {
self.access_token.as_deref().and_then(extract_expiration_timestamp)
});
if expiry.is_none_or(|expiry| now >= expiry.saturating_sub(60)) {
return Err(SIGN_IN_REQUIRED.to_owned());
}
self.access_context()
}
}
#[cfg(test)]
#[path = "chatgpt_auth_tests.rs"]
mod tests;
+171
View File
@@ -0,0 +1,171 @@
use std::cell::Cell;
use futures::FutureExt;
use super::*;
fn record(access: &str, refresh: &str) -> AuthRecord {
AuthRecord {
access_token: Some(access.to_owned()),
refresh_token: Some(refresh.to_owned()),
id_token: None,
expires_at: Some(10_000),
account_id: Some("test-account".to_owned()),
}
}
#[test]
fn concurrent_requests_share_one_refresh_and_wait_for_persistence() {
let mut coordinator = RefreshCoordinator::default();
let (first_tx, mut first_rx) = oneshot::channel();
let (second_tx, mut second_rx) = oneshot::channel();
let generation = coordinator.begin(first_tx).unwrap();
assert_eq!(coordinator.begin(second_tx), None);
let saved = Cell::new(false);
let result = coordinator.complete(generation, Ok(record("rotated-access", "rotated-refresh")), |record| {
assert_eq!(first_rx.try_recv().unwrap(), None);
assert_eq!(second_rx.try_recv().unwrap(), None);
assert_eq!(record.refresh_token.as_deref(), Some("rotated-refresh"));
saved.set(true);
Ok(())
});
assert!(result.unwrap().is_ok());
assert!(saved.get());
let first = first_rx.now_or_never().unwrap().unwrap().unwrap();
let second = second_rx.now_or_never().unwrap().unwrap().unwrap();
assert_eq!(first, second);
assert_eq!(first.access_token, "rotated-access");
}
#[test]
fn failed_persistence_never_publishes_rotated_credentials() {
let mut coordinator = RefreshCoordinator::default();
let (tx, rx) = oneshot::channel();
let generation = coordinator.begin(tx).unwrap();
let result = coordinator.complete(generation, Ok(record("rotated", "new-refresh")), |_| {
Err("storage unavailable".to_owned())
}).unwrap().unwrap_err();
assert!(result.reauthenticate);
assert!(rx.now_or_never().unwrap().unwrap().is_err());
}
#[test]
fn replaced_sign_in_rejects_late_refresh_without_overwriting_new_session() {
let mut coordinator = RefreshCoordinator::default();
let (old_tx, old_rx) = oneshot::channel();
let old = coordinator.begin(old_tx).unwrap();
coordinator.invalidate();
assert!(old_rx.now_or_never().unwrap().unwrap().is_err());
let (new_tx, new_rx) = oneshot::channel();
let current = coordinator.begin(new_tx).unwrap();
assert_ne!(old, current);
assert!(coordinator.complete(old, Ok(record("stale", "stale-refresh")), |_| {
panic!("A stale refresh must not write credentials")
}).is_none());
assert!(coordinator.complete(current, Ok(record("current", "current-refresh")), |_| Ok(()))
.unwrap().is_ok());
assert_eq!(new_rx.now_or_never().unwrap().unwrap().unwrap().access_token, "current");
}
#[test]
fn reconnect_starts_a_new_generation_even_with_a_cached_refresh_token() {
let mut model = ChatGPTAuthModel::new_for_test();
model.record = Some(record("old-access", "already-used-refresh"));
model.state = ChatGPTAuthState::Connected;
let first = model.begin_sign_in().unwrap();
model.state = ChatGPTAuthState::AwaitingBrowser;
assert_eq!(model.begin_sign_in(), None);
model.state = ChatGPTAuthState::Failed("refresh_token_reused".to_owned());
let replacement = model.begin_sign_in().unwrap();
assert!(replacement > first);
}
#[test]
fn rotated_tokens_replace_previous_tokens_and_omitted_refresh_tokens_are_preserved() {
let previous = record("old-access", "old-refresh");
for refresh in [None, Some("new-refresh".to_owned())] {
let result = record_from_tokens(TokenResponse {
access_token: "new-access".to_owned(),
refresh_token: refresh.clone(),
id_token: None,
expires_in: Some(3600),
}, Some(&previous), 100).unwrap();
assert_eq!(result.access_token.as_deref(), Some("new-access"));
assert_eq!(result.refresh_token, refresh.or_else(|| Some("old-refresh".to_owned())));
assert_eq!(result.expires_at, Some(3700));
assert_eq!(result.account_id.as_deref(), Some("test-account"));
}
}
#[test]
fn rejected_refresh_tokens_require_reauthentication_without_echoing_tokens() {
for code in ["refresh_token_reused", "invalid_grant", "refresh_token_expired"] {
let body = serde_json::to_vec(&serde_json::json!({
"error": {"code": code, "message": "sensitive-refresh-token"}
})).unwrap();
let error = parse_token_response(401, &body).err().unwrap();
assert!(error.reauthenticate);
assert!(error.message.contains("sign in"));
assert!(!error.message.contains("sensitive-refresh-token"));
}
let error = parse_token_response(400, br#"{"error":"invalid_grant"}"#).err().unwrap();
assert!(error.reauthenticate);
}
#[test]
fn transient_refresh_failure_preserves_the_session_and_does_not_save_rejected_data() {
let error = parse_token_response(503, br#"{"message":"sensitive-data"}"#).err().unwrap();
assert!(!error.reauthenticate);
assert!(!error.message.contains("sensitive-data"));
let mut coordinator = RefreshCoordinator::default();
let (tx, rx) = oneshot::channel();
let generation = coordinator.begin(tx).unwrap();
let result = coordinator.complete(generation, Err(error), |_| {
panic!("Transient failures must not overwrite stored credentials")
}).unwrap().unwrap_err();
assert!(!result.reauthenticate);
assert!(rx.now_or_never().unwrap().unwrap().is_err());
}
#[test]
fn credential_debug_output_is_redacted_and_expiry_has_a_refresh_margin() {
let credentials = ChatGPTAuthCredentials {
access_token: "sensitive-access-token".to_owned(),
account_id: Some("account".to_owned()),
};
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
let credentials = record("sensitive-access-token", "sensitive-refresh-token");
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
assert!(!format!("{credentials:?}").contains("sensitive-refresh-token"));
assert!(credentials.credentials(100).is_ok());
assert!(credentials.credentials(9940).is_err());
assert!(AuthRecord::default().credentials(100).is_err());
}
#[test]
fn invalid_token_payloads_are_not_saved_or_exposed() {
assert!(parse_token_response(200, b"invalid-sensitive-data").is_err());
assert!(record_from_tokens(TokenResponse {
access_token: String::new(),
refresh_token: Some("refresh".to_owned()),
id_token: None,
expires_in: Some(3600),
}, None, 100).is_err());
assert!(record_from_tokens(TokenResponse {
access_token: "opaque-token-without-expiry".to_owned(),
refresh_token: None,
id_token: None,
expires_in: None,
}, None, 100).is_err());
}
#[test]
fn browser_authorization_keeps_pkce_and_state_validation() {
let url = build_authorize_url("http://localhost:1455/auth/callback", "challenge", "state").unwrap();
let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
assert_eq!(params.get("code_challenge_method").map(|v| v.as_ref()), Some("S256"));
assert_eq!(params.get("state").map(|v| v.as_ref()), Some("state"));
let callback = parse_callback_request_line("GET /auth/callback?code=code&state=state HTTP/1.1").unwrap();
assert_eq!(callback.state, "state");
assert!(parse_callback_request_line("GET /auth/callback?code=code HTTP/1.1").is_err());
}
+24
View File
@@ -246,9 +246,20 @@ pub struct ConversationDetailsData {
harness: Option<Harness>, harness: Option<Harness>,
/// Error details displayed when the API call to fetch run data failed. /// Error details displayed when the API call to fetch run data failed.
fetch_error: Option<TaskFetchError>, fetch_error: Option<TaskFetchError>,
/// Local history resumes the saved session instead of offering a hosted fork.
local_history: bool,
} }
impl ConversationDetailsData { impl ConversationDetailsData {
pub fn for_local_history(mut self) -> Self {
self.local_history = true;
self
}
fn is_local_history(&self) -> bool {
self.local_history
}
fn directory_for_task(task: &AmbientAgentTask, app: &AppContext) -> Option<String> { fn directory_for_task(task: &AmbientAgentTask, app: &AppContext) -> Option<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app); let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation_id = history_model let conversation_id = history_model
@@ -358,6 +369,7 @@ impl ConversationDetailsData {
skill_spec: None, skill_spec: None,
harness, harness,
fetch_error: None, fetch_error: None,
local_history: false,
} }
} }
@@ -422,6 +434,7 @@ impl ConversationDetailsData {
skill_spec, skill_spec,
harness, harness,
fetch_error: None, fetch_error: None,
local_history: false,
} }
} }
@@ -494,6 +507,7 @@ impl ConversationDetailsData {
skill_spec, skill_spec,
harness, harness,
fetch_error: None, fetch_error: None,
local_history: false,
}; };
} }
@@ -521,6 +535,7 @@ impl ConversationDetailsData {
skill_spec: None, skill_spec: None,
harness, harness,
fetch_error: None, fetch_error: None,
local_history: false,
} }
} }
@@ -552,6 +567,7 @@ impl ConversationDetailsData {
skill_spec: None, skill_spec: None,
harness: None, harness: None,
fetch_error, fetch_error,
local_history: false,
} }
} }
@@ -594,6 +610,7 @@ impl ConversationDetailsData {
skill_spec: None, skill_spec: None,
harness, harness,
fetch_error: None, fetch_error: None,
local_history: false,
} }
} }
} }
@@ -752,6 +769,10 @@ impl ConversationDetailsPanel {
&self, &self,
app: &AppContext, app: &AppContext,
) -> Option<DetailsPanelLocalContinuationInfo> { ) -> Option<DetailsPanelLocalContinuationInfo> {
if self.data.is_local_history() {
// History's Open action restores the original local session directly.
return None;
}
if !AISettings::as_ref(app).is_any_ai_enabled(app) { if !AISettings::as_ref(app).is_any_ai_enabled(app) {
return None; return None;
} }
@@ -894,6 +915,9 @@ impl ConversationDetailsPanel {
open_action, open_action,
data.copy_link_url.clone(), data.copy_link_url.clone(),
); );
if data.is_local_history() {
config.fork_conversation_id = None;
}
if !status.as_ref().is_some_and(ConversationStatus::is_done) { if !status.as_ref().is_some_and(ConversationStatus::is_done) {
config.delete_conversation_id = None; config.delete_conversation_id = None;
} }
+90 -25
View File
@@ -118,7 +118,17 @@ impl CrosscheckReviewer {
// Spawn the reviewer request asynchronously // Spawn the reviewer request asynchronously
let conversation_id_for_task = conversation_id; let conversation_id_for_task = conversation_id;
ctx.spawn( ctx.spawn(
Self::invoke_reviewer(agent_output, model_id, provider_config), Self::invoke_reviewer(
agent_output,
model_id,
provider_config,
#[cfg(not(target_family = "wasm"))]
serde_json::json!({
"conversation_id": conversation_id,
"iteration": current_iteration,
"max_iterations": max,
}),
),
move |me, reviewer_response, ctx| { move |me, reviewer_response, ctx| {
me.handle_reviewer_response( me.handle_reviewer_response(
conversation_id_for_task, conversation_id_for_task,
@@ -164,6 +174,7 @@ impl CrosscheckReviewer {
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) { if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig { return ProviderConfig::OpenAI(OpenAIClientConfig {
kind: client_config.kind, kind: client_config.kind,
connection_identity: client_config.connection_identity.clone(),
base_url: client_config.base_url.clone(), base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(), api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(), project_id: client_config.project_id.clone(),
@@ -181,28 +192,14 @@ impl CrosscheckReviewer {
// Fall back to Bedrock via external config // Fall back to Bedrock via external config
if *settings.bedrock_enabled.value() { if *settings.bedrock_enabled.value() {
let auth_method = *settings.bedrock_auth_method.value();
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx); let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
let mut config = crate::ai::provider::client::BedrockClientConfig { let config = crate::ai::provider::client::BedrockClientConfig::from_settings(settings)
auth_method, .with_model_route(model_id, settings.bedrock_models.value())
profile: settings.bedrock_profile.value().clone(), .with_managed_credentials(
region: settings.bedrock_region.value().clone(), &api_key_manager.aws_credentials_refresh_strategy(),
access_key_id: settings.bedrock_access_key_id.value().clone(), api_key_manager.aws_credentials_state(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(), );
session_token: None, return ProviderConfig::Bedrock(config);
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
api_key_manager.aws_credentials_state()
{
config.auth_method = crate::settings::BedrockAuthMethod::StaticKeys;
config.access_key_id = credentials.access_key().to_string();
config.secret_access_key = credentials.secret_key().to_string();
config.session_token = credentials.session_token().map(|s| s.to_string());
}
return ProviderConfig::Bedrock(config.with_external_fallbacks());
} }
ProviderConfig::None ProviderConfig::None
@@ -219,6 +216,70 @@ impl CrosscheckReviewer {
agent_output: String, agent_output: String,
model_id: String, model_id: String,
provider_config: ProviderConfig, provider_config: ProviderConfig,
#[cfg(not(target_family = "wasm"))] mut activity_context: serde_json::Value,
) -> Result<String, String> {
#[cfg(not(target_family = "wasm"))]
let started_at = instant::Instant::now();
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
let effective_model = match &provider_config {
ProviderConfig::OpenAI(config) => config.model.as_deref().unwrap_or(&model_id),
ProviderConfig::Bedrock(_) | ProviderConfig::None => &model_id,
};
activity_context["model_id"] = serde_json::json!(effective_model);
activity_context["request_id"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
activity_context["snapshot_kind"] = serde_json::json!("reviewer_input");
#[derive(serde::Serialize)]
struct ReviewerInput<'a> {
system_prompt: &'a str,
user_prompt_prefix: &'a str,
agent_output: &'a str,
max_output_tokens: u32,
}
crate::ai::activity::record_serializable(
"crosscheck_model_request",
"Crosscheck reviewer request",
activity_context.clone(),
&ReviewerInput {
system_prompt: prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT,
user_prompt_prefix: "Please review the following agent output:\n\n---\n\n",
agent_output: &agent_output,
max_output_tokens: REVIEWER_MAX_OUTPUT_TOKENS,
},
);
}
let response = Self::invoke_reviewer_inner(agent_output, model_id, provider_config).await;
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
activity_context["elapsed_ms"] =
serde_json::json!(started_at.elapsed().as_millis() as u64);
// These compatibility helpers return text only, with no usage report.
activity_context["usage_reported"] = serde_json::json!(false);
match &response {
Ok(text) => crate::ai::activity::record_serializable(
"crosscheck_model_response",
"Crosscheck reviewer response",
activity_context,
text,
),
Err(error) => {
activity_context["error"] =
serde_json::json!(crate::ai::remote_logging::sanitize_error(error));
crate::ai::activity::record(
"crosscheck_model_error",
"Crosscheck reviewer failed",
activity_context,
);
}
}
}
response
}
async fn invoke_reviewer_inner(
agent_output: String,
model_id: String,
provider_config: ProviderConfig,
) -> Result<String, String> { ) -> Result<String, String> {
match provider_config { match provider_config {
ProviderConfig::OpenAI(config) ProviderConfig::OpenAI(config)
@@ -229,9 +290,9 @@ impl CrosscheckReviewer {
model: config.model.unwrap_or(model_id), model: config.model.unwrap_or(model_id),
reasoning_effort: config.reasoning_effort, reasoning_effort: config.reasoning_effort,
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)), max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
auth_file: None,
}, },
); )
.with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver());
runtime runtime
.complete_text( .complete_text(
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(), prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
@@ -268,7 +329,7 @@ impl CrosscheckReviewer {
let effective_model = config.model.unwrap_or(model_id); let effective_model = config.model.unwrap_or(model_id);
let request_body = serde_json::json!({ let mut request_body = serde_json::json!({
"model": effective_model, "model": effective_model,
"messages": [ "messages": [
{ {
@@ -286,6 +347,10 @@ impl CrosscheckReviewer {
"stream": false "stream": false
}); });
if let Some(effort) = config.reasoning_effort {
request_body["reasoning_effort"] = effort.into();
}
// Use a non-streaming request by calling the same endpoint but with stream: false // Use a non-streaming request by calling the same endpoint but with stream: false
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
+2 -2
View File
@@ -687,7 +687,7 @@ impl CustomRouterEditorView {
); );
let rules_copy = FormattedText::new([ let rules_copy = FormattedText::new([
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text( FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(
"Rules are custom prompts that describe when to use a specific model. Warp intelligently matches your tasks against these rules.", "Rules are custom prompts that describe when to use a specific model. Galaxy matches your tasks against these rules.",
)]), )]),
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text( FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(
"Rules are matched top to bottom — rules higher in the list take precedence over those below.", "Rules are matched top to bottom — rules higher in the list take precedence over those below.",
@@ -750,7 +750,7 @@ impl CustomRouterEditorView {
FormattedTextLine::Line(vec![ FormattedTextLine::Line(vec![
FormattedTextFragment::bold("Complexity-based"), FormattedTextFragment::bold("Complexity-based"),
FormattedTextFragment::plain_text( FormattedTextFragment::plain_text(
" routing chooses a model based on Warp's classification of the task's difficulty.", " routing chooses a model based on Galaxy's classification of the task's difficulty.",
), ),
]), ]),
FormattedTextLine::Line(vec![ FormattedTextLine::Line(vec![
+1
View File
@@ -126,6 +126,7 @@ impl CustomModelRouter {
base_model_name: name, base_model_name: name,
id: config_key.into(), id: config_key.into(),
reasoning_level: None, reasoning_level: None,
reasoning_model_id: None,
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
+2 -2
View File
@@ -618,12 +618,12 @@ impl AIDocumentModel {
) )
}) })
.ok_or_else(|| { .ok_or_else(|| {
format!("Plan document {ai_document_id} was not found in Warp Drive.") format!("Plan document {ai_document_id} was not found in Galaxy Drive.")
})?; })?;
let (sync_id, title, content) = notebook; let (sync_id, title, content) = notebook;
if sync_id.into_server().is_none() { if sync_id.into_server().is_none() {
return Err(format!( return Err(format!(
"Plan document {ai_document_id} is not backed by a saved Warp Drive notebook." "Plan document {ai_document_id} is not backed by a saved Galaxy Drive notebook."
)); ));
} }
+1
View File
@@ -1,3 +1,4 @@
pub mod ai_document_model; pub mod ai_document_model;
pub mod orchestration_config_block; pub mod orchestration_config_block;
pub(crate) mod plan_archival;
pub(in crate::ai) mod plan_publication; pub(in crate::ai) mod plan_publication;
+500
View File
@@ -0,0 +1,500 @@
//! Retain completed plans in a recoverable Drive folder after one week.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use chrono::{DateTime, Utc};
use galaxy_core::user_preferences::GetUserPreferences as _;
use galaxyui::r#async::Timer;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use serde::{Deserialize, Serialize};
use super::ai_document_model::{
AIDocumentId, AIDocumentModel, AIDocumentModelEvent, AIDocumentUpdateSource,
AIDocumentUserEditStatus,
};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{CloudObject, CloudObjectLocation, Space};
use crate::drive::CloudObjectTypeAndId;
use crate::local_object_repository::{
local_owner, LocalObjectRepository, LocalObjectRepositoryEvent,
};
use crate::server::ids::{ClientId, SyncId};
const PLAN_ARCHIVAL_KEY: &str = "CompletedPlanArchival";
const PLAN_FOLDER_NAME: &str = "Plans";
const ARCHIVE_FOLDER_NAME: &str = "Archive";
const SWEEP_INTERVAL: Duration = Duration::from_secs(5 * 60);
const ARCHIVE_AFTER_DAYS: i64 = 7;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CompletedPlan {
conversation_id: AIConversationId,
completed_at: DateTime<Utc>,
#[serde(default)]
archived_to: Option<SyncId>,
#[serde(default)]
original_folder: Option<SyncId>,
}
impl CompletedPlan {
fn is_due(&self, now: DateTime<Utc>) -> bool {
self.archived_to.is_none()
&& now.signed_duration_since(self.completed_at)
>= chrono::Duration::days(ARCHIVE_AFTER_DAYS)
}
}
/// Completion records are local preferences; notebook contents and folder
/// membership continue to use the normal SQLite-backed Drive repository.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PlanArchivalModel {
#[serde(default)]
completed_plans: HashMap<String, CompletedPlan>,
#[serde(default)]
reopened_plans: HashMap<String, ReopenedPlan>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ReopenedPlan {
conversation_id: AIConversationId,
/// The edited plan must reach a new agent exchange before the old task
/// list can be used as evidence of completion again.
saw_new_exchange: bool,
}
impl PlanArchivalModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let model = ctx
.private_user_preferences()
.read_value(PLAN_ARCHIVAL_KEY)
.unwrap_or_default()
.and_then(|json| Self::from_persisted(&json))
.unwrap_or_default();
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|model, _, event, ctx| match event {
BlocklistAIHistoryEvent::AppendedExchange { conversation_id, .. } => {
// Clear completion before a successor turn can start. A
// previously archived plan becomes visible again as well.
model.begin_exchange(*conversation_id, ctx);
}
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. } => {
model.sweep(ctx);
}
_ => {}
},
);
ctx.subscribe_to_model(&AIDocumentModel::handle(ctx), |model, _, event, ctx| {
match event {
AIDocumentModelEvent::DocumentUpdated {
document_id,
source: AIDocumentUpdateSource::User,
..
}
| AIDocumentModelEvent::DocumentUserEditStatusUpdated {
document_id,
status: AIDocumentUserEditStatus::Dirty,
} => model.reopen_document(*document_id, ctx),
AIDocumentModelEvent::DocumentUserEditStatusUpdated {
document_id,
status: AIDocumentUserEditStatus::UpToDate,
} => model.accept_document_edits(*document_id, ctx),
_ => {}
}
});
ctx.subscribe_to_model(&CloudModel::handle(ctx), |model, _, event, ctx| {
if matches!(event, CloudModelEvent::InitialLoadCompleted) {
model.sweep(ctx);
}
});
ctx.subscribe_to_model(&LocalObjectRepository::handle(ctx), |model, _, event, ctx| {
let LocalObjectRepositoryEvent::NotebookContentEdited { id } = event else {
return;
};
let document_id = CloudModel::as_ref(ctx)
.get_notebook(id)
.and_then(|notebook| notebook.model().ai_document_id);
if let Some(document_id) = document_id {
model.reopen_document(document_id, ctx);
}
});
// The first callback runs after singleton registration and session
// restoration. Periodic checks also catch provider cleanup occurring
// just after a successful status event, without delaying that cleanup.
ctx.spawn(async {}, |model, _, ctx| {
model.sweep(ctx);
model.schedule_sweep(ctx);
});
model
}
fn from_persisted(json: &str) -> Option<Self> {
serde_json::from_str(json).ok().or_else(|| {
// Preserve completion records written before reopened-plan state
// was added to the preference payload.
serde_json::from_str(json).ok().map(|completed_plans| Self {
completed_plans,
..Self::default()
})
})
}
fn schedule_sweep(&self, ctx: &mut ModelContext<Self>) {
ctx.spawn(
async { Timer::after(SWEEP_INTERVAL).await },
|model, _, ctx| {
model.sweep(ctx);
model.schedule_sweep(ctx);
},
);
}
fn sweep(&mut self, ctx: &mut ModelContext<Self>) {
let before = self.completed_plans.clone();
let reopened_before = self.reopened_plans.clone();
self.reopened_plans.retain(|document_id, record| {
!record.saw_new_exchange
|| AIDocumentId::try_from(document_id.as_str())
.ok()
.and_then(|id| AIDocumentModel::as_ref(ctx).get_current_document(&id))
.is_none_or(|document| document.user_edit_status.is_dirty())
});
let observations = {
let history = BlocklistAIHistoryModel::as_ref(ctx);
let documents = AIDocumentModel::as_ref(ctx);
let mut seen = HashSet::new();
history
.all_live_conversations()
.into_iter()
.chain(history.all_cleared_conversations())
.filter(|(_, conversation)| seen.insert(conversation.id()))
.map(|(_, conversation)| {
let mut document_ids = documents
.get_all_documents_for_conversation(conversation.id())
.into_iter()
.map(|(id, _)| id.to_string())
.collect::<HashSet<_>>();
document_ids.extend(conversation.artifacts().iter().filter_map(
|artifact| {
if let Artifact::Plan { document_uid, .. } = artifact {
Some(document_uid.clone())
} else {
None
}
},
));
(
conversation.id(),
document_ids,
completed_work_time(conversation),
)
})
.collect::<Vec<_>>()
};
for (conversation_id, document_ids, completed_at) in observations {
match completed_at {
Some(completed_at) => {
for document_id in document_ids {
self.observe_completion(document_id, conversation_id, completed_at);
}
}
None => self.resume_conversation(conversation_id, ctx),
}
}
// Persisted completion records also cover unloaded conversations.
// AppendedExchange clears them before later work starts. Do not use
// metadata.last_modified_at as work identity: checkpoint and title
// saves update that timestamp after the exchange has already finished.
let now = Utc::now();
let due = self
.completed_plans
.iter()
.filter(|(_, record)| record.is_due(now))
.map(|(document_id, _)| document_id.clone())
.collect::<Vec<_>>();
for document_id in due {
self.archive_plan(&document_id, ctx);
}
if before != self.completed_plans || reopened_before != self.reopened_plans {
self.persist(ctx);
}
}
fn observe_completion(
&mut self,
document_id: String,
conversation_id: AIConversationId,
completed_at: DateTime<Utc>,
) {
if self.reopened_plans.contains_key(&document_id) {
return;
}
if self
.completed_plans
.get(&document_id)
.is_some_and(|record| record.conversation_id == conversation_id)
{
return;
}
self.completed_plans.insert(
document_id,
CompletedPlan {
conversation_id,
completed_at,
archived_to: None,
original_folder: None,
},
);
}
fn archive_plan(&mut self, document_id: &str, ctx: &mut ModelContext<Self>) {
if self.reopened_plans.contains_key(document_id) {
return;
}
if let Some(id) = AIDocumentId::try_from(document_id).ok().filter(|id| {
AIDocumentModel::as_ref(ctx)
.get_current_document(id)
.is_some_and(|document| document.user_edit_status.is_dirty())
}) {
self.reopen_document(id, ctx);
return;
}
let notebook = CloudModel::as_ref(ctx)
.get_all_active_notebooks()
.find(|notebook| {
notebook.permissions.owner == local_owner()
&& notebook
.model()
.ai_document_id
.is_some_and(|id| id.to_string() == document_id)
})
.cloned();
let Some(notebook) = notebook else {
return;
};
let plans_folder = get_or_create_folder(PLAN_FOLDER_NAME, None, ctx);
let archive_folder = get_or_create_folder(ARCHIVE_FOLDER_NAME, Some(plans_folder), ctx);
let original_folder = notebook.metadata.folder_id;
if original_folder != Some(archive_folder)
&& !LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(notebook.id),
CloudObjectLocation::Folder(archive_folder),
ctx,
)
})
{
return;
}
if let Some(record) = self.completed_plans.get_mut(document_id) {
record.original_folder = original_folder.filter(|id| *id != archive_folder);
record.archived_to = Some(archive_folder);
}
}
fn resume_conversation(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let document_ids = self
.completed_plans
.iter()
.filter(|(_, record)| record.conversation_id == conversation_id)
.map(|(document_id, _)| document_id.clone())
.collect::<Vec<_>>();
if document_ids.is_empty() {
return;
}
for document_id in document_ids {
let Some(record) = self.completed_plans.remove(&document_id) else {
continue;
};
restore_archived_plan(&document_id, record, ctx);
}
self.persist(ctx);
}
fn begin_exchange(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
self.resume_conversation(conversation_id, ctx);
let mut changed = false;
self.reopened_plans.retain(|document_id, record| {
if record.conversation_id != conversation_id {
return true;
}
record.saw_new_exchange = true;
changed = true;
let document = AIDocumentId::try_from(document_id.as_str())
.ok()
.and_then(|id| AIDocumentModel::as_ref(ctx).get_current_document(&id));
document.is_none_or(|document| document.user_edit_status.is_dirty())
});
if changed {
self.persist(ctx);
}
}
fn reopen_document(&mut self, document_id: AIDocumentId, ctx: &mut ModelContext<Self>) {
let key = document_id.to_string();
let completed = self.completed_plans.remove(&key);
// Restored free-floating document views can have a synthetic owner.
// Prefer the real owner retained in the completion/reopening record.
let conversation_id = completed
.as_ref()
.map(|record| record.conversation_id)
.or_else(|| {
self.reopened_plans
.get(&key)
.map(|record| record.conversation_id)
})
.or_else(|| {
AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(&document_id)
});
if let Some(conversation_id) = conversation_id {
self.reopened_plans.insert(
key.clone(),
ReopenedPlan {
conversation_id,
saw_new_exchange: false,
},
);
}
if let Some(record) = completed {
restore_archived_plan(&key, record, ctx);
}
self.persist(ctx);
}
fn accept_document_edits(&mut self, document_id: AIDocumentId, ctx: &mut ModelContext<Self>) {
let key = document_id.to_string();
if self
.reopened_plans
.get(&key)
.is_some_and(|record| record.saw_new_exchange)
{
self.reopened_plans.remove(&key);
self.persist(ctx);
}
}
fn persist(&self, ctx: &mut ModelContext<Self>) {
match serde_json::to_string(self) {
Ok(json) => {
if let Err(error) = ctx
.private_user_preferences()
.write_value(PLAN_ARCHIVAL_KEY, json)
{
log::error!("Failed to persist completed plan retention: {error}");
}
}
Err(error) => log::error!("Failed to serialize completed plan retention: {error}"),
}
}
}
fn restore_archived_plan(
document_id: &str,
record: CompletedPlan,
ctx: &mut ModelContext<PlanArchivalModel>,
) {
let Some(archive_folder) = record.archived_to else {
return;
};
let notebook = CloudModel::as_ref(ctx)
.get_all_active_notebooks()
.find(|notebook| {
notebook.permissions.owner == local_owner()
&& notebook.metadata.folder_id == Some(archive_folder)
&& notebook
.model()
.ai_document_id
.is_some_and(|id| id.to_string() == document_id)
})
.cloned();
let Some(notebook) = notebook else {
return;
};
let original_folder = record.original_folder.filter(|id| {
let cloud_model = CloudModel::as_ref(ctx);
cloud_model.get_folder(id).is_some_and(|folder| {
folder.permissions.owner == local_owner() && !folder.is_trashed(cloud_model)
})
});
let destination = original_folder
.map(CloudObjectLocation::Folder)
.unwrap_or(CloudObjectLocation::Space(Space::Personal));
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(notebook.id),
destination,
ctx,
);
});
}
fn completed_work_time(conversation: &AIConversation) -> Option<DateTime<Utc>> {
if !matches!(conversation.status(), ConversationStatus::Success)
|| conversation.active_provider_run_json().is_some()
|| conversation.has_active_subagent()
|| !conversation
.active_todo_list()
.is_some_and(|list| list.is_finished())
{
return None;
}
conversation
.latest_exchange()
.and_then(|exchange| exchange.finish_time)
.map(|time| time.with_timezone(&Utc))
}
fn get_or_create_folder(
name: &str,
parent: Option<SyncId>,
ctx: &mut ModelContext<PlanArchivalModel>,
) -> SyncId {
if let Some(id) = CloudModel::as_ref(ctx)
.get_all_active_folders()
.find(|folder| {
folder.permissions.owner == local_owner()
&& folder.model().name == name
&& folder.metadata.folder_id == parent
})
.map(|folder| folder.id)
{
return id;
}
let id = SyncId::ClientId(ClientId::new());
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.create_folder_with_id(id, name.to_string(), parent, ctx);
});
id
}
impl Entity for PlanArchivalModel {
type Event = ();
}
impl SingletonEntity for PlanArchivalModel {}
#[cfg(test)]
#[path = "plan_archival_tests.rs"]
mod tests;
+417
View File
@@ -0,0 +1,417 @@
use std::sync::mpsc::sync_channel;
use ai::document::AIDocumentId;
use galaxyui::App;
use super::*;
use crate::notebooks::CloudNotebookModel;
use crate::persistence::ModelEvent;
use crate::test_util::settings::initialize_settings_for_tests;
fn completed_plan(completed_at: DateTime<Utc>) -> CompletedPlan {
CompletedPlan {
conversation_id: AIConversationId::new(),
completed_at,
archived_to: None,
original_folder: None,
}
}
#[test]
fn completed_plan_archives_after_seven_full_days_only() {
let now = Utc::now();
let plan = completed_plan(now - chrono::Duration::days(7));
assert!(!plan.is_due(now - chrono::Duration::milliseconds(1)));
assert!(plan.is_due(now));
assert!(!completed_plan(now + chrono::Duration::days(1)).is_due(now));
}
#[test]
fn archived_plan_deadline_and_recovery_location_survive_restart() {
let mut plan = completed_plan(Utc::now() - chrono::Duration::days(8));
plan.archived_to = Some(SyncId::ClientId(ClientId::new()));
plan.original_folder = Some(SyncId::ClientId(ClientId::new()));
let serialized = serde_json::to_string(&plan).unwrap();
let restored: CompletedPlan = serde_json::from_str(&serialized).unwrap();
assert_eq!(restored, plan);
assert!(!restored.is_due(Utc::now()));
}
#[test]
fn revisiting_a_completed_plan_preserves_its_original_deadline() {
let mut model = PlanArchivalModel {
completed_plans: HashMap::new(),
..Default::default()
};
let conversation_id = AIConversationId::new();
let completed_at = Utc::now() - chrono::Duration::days(6);
model.observe_completion("plan".to_string(), conversation_id, completed_at);
model.observe_completion("plan".to_string(), conversation_id, Utc::now());
assert_eq!(model.completed_plans["plan"].completed_at, completed_at);
}
#[test]
fn legacy_completed_plan_records_are_preserved() {
let record = completed_plan(Utc::now() - chrono::Duration::days(4));
let records = HashMap::from([("plan".to_string(), record.clone())]);
let json = serde_json::to_string(&records).unwrap();
let model = PlanArchivalModel::from_persisted(&json).unwrap();
assert_eq!(model.completed_plans["plan"], record);
assert!(model.reopened_plans.is_empty());
}
#[test]
fn dirty_and_reverted_plans_stay_visible_until_new_work_accepts_the_edits() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let documents = app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let document_id = AIDocumentId::new();
let notebook_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(
notebook_id,
None,
CloudNotebookModel {
title: "Reopened plan".to_string(),
data: "New unfinished work".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
ctx,
);
});
let archival = app.add_model(PlanArchivalModel::new);
let conversation_id = AIConversationId::new();
let old_completion = Utc::now() - chrono::Duration::days(8);
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, old_completion);
model.archive_plan(&document_id.to_string(), ctx);
});
assert!(CloudModel::as_ref(&app)
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id
.is_some());
// Typing in the editor emits Dirty; reverting a version also emits a
// user-originated DocumentUpdated event. Either must reopen the plan.
for event in [
AIDocumentModelEvent::DocumentUserEditStatusUpdated {
document_id,
status: AIDocumentUserEditStatus::Dirty,
},
AIDocumentModelEvent::DocumentUpdated {
document_id,
version: Default::default(),
source: AIDocumentUpdateSource::User,
},
] {
documents.update(&mut app, |_, ctx| ctx.emit(event));
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, old_completion);
model.sweep(ctx);
assert!(!model.completed_plans.contains_key(&document_id.to_string()));
assert_eq!(
model.reopened_plans[&document_id.to_string()].conversation_id,
conversation_id,
);
let json = serde_json::to_string(model).unwrap();
let mut restored = PlanArchivalModel::from_persisted(&json).unwrap();
restored.observe_completion(
document_id.to_string(),
conversation_id,
old_completion,
);
assert!(restored.completed_plans.is_empty());
});
assert!(CloudModel::as_ref(&app)
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id
.is_none());
}
archival.update(&mut app, |model, ctx| {
// Merely restoring clean editor state cannot close a reopened plan.
model.accept_document_edits(document_id, ctx);
assert!(model.reopened_plans.contains_key(&document_id.to_string()));
model.begin_exchange(conversation_id, ctx);
assert!(model.reopened_plans.contains_key(&document_id.to_string()));
model.accept_document_edits(document_id, ctx);
assert!(model.reopened_plans.is_empty());
let finished_again = Utc::now();
model.observe_completion(document_id.to_string(), conversation_id, finished_again);
assert_eq!(model.completed_plans[&document_id.to_string()].completed_at, finished_again);
assert!(!model.completed_plans[&document_id.to_string()].is_due(finished_again));
});
});
}
#[test]
fn a_successful_draft_conversation_does_not_complete_a_plan() {
let mut conversation = AIConversation::new(false, false);
conversation.set_status_for_test(ConversationStatus::Success);
assert!(completed_work_time(&conversation).is_none());
for status in [
ConversationStatus::InProgress,
ConversationStatus::WaitingForEvents,
ConversationStatus::TransientError,
ConversationStatus::Error,
ConversationStatus::Cancelled,
] {
conversation.set_status_for_test(status);
assert!(completed_work_time(&conversation).is_none());
}
}
#[test]
fn archival_preserves_notebook_and_resuming_restores_original_folder() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let (sender, receiver) = sync_channel(32);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(Some(sender), None, ctx)
});
let archival = app.add_model(|_| PlanArchivalModel {
completed_plans: HashMap::new(),
..Default::default()
});
let original_folder = SyncId::ClientId(ClientId::new());
let notebook_id = SyncId::ClientId(ClientId::new());
let document_id = AIDocumentId::new();
let notebook_model = CloudNotebookModel {
title: "Retained plan".to_string(),
data: "# Plan\n- [x] Finished work".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
};
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(
original_folder,
"Project".to_string(),
None,
ctx,
);
repository.create_notebook_with_id(
notebook_id,
Some(original_folder),
notebook_model.clone(),
ctx,
);
});
let conversation_id = AIConversationId::new();
archival.update(&mut app, |model, ctx| {
model.observe_completion(
document_id.to_string(),
conversation_id,
Utc::now() - chrono::Duration::days(8),
);
model.persist(ctx);
});
// Simulate startup with persisted completion records and no loaded
// conversations/documents. The notebook is still retained in Drive.
let archival = app.add_model(PlanArchivalModel::new);
archival.update(&mut app, |model, ctx| model.sweep(ctx));
let archive_folder = archival.read(&app, |model, _| {
model.completed_plans[&document_id.to_string()]
.archived_to
.expect("plan should have been archived")
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert_eq!(notebook.model(), &notebook_model);
assert_eq!(notebook.metadata.folder_id, Some(archive_folder));
assert!(!notebook.is_trashed(cloud_model));
let folder = cloud_model.get_folder(&archive_folder).unwrap();
assert_eq!(folder.model().name, ARCHIVE_FOLDER_NAME);
let parent = cloud_model
.get_folder(&folder.metadata.folder_id.unwrap())
.unwrap();
assert_eq!(parent.model().name, PLAN_FOLDER_NAME);
let events = receiver.try_iter().collect::<Vec<_>>();
assert!(events.iter().any(|event| matches!(
event,
ModelEvent::UpsertNotebook { notebook }
if notebook.id == notebook_id
&& notebook.metadata.folder_id == Some(archive_folder)
&& notebook.model() == &notebook_model
)));
assert!(!events.iter().any(|event| matches!(event, ModelEvent::DeleteObjects { .. })));
archival.update(&mut app, |model, ctx| {
model.resume_conversation(conversation_id, ctx);
assert!(model.completed_plans.is_empty());
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert_eq!(notebook.metadata.folder_id, Some(original_folder));
assert_eq!(notebook.model(), &notebook_model);
assert!(!notebook.is_trashed(cloud_model));
});
}
#[test]
fn manual_restore_does_not_rearchive_a_completed_plan() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let archive_folder = SyncId::ClientId(ClientId::new());
let notebook_id = SyncId::ClientId(ClientId::new());
let document_id = AIDocumentId::new();
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(archive_folder, "Archive".to_string(), None, ctx);
repository.create_notebook_with_id(
notebook_id,
Some(archive_folder),
CloudNotebookModel {
title: "Recovered plan".to_string(),
data: "Keep this plan visible".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
ctx,
);
});
let mut record = completed_plan(Utc::now() - chrono::Duration::days(8));
record.archived_to = Some(archive_folder);
let archival = app.add_model(|_| PlanArchivalModel {
completed_plans: HashMap::from([(document_id.to_string(), record)]),
..Default::default()
});
assert!(repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(notebook_id),
CloudObjectLocation::Space(Space::Personal),
ctx,
)
}));
archival.update(&mut app, |model, ctx| model.sweep(ctx));
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert!(notebook.metadata.folder_id.is_none());
assert_eq!(notebook.model().data, "Keep this plan visible");
archival.read(&app, |model, _| {
assert!(!model.completed_plans[&document_id.to_string()].is_due(Utc::now()));
});
});
}
#[test]
fn ordinary_notebooks_are_never_archived() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let notebook_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(
notebook_id,
None,
CloudNotebookModel {
title: "Ordinary notes".to_string(),
data: "Keep these notes".to_string(),
ai_document_id: None,
conversation_id: None,
},
ctx,
);
});
let archival = app.add_model(|_| PlanArchivalModel {
completed_plans: HashMap::new(),
..Default::default()
});
archival.update(&mut app, |model, ctx| {
model.observe_completion(
notebook_id.to_string(),
AIConversationId::new(),
Utc::now() - chrono::Duration::days(8),
);
model.archive_plan(&notebook_id.to_string(), ctx);
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert!(notebook.metadata.folder_id.is_none());
assert_eq!(notebook.model().data, "Keep these notes");
assert_eq!(cloud_model.get_all_active_folders().count(), 0);
});
}
#[test]
fn editing_an_archived_plan_from_drive_reopens_it_without_loading_its_conversation() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let document_id = AIDocumentId::new();
let notebook_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(
notebook_id,
None,
CloudNotebookModel {
title: "Completed plan".to_string(),
data: "- [x] Done".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
ctx,
);
});
let archival = app.add_model(PlanArchivalModel::new);
let conversation_id = AIConversationId::new();
let completed_at = Utc::now() - chrono::Duration::days(8);
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, completed_at);
model.archive_plan(&document_id.to_string(), ctx);
});
repository.update(&mut app, |repository, ctx| {
repository.update_notebook_data(
notebook_id,
"- [ ] New unfinished work".to_string(),
ctx,
);
});
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, completed_at);
model.sweep(ctx);
assert!(model.completed_plans.is_empty());
assert_eq!(
model.reopened_plans[&document_id.to_string()].conversation_id,
conversation_id,
);
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert!(notebook.metadata.folder_id.is_none());
assert_eq!(notebook.model().data, "- [ ] New unfinished work");
assert!(!notebook.is_trashed(cloud_model));
});
}
+104 -7
View File
@@ -19,7 +19,9 @@ use warpui::{
}; };
use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; use crate::ai::execution_profiles::model_menu_items::{
available_model_menu_items, collapse_model_reasoning_choices, reasoning_choices_for_model,
};
use crate::ai::execution_profiles::profiles::{ use crate::ai::execution_profiles::profiles::{
AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId, AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId,
}; };
@@ -28,11 +30,13 @@ use crate::ai::execution_profiles::{
}; };
use crate::ai::llms::{ use crate::ai::llms::{
DisableReason, LLMContextWindow, LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent, DisableReason, LLMContextWindow, LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent,
LLMProvider,
}; };
use crate::cloud_object::model::generic_string_model::StringModel; use crate::cloud_object::model::generic_string_model::StringModel;
use crate::editor::{ use crate::editor::{
EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions, EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions,
}; };
use crate::menu::{MenuItem, MenuItemFields, MenuTooltipPosition};
use crate::pane_group::focus_state::PaneFocusHandle; use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view; use crate::pane_group::pane::view;
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent}; use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
@@ -131,6 +135,13 @@ pub enum ExecutionProfileEditorViewEvent {
Pane(PaneEvent), Pane(PaneEvent),
} }
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProfileModelRole {
Base,
FullTerminalUse,
ComputerUse,
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ExecutionProfileEditorViewAction { pub enum ExecutionProfileEditorViewAction {
Save, Save,
@@ -138,6 +149,10 @@ pub enum ExecutionProfileEditorViewAction {
SetBaseModel { SetBaseModel {
id: LLMId, id: LLMId,
}, },
SetModelReasoning {
role: ProfileModelRole,
id: LLMId,
},
/// Fired continuously while the user drags the context window slider. /// Fired continuously while the user drags the context window slider.
ContextWindowSliderDragged { ContextWindowSliderDragged {
value: u32, value: u32,
@@ -197,6 +212,10 @@ pub struct ExecutionProfileEditorView {
full_terminal_use_model_dropdown: full_terminal_use_model_dropdown:
ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>, ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>,
computer_use_model_dropdown: ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>, computer_use_model_dropdown: ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>,
base_reasoning_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
full_terminal_reasoning_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
computer_use_reasoning_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
reasoning_roles: Vec<ProfileModelRole>,
permission_mode_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>, permission_mode_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
command_denylist_editor: ViewHandle<SubmittableTextInput>, command_denylist_editor: ViewHandle<SubmittableTextInput>,
command_allowlist_editor: ViewHandle<SubmittableTextInput>, command_allowlist_editor: ViewHandle<SubmittableTextInput>,
@@ -300,6 +319,9 @@ impl ExecutionProfileEditorView {
dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx); dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx);
dropdown dropdown
}); });
let base_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new);
let full_terminal_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new);
let computer_use_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new);
let command_allowlist_editor = ctx.add_typed_action_view(|ctx| { let command_allowlist_editor = ctx.add_typed_action_view(|ctx| {
let mut input = SubmittableTextInput::new(ctx).validate_on_edit(|s| Regex::new(s).is_ok()); let mut input = SubmittableTextInput::new(ctx).validate_on_edit(|s| Regex::new(s).is_ok());
input.set_placeholder_text("e.g. ^cargo test$", ctx); input.set_placeholder_text("e.g. ^cargo test$", ctx);
@@ -360,6 +382,10 @@ impl ExecutionProfileEditorView {
coding_model_dropdown, coding_model_dropdown,
full_terminal_use_model_dropdown, full_terminal_use_model_dropdown,
computer_use_model_dropdown, computer_use_model_dropdown,
base_reasoning_dropdown,
full_terminal_reasoning_dropdown,
computer_use_reasoning_dropdown,
reasoning_roles: Vec::new(),
permission_mode_dropdown, permission_mode_dropdown,
command_denylist_editor, command_denylist_editor,
command_allowlist_editor, command_allowlist_editor,
@@ -481,6 +507,7 @@ impl ExecutionProfileEditorView {
); );
} }
} }
me.refresh_reasoning_dropdowns(ctx);
}); });
// Refresh model dropdowns when BYO API keys update so key icons reflect current state. // Refresh model dropdowns when BYO API keys update so key icons reflect current state.
@@ -505,6 +532,7 @@ impl ExecutionProfileEditorView {
ctx, ctx,
); );
me.sync_context_window_editor(ctx, false); me.sync_context_window_editor(ctx, false);
me.refresh_reasoning_dropdowns(ctx);
ctx.notify(); ctx.notify();
}, },
); );
@@ -556,7 +584,10 @@ impl ExecutionProfileEditorView {
self.command_allowlist_mouse_state_handles = current_permissions.command_allowlist.iter().map(|_| Default::default()).collect(); self.command_allowlist_mouse_state_handles = current_permissions.command_allowlist.iter().map(|_| Default::default()).collect();
let mut names: Vec<_> = LLMPreferences::as_ref(app).get_base_llm_choices_for_agent_mode(app) let mut names: Vec<_> = LLMPreferences::as_ref(app).get_base_llm_choices_for_agent_mode(app)
.filter(|model| !matches!(model.id.as_str(), "none" | "placeholder") && !crate::ai::custom_model_routers::is_custom_router_id(model.id.as_str())) .filter(|model| !matches!(model.id.as_str(), "none" | "placeholder") && !crate::ai::custom_model_routers::is_custom_router_id(model.id.as_str()))
.filter_map(|model| model.description.clone()).collect(); // Cloud catalog descriptions name vendors, not configured connections.
// Direct registry models already carry the user's connection name.
.filter(|model| matches!(model.provider, LLMProvider::LiteLLM | LLMProvider::Bedrock | LLMProvider::Acp))
.map(|model| LLMPreferences::as_ref(app).orchestration_provider_name(model).to_string()).collect();
names.extend(AISettings::as_ref(app).openai_providers.value().iter().map(|provider| provider.name.clone())); names.extend(AISettings::as_ref(app).openai_providers.value().iter().map(|provider| provider.name.clone()));
names.retain(|name| !name.trim().is_empty()); names.retain(|name| !name.trim().is_empty());
names.sort(); names.dedup(); names.sort(); names.dedup();
@@ -643,6 +674,62 @@ impl ExecutionProfileEditorView {
Self::update_profile_name_editor(&self.profile_name_editor, &current_permissions, ctx); Self::update_profile_name_editor(&self.profile_name_editor, &current_permissions, ctx);
self.sync_context_window_editor(ctx, false); self.sync_context_window_editor(ctx, false);
self.refresh_reasoning_dropdowns(ctx);
}
fn refresh_reasoning_dropdowns(&mut self, ctx: &mut ViewContext<Self>) {
let profile = BlocklistAIPermissions::as_ref(ctx)
.permissions_profile_for_id(ctx, self.profile_id);
self.reasoning_roles.clear();
for role in [ProfileModelRole::Base, ProfileModelRole::FullTerminalUse, ProfileModelRole::ComputerUse] {
let prefs = LLMPreferences::as_ref(ctx);
let (id, choices, dropdown) = match role {
ProfileModelRole::Base => (
profile.base_model.clone().unwrap_or_else(|| prefs.get_default_base_model().id.clone()),
prefs.get_base_llm_choices_for_agent_mode(ctx).collect_vec(),
&self.base_reasoning_dropdown,
),
ProfileModelRole::FullTerminalUse => (
profile.cli_agent_model.clone().unwrap_or_else(|| prefs.get_default_cli_agent_model().id.clone()),
prefs.get_cli_agent_llm_choices(ctx).collect_vec(),
&self.full_terminal_reasoning_dropdown,
),
ProfileModelRole::ComputerUse => (
profile.computer_use_model.clone().unwrap_or_else(|| prefs.get_default_computer_use_model().id.clone()),
prefs.get_computer_use_llm_choices(ctx).collect_vec(),
&self.computer_use_reasoning_dropdown,
),
};
let id = prefs.get_llm_info(&id).map_or(id.clone(), |model| model.id.clone());
let reasoning_choices = reasoning_choices_for_model(&id, &choices);
let items: Vec<MenuItem<DropdownAction>> = reasoning_choices.iter().map(|model| {
let mut fields = MenuItemFields::new(
model.reasoning_level.clone().unwrap_or_else(|| "Provider default".to_owned()),
)
.with_on_select_action(DropdownAction::select_action_and_close(
ExecutionProfileEditorViewAction::SetModelReasoning { role, id: model.id.clone() },
))
.with_disabled(model.disable_reason.is_some());
if let Some(reason) = &model.disable_reason {
fields = fields.with_tooltip(reason.tooltip_text())
.with_tooltip_position(MenuTooltipPosition::Above);
}
fields.into_item()
}).collect();
if !reasoning_choices.is_empty() {
self.reasoning_roles.push(role);
}
dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_action(ExecutionProfileEditorViewAction::SetModelReasoning { role, id }, ctx);
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
dropdown.set_enabled(ctx);
} else {
dropdown.set_disabled(ctx);
}
});
}
ctx.notify();
} }
fn refresh_filterable_model_dropdown<G, A, D>( fn refresh_filterable_model_dropdown<G, A, D>(
@@ -669,7 +756,10 @@ impl ExecutionProfileEditorView {
let llm_prefs = LLMPreferences::handle(ctx); let llm_prefs = LLMPreferences::handle(ctx);
let llm_prefs = llm_prefs.as_ref(ctx); let llm_prefs = llm_prefs.as_ref(ctx);
let choices = get_choices(llm_prefs, ctx); let model_to_select = profile_model.unwrap_or_else(|| get_default_id(llm_prefs));
let model_to_select = llm_prefs.get_llm_info(&model_to_select)
.map_or(model_to_select.clone(), |model| model.id.clone());
let choices = collapse_model_reasoning_choices(get_choices(llm_prefs, ctx), Some(&model_to_select));
let has_upgrade_gated_models = choices let has_upgrade_gated_models = choices
.iter() .iter()
@@ -681,7 +771,7 @@ impl ExecutionProfileEditorView {
None, None,
None, None,
false, false,
false, true,
ctx, ctx,
); );
dropdown.set_rich_items(items, ctx); dropdown.set_rich_items(items, ctx);
@@ -696,9 +786,6 @@ impl ExecutionProfileEditorView {
dropdown.clear_footer(ctx); dropdown.clear_footer(ctx);
} }
let llm_prefs = LLMPreferences::handle(ctx);
let llm_prefs = llm_prefs.as_ref(ctx);
let model_to_select = profile_model.unwrap_or_else(|| get_default_id(llm_prefs));
dropdown.set_selected_by_action(create_action(model_to_select), ctx); dropdown.set_selected_by_action(create_action(model_to_select), ctx);
ctx.notify(); ctx.notify();
}); });
@@ -1032,6 +1119,16 @@ impl TypedActionView for ExecutionProfileEditorView {
self.sync_context_window_editor(ctx, true); self.sync_context_window_editor(ctx, true);
ctx.notify(); ctx.notify();
} }
ExecutionProfileEditorViewAction::SetModelReasoning { role, id } => {
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| {
match role {
ProfileModelRole::Base => profiles_model.set_base_model(self.profile_id, Some(id.clone()), ctx),
ProfileModelRole::FullTerminalUse => profiles_model.set_cli_agent_model(self.profile_id, Some(id.clone()), ctx),
ProfileModelRole::ComputerUse => profiles_model.set_computer_use_model(self.profile_id, Some(id.clone()), ctx),
}
});
ctx.notify();
}
ExecutionProfileEditorViewAction::ContextWindowSliderDragged { value } => { ExecutionProfileEditorViewAction::ContextWindowSliderDragged { value } => {
if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
self.sync_context_window_editor(ctx, true); self.sync_context_window_editor(ctx, true);
@@ -5,6 +5,9 @@ use warpui::App;
use super::ui_helpers::context_window_snap_values; use super::ui_helpers::context_window_snap_values;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::model_menu_items::{
collapse_model_reasoning_choices, has_reasoning_variants, reasoning_choices_for_model,
};
use crate::ai::execution_profiles::{ use crate::ai::execution_profiles::{
has_configurable_context_window, should_show_long_context_pricing_warning, AIExecutionProfile, has_configurable_context_window, should_show_long_context_pricing_warning, AIExecutionProfile,
AIExecutionProfileAppExt as _, AIExecutionProfileAppExt as _,
@@ -31,6 +34,7 @@ fn configurable_model(provider: LLMProvider) -> LLMInfo {
base_model_name: "test model".to_string(), base_model_name: "test model".to_string(),
id: "test-model".into(), id: "test-model".into(),
reasoning_level: None, reasoning_level: None,
reasoning_model_id: None,
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -316,3 +320,53 @@ fn non_openai_configurable_context_ignores_gpt_flag_and_does_not_show_openai_war
false false
)); ));
} }
fn reasoning_model(id: &str, provider: &str, effort: Option<&str>) -> LLMInfo {
let mut model = configurable_model(LLMProvider::LiteLLM);
model.id = id.into();
model.base_model_name = "Same model".to_owned();
model.description = Some(provider.to_owned());
model.reasoning_level = effort.map(str::to_owned);
model
}
#[test]
fn reasoning_dropdown_supports_one_advertised_level_and_provider_default() {
let base = reasoning_model("model", "Connection A", None);
let high = reasoning_model("model::reasoning::high", "Connection A", Some("high"));
let choices = vec![&base, &high];
assert!(has_reasoning_variants(&base, &choices));
let levels = reasoning_choices_for_model(&base.id, &choices);
assert_eq!(levels.len(), 2);
assert_eq!(levels[0].reasoning_level, None);
assert_eq!(levels[1].reasoning_level.as_deref(), Some("high"));
}
#[test]
fn reasoning_choices_without_discovery_remain_unchanged() {
let base = reasoning_model("model", "Connection A", None);
let choices = vec![&base];
assert!(!has_reasoning_variants(&base, &choices));
assert!(reasoning_choices_for_model(&base.id, &choices).is_empty());
assert_eq!(collapse_model_reasoning_choices(choices, Some(&base.id))[0].id, base.id);
}
#[test]
fn collapsed_models_preserve_selected_reasoning_and_provider_identity() {
let base = reasoning_model("model", "Connection A", None);
let high = reasoning_model("model::reasoning::high", "Connection A", Some("high"));
let other = reasoning_model("model::connection::other", "Connection B", None);
let choices = collapse_model_reasoning_choices(vec![&base, &high, &other], Some(&high.id));
assert_eq!(choices.len(), 2);
assert_eq!(choices[0].id, high.id);
assert_eq!(choices[1].id, other.id);
assert!(reasoning_choices_for_model(&other.id, &[&base, &high, &other]).is_empty());
}
#[test]
fn reasoning_dropdown_does_not_invent_provider_default() {
let high = reasoning_model("model::reasoning::high", "Connection A", Some("high"));
let levels = reasoning_choices_for_model(&high.id, &[&high]);
assert_eq!(levels.len(), 1);
assert_eq!(levels[0].id, high.id);
}
@@ -12,7 +12,7 @@ use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::text::Span; use warpui::ui_components::text::Span;
use warpui::{AppContext, Element, SingletonEntity, ViewHandle}; use warpui::{AppContext, Element, SingletonEntity, ViewHandle};
use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction}; use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction, ProfileModelRole};
use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::{ use crate::ai::execution_profiles::{
long_context_pricing_warning_title, AIExecutionProfile, AIExecutionProfileAppExt as _, long_context_pricing_warning_title, AIExecutionProfile, AIExecutionProfileAppExt as _,
@@ -247,6 +247,29 @@ fn render_permission_row<T: DropdownItemAction>(
.finish() .finish()
} }
fn render_reasoning_dropdown_row(
appearance: &Appearance,
dropdown: &ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
) -> Box<dyn Element> {
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
Text::new("Reasoning level", appearance.ui_font_family(), 12.)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_margin_right(12.)
.finish(),
)
.with_child(ChildView::new(dropdown).finish())
.finish(),
)
.with_margin_bottom(16.)
.finish()
}
pub fn render_models_section( pub fn render_models_section(
appearance: &Appearance, appearance: &Appearance,
view: &ExecutionProfileEditorView, view: &ExecutionProfileEditorView,
@@ -258,10 +281,14 @@ pub fn render_models_section(
.with_child(render_filterable_dropdown_row( .with_child(render_filterable_dropdown_row(
appearance, appearance,
"Base model", "Base model",
"This model serves as the primary engine behind the agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Warp may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization.", "This model serves as the primary engine behind the agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Galaxy may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization.",
&view.base_model_dropdown, &view.base_model_dropdown,
)); ));
if view.reasoning_roles.contains(&ProfileModelRole::Base) {
column.add_child(render_reasoning_dropdown_row(appearance, &view.base_reasoning_dropdown));
}
if let Some(row) = render_context_window_row(appearance, view, app) { if let Some(row) = render_context_window_row(appearance, view, app) {
column.add_child(row); column.add_child(row);
} }
@@ -273,6 +300,10 @@ pub fn render_models_section(
&view.full_terminal_use_model_dropdown, &view.full_terminal_use_model_dropdown,
)); ));
if view.reasoning_roles.contains(&ProfileModelRole::FullTerminalUse) {
column.add_child(render_reasoning_dropdown_row(appearance, &view.full_terminal_reasoning_dropdown));
}
if FeatureFlag::LocalComputerUse.is_enabled() { if FeatureFlag::LocalComputerUse.is_enabled() {
column.add_child(render_filterable_dropdown_row( column.add_child(render_filterable_dropdown_row(
appearance, appearance,
@@ -280,6 +311,9 @@ pub fn render_models_section(
"The model used when the agent takes control of your computer to interact with graphical applications through mouse movements, clicks, and keyboard input.", "The model used when the agent takes control of your computer to interact with graphical applications through mouse movements, clicks, and keyboard input.",
&view.computer_use_model_dropdown, &view.computer_use_model_dropdown,
)); ));
if view.reasoning_roles.contains(&ProfileModelRole::ComputerUse) {
column.add_child(render_reasoning_dropdown_row(appearance, &view.computer_use_reasoning_dropdown));
}
} }
Container::new(column.finish()) Container::new(column.finish())
@@ -647,7 +681,7 @@ fn render_orchestration_providers(
let checkbox = appearance.ui_builder() let checkbox = appearance.ui_builder()
.checkbox(view.all_providers_checkbox.clone(), Some(14.)) .checkbox(view.all_providers_checkbox.clone(), Some(14.))
.with_label(Span::new("All providers", Default::default())) .with_label(Span::new("All providers", Default::default()))
.with_style(UiComponentStyles { foreground: Some(appearance.theme().active_ui_text_color().into()), ..Default::default() }) .with_label_style(UiComponentStyles { font_color: Some(appearance.theme().active_ui_text_color().into()), ..Default::default() })
.check(all) .check(all)
.build() .build()
.on_click(move |ctx, _, _| { .on_click(move |ctx, _, _| {
@@ -671,7 +705,7 @@ fn render_orchestration_providers(
let checkbox = appearance.ui_builder() let checkbox = appearance.ui_builder()
.checkbox(state.clone(), Some(14.)) .checkbox(state.clone(), Some(14.))
.with_label(Span::new(name.clone(), Default::default())) .with_label(Span::new(name.clone(), Default::default()))
.with_style(UiComponentStyles { foreground: Some(appearance.theme().active_ui_text_color().into()), ..Default::default() }) .with_label_style(UiComponentStyles { font_color: Some(appearance.theme().active_ui_text_color().into()), ..Default::default() })
.check(included) .check(included)
.build() .build()
.on_click(move |ctx, _, _| { .on_click(move |ctx, _, _| {
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use galaxy_core::ui::Icon; use galaxy_core::ui::Icon;
@@ -21,15 +22,55 @@ pub fn is_auto(llm: &LLMInfo) -> bool {
|| llm.id.to_string().to_lowercase().contains("auto") || llm.id.to_string().to_lowercase().contains("auto")
} }
/// Returns true if the given model has other variants with different reasoning levels. /// Returns true when a model's configured connection advertises reasoning choices.
pub fn has_reasoning_variants(llm: &LLMInfo, all_models: &[&LLMInfo]) -> bool { pub fn has_reasoning_variants(llm: &LLMInfo, all_models: &[&LLMInfo]) -> bool {
if !llm.has_reasoning_level() { all_models.iter().any(|other| {
return false; other.reasoning_group_key() == llm.reasoning_group_key() && other.has_reasoning_level()
})
}
/// Keep one visible model entry per connection while preserving the selected
/// reasoning variant as the entry's action. Unknown reasoning metadata stays unchanged.
pub fn collapse_model_reasoning_choices<'a>(
choices: Vec<&'a LLMInfo>,
selected_id: Option<&LLMId>,
) -> Vec<&'a LLMInfo> {
let mut groups = HashSet::new();
let mut result = Vec::new();
for model in &choices {
if !has_reasoning_variants(model, &choices) {
result.push(*model);
continue;
} }
all_models let key = model.reasoning_group_key();
.iter() if !groups.insert(key.clone()) {
.filter(|other| other.reasoning_group_key() == llm.reasoning_group_key() && other.id != llm.id) continue;
.any(|other| other.has_reasoning_level()) }
let matching = || choices.iter().copied().filter(|other| other.reasoning_group_key() == key);
let representative = matching()
.find(|other| Some(&other.id) == selected_id)
.or_else(|| matching().find(|other| !other.has_reasoning_level()))
.unwrap_or(model);
result.push(representative);
}
result
}
/// Includes the provider-default entry only when that entry is actually available.
pub fn reasoning_choices_for_model<'a>(
selected_id: &LLMId,
choices: &[&'a LLMInfo],
) -> Vec<&'a LLMInfo> {
let Some(selected) = choices.iter().find(|model| &model.id == selected_id) else {
return Vec::new();
};
if !has_reasoning_variants(selected, choices) {
return Vec::new();
}
choices.iter().copied()
.filter(|model| model.reasoning_group_key() == selected.reasoning_group_key())
.unique_by(|model| model.reasoning_level.clone())
.collect()
} }
fn with_cost_and_profile_info<A: Action + Clone>( fn with_cost_and_profile_info<A: Action + Clone>(
@@ -1377,6 +1377,7 @@ impl AIExecutionProfilesModel {
} }
LocalObjectRepositoryEvent::Rules LocalObjectRepositoryEvent::Rules
| LocalObjectRepositoryEvent::Notebooks { .. } | LocalObjectRepositoryEvent::Notebooks { .. }
| LocalObjectRepositoryEvent::NotebookContentEdited { .. }
| LocalObjectRepositoryEvent::Workflows { .. } => {} | LocalObjectRepositoryEvent::Workflows { .. } => {}
} }
} }
+42
View File
@@ -0,0 +1,42 @@
//! Match-mode preparation shared by native and compatibility grep tool calls.
/// Keep regex mode unchanged; literal mode escapes only metacharacters shared
/// by git/grep extended regex, ripgrep, and PowerShell Select-String. This keeps
/// the persisted action and backend execution paths identical in both modes.
pub(crate) fn prepare_grep_queries(queries: Vec<String>, fixed_strings: bool) -> Vec<String> {
if !fixed_strings {
return queries;
}
queries
.into_iter()
.map(|query| {
let mut escaped = String::with_capacity(query.len());
for character in query.chars() {
if matches!(
character,
'\\' | '.'
| '^'
| '$'
| '|'
| '?'
| '*'
| '+'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
})
.collect()
}
#[cfg(test)]
#[path = "grep_query_tests.rs"]
mod tests;
+39
View File
@@ -0,0 +1,39 @@
use super::prepare_grep_queries;
#[test]
fn literal_queries_match_source_punctuation_backslashes_and_unicode_exactly() {
let cases = [
("variantResolutions: []", r"variantResolutions: \[\]"),
("indexAsset(context", r"indexAsset\(context"),
(
r#"describe("duplicate"|managed output|duplicateContentAsset"#,
r#"describe\("duplicate"\|managed output\|duplicateContentAsset"#,
),
(r"C:\repo\file.rs", r"C:\\repo\\file\.rs"),
("🚀 café [数据] (名)", r"🚀 café \[数据\] \(名\)"),
(
r#"owner's $HOME `id` #&-~ name=foo: bar"#,
r#"owner's \$HOME `id` #&-~ name=foo: bar"#,
),
(
"^foo.*(bar|baz)?[0-9]+{2}$",
r"\^foo\.\*\(bar\|baz\)\?\[0-9\]\+\{2\}\$",
),
];
for (literal, escaped) in cases {
let queries = prepare_grep_queries(vec![literal.to_string()], true);
assert_eq!(queries, vec![escaped], "literal: {literal}");
let regex = regex::Regex::new(&queries[0]).unwrap();
assert_eq!(regex.find(literal).unwrap().as_str(), literal);
}
}
#[test]
fn regex_queries_stay_byte_for_byte_unchanged_including_backend_specific_syntax() {
let queries = vec![
r"^(foo|bar)\s+[0-9]+$".to_string(),
r"(?<=prefix)lookbehind".to_string(),
"[invalid".to_string(),
];
assert_eq!(prepare_grep_queries(queries.clone(), false), queries);
}
+254 -123
View File
@@ -27,6 +27,8 @@ use warp_multi_agent_api as api;
use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError}; use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError};
use super::execution_profiles::profiles::AIExecutionProfilesModel; use super::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::acp::{acp_launch_fingerprint, acp_provider_selection_identity}; use crate::ai::acp::{acp_launch_fingerprint, acp_provider_selection_identity};
#[cfg(not(target_family = "wasm"))]
use crate::ai::provider::external_config::ExternalBedrockConfig;
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::AuthStateProvider; use crate::auth::AuthStateProvider;
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
@@ -202,6 +204,10 @@ pub struct LLMInfo {
pub base_model_name: String, pub base_model_name: String,
pub id: LLMId, pub id: LLMId,
pub reasoning_level: Option<String>, pub reasoning_level: Option<String>,
/// Exact provider model identity for grouping locally discovered reasoning variants.
/// Older catalogs and ACP retain their existing display-name grouping.
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_model_id: Option<String>,
pub usage_metadata: LLMUsageMetadata, pub usage_metadata: LLMUsageMetadata,
pub description: Option<String>, pub description: Option<String>,
pub disable_reason: Option<DisableReason>, pub disable_reason: Option<DisableReason>,
@@ -242,6 +248,8 @@ impl<'de> Deserialize<'de> for LLMInfo {
id: LLMId, id: LLMId,
#[serde(default)] #[serde(default)]
reasoning_level: Option<String>, reasoning_level: Option<String>,
#[serde(default)]
reasoning_model_id: Option<String>,
usage_metadata: LLMUsageMetadata, usage_metadata: LLMUsageMetadata,
#[serde(default)] #[serde(default)]
description: Option<String>, description: Option<String>,
@@ -286,6 +294,7 @@ impl<'de> Deserialize<'de> for LLMInfo {
display_name: wire.display_name, display_name: wire.display_name,
id: wire.id, id: wire.id,
reasoning_level: wire.reasoning_level, reasoning_level: wire.reasoning_level,
reasoning_model_id: wire.reasoning_model_id,
usage_metadata: wire.usage_metadata, usage_metadata: wire.usage_metadata,
description: wire.description, description: wire.description,
disable_reason: wire.disable_reason, disable_reason: wire.disable_reason,
@@ -335,9 +344,21 @@ impl LLMInfo {
/// Reasoning menus must never merge models belonging to different connections. /// Reasoning menus must never merge models belonging to different connections.
pub fn reasoning_group_key(&self) -> String { pub fn reasoning_group_key(&self) -> String {
let connection = self.id.as_str().rsplit_once("::connection::") let connection = self
.map(|(_, connection)| connection).unwrap_or_default(); .id
format!("{}\u{1f}{}\u{1f}{:?}\u{1f}{connection}", self.base_model_name, self.description.as_deref().unwrap_or_default(), self.provider) .as_str()
.rsplit_once("::connection::")
.map(|(_, connection)| connection)
.unwrap_or_default();
let model_identity = self
.reasoning_model_id
.as_deref()
.unwrap_or(&self.base_model_name);
format!(
"{model_identity}\u{1f}{}\u{1f}{:?}\u{1f}{connection}",
self.description.as_deref().unwrap_or_default(),
self.provider
)
} }
/// Returns the given model's base name. /// Returns the given model's base name.
@@ -364,6 +385,7 @@ impl LLMInfo {
base_model_name: llm_name.to_string(), base_model_name: llm_name.to_string(),
id: llm_name.into(), id: llm_name.into(),
reasoning_level: None, reasoning_level: None,
reasoning_model_id: None,
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -386,6 +408,8 @@ pub struct AvailableLLMs {
/// The Warp "default" LLM. /// The Warp "default" LLM.
default_id: LLMId, default_id: LLMId,
choices: Vec<LLMInfo>, choices: Vec<LLMInfo>,
#[serde(skip)]
legacy_reasoning_aliases: HashMap<LLMId, LLMId>,
#[serde(default)] #[serde(default)]
preferred_codex_model_id: Option<LLMId>, preferred_codex_model_id: Option<LLMId>,
@@ -423,12 +447,16 @@ impl AvailableLLMs {
Ok(Self { Ok(Self {
default_id, default_id,
choices: choices.into_iter().collect(), choices: choices.into_iter().collect(),
legacy_reasoning_aliases: HashMap::new(),
preferred_codex_model_id, preferred_codex_model_id,
}) })
} }
fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> { fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
self.choices.iter().find(|info| info.id == *id) self.choices.iter().find(|info| info.id == *id).or_else(|| {
let resolved = self.legacy_reasoning_aliases.get(id)?;
self.choices.iter().find(|info| info.id == *resolved)
})
} }
/// Returns the info for the given id only if the model is usable (present /// Returns the info for the given id only if the model is usable (present
@@ -449,6 +477,7 @@ impl AvailableLLMs {
base_model_name: "No models configured".to_owned(), base_model_name: "No models configured".to_owned(),
id: "none".to_owned().into(), id: "none".to_owned().into(),
reasoning_level: None, reasoning_level: None,
reasoning_model_id: None,
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -516,12 +545,14 @@ impl ModelsByFeature {
/// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`. /// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`.
fn default_computer_use_llms() -> AvailableLLMs { fn default_computer_use_llms() -> AvailableLLMs {
AvailableLLMs { AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "computer-use-agent-auto".to_owned().into(), default_id: "computer-use-agent-auto".to_owned().into(),
choices: vec![LLMInfo { choices: vec![LLMInfo {
display_name: "auto".to_owned(), display_name: "auto".to_owned(),
base_model_name: "auto".to_owned(), base_model_name: "auto".to_owned(),
id: "computer-use-agent-auto".to_owned().into(), id: "computer-use-agent-auto".to_owned().into(),
reasoning_level: None, reasoning_level: None,
reasoning_model_id: None,
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -546,12 +577,14 @@ impl Default for ModelsByFeature {
/// `inject_bedrock_models` once real models are loaded. /// `inject_bedrock_models` once real models are loaded.
fn default() -> Self { fn default() -> Self {
let placeholder = || AvailableLLMs { let placeholder = || AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "placeholder".to_owned().into(), default_id: "placeholder".to_owned().into(),
choices: vec![LLMInfo { choices: vec![LLMInfo {
display_name: "No models configured".to_owned(), display_name: "No models configured".to_owned(),
base_model_name: "No models configured".to_owned(), base_model_name: "No models configured".to_owned(),
id: "placeholder".to_owned().into(), id: "placeholder".to_owned().into(),
reasoning_level: None, reasoning_level: None,
reasoning_model_id: None,
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -616,6 +649,7 @@ pub struct LLMPreferences {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
chatgpt_subscription_models_refresh_in_flight: bool, chatgpt_subscription_models_refresh_in_flight: bool,
bedrock_models_refresh_in_flight: bool, bedrock_models_refresh_in_flight: bool,
bedrock_verification_generation: u64,
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
bedrock_verified_config: Option<super::provider::client::BedrockClientConfig>, bedrock_verified_config: Option<super::provider::client::BedrockClientConfig>,
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
@@ -796,6 +830,7 @@ impl LLMPreferences {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
chatgpt_subscription_models_refresh_in_flight: false, chatgpt_subscription_models_refresh_in_flight: false,
bedrock_models_refresh_in_flight: false, bedrock_models_refresh_in_flight: false,
bedrock_verification_generation: 0,
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
bedrock_verified_config: None, bedrock_verified_config: None,
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
@@ -844,7 +879,9 @@ impl LLMPreferences {
return; return;
} }
self.bedrock_models_refresh_in_flight = true; self.bedrock_models_refresh_in_flight = true;
let generation = self.begin_bedrock_verification();
let config = bedrock_discovery_config(settings); let config = bedrock_discovery_config(settings);
let revision = super::provider::authentication::connection_revision(ctx);
let requested_config = config.clone(); let requested_config = config.clone();
self.bedrock_verified_config = None; self.bedrock_verified_config = None;
self.bedrock_verified_model_ids.clear(); self.bedrock_verified_model_ids.clear();
@@ -860,6 +897,11 @@ impl LLMPreferences {
discovery.models discovery.models
}); });
me.bedrock_models_refresh_in_flight = false; me.bedrock_models_refresh_in_flight = false;
if me.bedrock_verification_generation != generation
|| super::provider::authentication::connection_revision(ctx) != revision
{
return;
}
if !*AISettings::as_ref(ctx).bedrock_enabled.value() { if !*AISettings::as_ref(ctx).bedrock_enabled.value() {
return; return;
} }
@@ -888,26 +930,37 @@ impl LLMPreferences {
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
} }
Err(error) => { Err(error) => {
log::debug!("[bedrock] Startup model discovery unavailable: {error}"); log::warn!("[bedrock] Startup model discovery unavailable: {error}");
} }
} }
}, },
); );
} }
#[cfg(not(target_family = "wasm"))]
pub(crate) fn begin_bedrock_verification(&mut self) -> u64 {
self.bedrock_verification_generation += 1;
self.bedrock_verification_generation
}
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub(crate) fn record_bedrock_verification( pub(crate) fn record_bedrock_verification(
&mut self, &mut self,
generation: u64,
config: super::provider::client::BedrockClientConfig, config: super::provider::client::BedrockClientConfig,
models: &[BedrockModelConfig], models: &[BedrockModelConfig],
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) -> bool {
if self.bedrock_verification_generation != generation {
return false;
}
self.bedrock_verified_model_ids = self.bedrock_verified_model_ids =
models.iter().map(|model| model.model_id.clone()).collect(); models.iter().map(|model| model.model_id.clone()).collect();
self.bedrock_verified_config = Some(config); self.bedrock_verified_config = Some(config);
self.inject_bedrock_models(ctx); self.inject_bedrock_models(ctx);
self.ensure_default_model_present(); self.ensure_default_model_present();
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
true
} }
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
@@ -945,48 +998,14 @@ impl LLMPreferences {
let region = settings.bedrock_region.value().clone(); let region = settings.bedrock_region.value().clone();
let cross_region = *settings.bedrock_cross_region_inference.value(); let cross_region = *settings.bedrock_cross_region_inference.value();
// Check if user wants only 1-hour cache models for model in discovered_models {
use crate::ai::provider::external_config::ExternalBedrockConfig;
let external_config = ExternalBedrockConfig::load();
let require_1h_cache = external_config.enable_prompt_caching_1h;
let mut effective = discovered_models;
// Filter out models that don't support 1-hour caching if required
if require_1h_cache {
effective.retain(|model| {
// 1-hour caching is supported by Claude 4.5+ models
// Opus 4.5+, Sonnet 4.5+, Haiku 4.5+
let supports_1h = model.model_id.contains("4-5")
|| model.model_id.contains("4.5")
|| model.model_id.contains("-4-6") // Opus/Sonnet 4.6+ also support 1h
|| model.model_id.contains("4.6")
|| model.model_id.contains("-4-7")
|| model.model_id.contains("4.7")
|| model.model_id.contains("-4-8")
|| model.model_id.contains("4.8");
if !supports_1h {
log::info!(
"[bedrock] Filtering out model {} - does not support 1-hour cache (ENABLE_PROMPT_CACHING_1H=1)",
model.model_id
);
}
supports_1h
});
if effective.is_empty() {
log::warn!("[bedrock] No models left after filtering for 1-hour cache support!");
}
}
let effective = effective;
for model in effective {
if !model.enabled || !self.bedrock_model_is_verified(&model.model_id, ctx) { if !model.enabled || !self.bedrock_model_is_verified(&model.model_id, ctx) {
continue; continue;
} }
let metadata = model_metadata(&model.model_id); let metadata = model_metadata(&model.model_id);
if metadata.is_some_and(|metadata| metadata.supports_agent_runtime() == Some(false)) { if model.runtime_supported == Some(false)
|| metadata.is_some_and(|metadata| metadata.supports_agent_runtime() == Some(false))
{
log::debug!( log::debug!(
"[bedrock] Excluding {} from Agent Mode because its catalog metadata marks it as incompatible with streaming Converse", "[bedrock] Excluding {} from Agent Mode because its catalog metadata marks it as incompatible with streaming Converse",
model.model_id model.model_id
@@ -1014,6 +1033,7 @@ impl LLMPreferences {
display_name: model.display_name.clone(), display_name: model.display_name.clone(),
base_model_name: model.display_name.clone(), base_model_name: model.display_name.clone(),
reasoning_level: None, reasoning_level: None,
reasoning_model_id: Some(model_id.clone()),
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -1046,7 +1066,6 @@ impl LLMPreferences {
// Default agent mode to ANTHROPIC_MODEL from external config if set, // Default agent mode to ANTHROPIC_MODEL from external config if set,
// otherwise Claude Opus 4.6, falling back to the first available model. // otherwise Claude Opus 4.6, falling back to the first available model.
// Note: external_config already loaded above for filtering
let external_config_for_default = ExternalBedrockConfig::load(); let external_config_for_default = ExternalBedrockConfig::load();
if let Some(id) = external_config_for_default if let Some(id) = external_config_for_default
@@ -1082,8 +1101,12 @@ impl LLMPreferences {
.map(|m| m.id.clone()) .map(|m| m.id.clone())
}) })
{ {
if !self.models_by_feature.agent_mode.choices.iter().any(|model| {
model.id == self.models_by_feature.agent_mode.default_id
}) {
self.models_by_feature.agent_mode.default_id = id; self.models_by_feature.agent_mode.default_id = id;
} }
}
// Default coding to Claude Sonnet 4.6, falling back to the first available model. // Default coding to Claude Sonnet 4.6, falling back to the first available model.
if let Some(id) = self if let Some(id) = self
@@ -1095,12 +1118,18 @@ impl LLMPreferences {
.or_else(|| self.models_by_feature.coding.choices.first()) .or_else(|| self.models_by_feature.coding.choices.first())
.map(|m| m.id.clone()) .map(|m| m.id.clone())
{ {
if !self.models_by_feature.coding.choices.iter().any(|model| {
model.id == self.models_by_feature.coding.default_id
}) {
self.models_by_feature.coding.default_id = id; self.models_by_feature.coding.default_id = id;
} }
}
// Default CLI agent to the first available model. // Default CLI agent to the first available model.
if let Some(ref mut cli) = self.models_by_feature.cli_agent { if let Some(ref mut cli) = self.models_by_feature.cli_agent {
if let Some(id) = cli.choices.first().map(|m| m.id.clone()) { if let Some(id) = cli.choices.first().filter(|_| {
!cli.choices.iter().any(|model| model.id == cli.default_id)
}).map(|m| m.id.clone()) {
cli.default_id = id; cli.default_id = id;
} }
} }
@@ -1131,6 +1160,11 @@ impl LLMPreferences {
cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM); cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM);
} }
self.openai_provider_routing.clear(); self.openai_provider_routing.clear();
self.models_by_feature.agent_mode.legacy_reasoning_aliases.clear();
self.models_by_feature.coding.legacy_reasoning_aliases.clear();
if let Some(cli) = &mut self.models_by_feature.cli_agent {
cli.legacy_reasoning_aliases.clear();
}
let settings = AISettings::as_ref(ctx); let settings = AISettings::as_ref(ctx);
self.inject_acp_models(ctx); self.inject_acp_models(ctx);
@@ -1241,6 +1275,8 @@ impl LLMPreferences {
let mut total_injected = 0; let mut total_injected = 0;
let mut seen_model_ids: HashSet<String> = HashSet::new(); let mut seen_model_ids: HashSet<String> = HashSet::new();
let mut legacy_reserved_model_ids = HashSet::new();
let mut legacy_reasoning_aliases = HashMap::new();
let mut seen_connections = HashSet::new(); let mut seen_connections = HashSet::new();
for ( for (
provider_name, provider_name,
@@ -1263,32 +1299,34 @@ impl LLMPreferences {
continue; continue;
} }
let reasoning_efforts: Vec<Option<&String>> = // All providers may advertise discrete effort choices. Keep the
if provider_kind == OpenAIProviderKind::ChatGPTSubscription { // default model key stable and route explicit choices separately.
// Keep the base model as the provider-default mode, then expose each let reasoning_efforts = std::iter::once(None)
// explicitly supported effort as a separate selectable variant. .chain(model.reasoning_efforts.iter().map(Some));
std::iter::once(None)
.chain(model.reasoning_efforts.iter().map(Some))
.collect()
} else {
vec![None]
};
// Choose a namespace once for the whole family. Otherwise a
// colliding base with unique efforts would split one connection
// into multiple groups in the model/reasoning selectors.
let connection_suffix = if seen_model_ids.contains(&model.model_id) {
use sha2::{Digest, Sha256};
Some(format!("{:x}", Sha256::digest(connection_identity.as_bytes())))
} else {
None
};
for reasoning_effort in reasoning_efforts { for reasoning_effort in reasoning_efforts {
let reasoning_effort = reasoning_effort.cloned(); let reasoning_effort = reasoning_effort.cloned();
let mut model_key = reasoning_effort.as_deref().map_or_else( let mut model_key = reasoning_effort.as_deref().map_or_else(
|| model.model_id.clone(), || model.model_id.clone(),
|effort| openai_model_variant_id(&model.model_id, effort), |effort| openai_model_variant_id(&model.model_id, effort),
); );
if !seen_model_ids.insert(model_key.clone()) { let legacy_key = model_key.clone();
use sha2::{Digest, Sha256}; let legacy_unclaimed = legacy_reserved_model_ids.insert(legacy_key.clone());
let connection_hash = if let Some(suffix) = &connection_suffix {
format!("{:x}", Sha256::digest(connection_identity.as_bytes())); model_key = format!("{model_key}::connection::{suffix}");
model_key = format!("{model_key}::connection::{connection_hash}"); }
if !seen_model_ids.insert(model_key.clone()) { if !seen_model_ids.insert(model_key.clone()) {
continue; continue;
} }
}
// Disabled entries still reserve their selection keys so // Disabled entries still reserve their selection keys so
// toggling a connection cannot silently reroute another // toggling a connection cannot silently reroute another
// connection's saved model selection. // connection's saved model selection.
@@ -1296,10 +1334,14 @@ impl LLMPreferences {
continue; continue;
} }
if legacy_unclaimed && reasoning_effort.is_some() && legacy_key != model_key {
legacy_reasoning_aliases.insert(LLMId::from(legacy_key), LLMId::from(model_key.clone()));
}
// Register the routing entry. Reasoning variants keep the provider's // Register the routing entry. Reasoning variants keep the provider's
// actual model ID while using their synthetic key only for selection. // actual model ID while using their synthetic key only for selection.
let client_config = OpenAIClientConfig { let client_config = OpenAIClientConfig {
kind: provider_kind, kind: provider_kind,
connection_identity: Some(connection_identity.clone()),
base_url: base_url.clone(), base_url: base_url.clone(),
api_key: api_key.clone(), api_key: api_key.clone(),
project_id: provider_project_id.clone(), project_id: provider_project_id.clone(),
@@ -1322,6 +1364,7 @@ impl LLMPreferences {
display_name, display_name,
base_model_name: model.display_name.clone(), base_model_name: model.display_name.clone(),
reasoning_level: reasoning_effort, reasoning_level: reasoning_effort,
reasoning_model_id: Some(model.model_id.clone()),
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -1354,6 +1397,18 @@ impl LLMPreferences {
} }
} }
// An exact key, including one reserved by a disabled model, always wins.
legacy_reasoning_aliases.retain(|legacy_id, _| !seen_model_ids.contains(legacy_id.as_str()));
for (legacy_id, canonical_id) in &legacy_reasoning_aliases {
if let Some(config) = self.openai_provider_routing.get(canonical_id.as_str()).cloned() {
self.openai_provider_routing.entry(legacy_id.to_string()).or_insert(config);
}
}
self.models_by_feature.agent_mode.legacy_reasoning_aliases = legacy_reasoning_aliases.clone();
self.models_by_feature.coding.legacy_reasoning_aliases = legacy_reasoning_aliases.clone();
if let Some(cli) = &mut self.models_by_feature.cli_agent {
cli.legacy_reasoning_aliases = legacy_reasoning_aliases;
}
log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices"); log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices");
} }
@@ -1476,6 +1531,7 @@ impl LLMPreferences {
display_name, display_name,
base_model_name: base_model_name.to_owned(), base_model_name: base_model_name.to_owned(),
reasoning_level: reasoning.map(|reasoning| reasoning.name.clone()), reasoning_level: reasoning.map(|reasoning| reasoning.name.clone()),
reasoning_model_id: None,
usage_metadata: LLMUsageMetadata { usage_metadata: LLMUsageMetadata {
request_multiplier: 1, request_multiplier: 1,
credit_multiplier: None, credit_multiplier: None,
@@ -1998,7 +2054,7 @@ impl LLMPreferences {
provider: None, provider: None,
supports_system_messages: Some(true), supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(), capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: model.reasoning_efforts,
enabled: true, enabled: true,
}) })
.collect() .collect()
@@ -2210,13 +2266,23 @@ impl LLMPreferences {
.data() .data()
.computer_use_model .computer_use_model
.clone() .clone()
.and_then(|id| { .and_then(|id| self.computer_use_llm_info_for_id(&id, app))
self.get_computer_use_llm_choices(app)
.find(|info| info.id == id)
})
.unwrap_or_else(|| self.get_default_computer_use_model()) .unwrap_or_else(|| self.get_default_computer_use_model())
} }
/// Resolve migrated selection IDs before checking computer-use eligibility.
/// Looking up the canonical ID in these choices preserves the vision filter
/// and any computer-use-specific metadata, including disable reasons.
fn computer_use_llm_info_for_id<'a>(
&'a self,
id: &LLMId,
app: &'a AppContext,
) -> Option<&'a LLMInfo> {
let canonical_id = self.get_llm_info(id).map_or(id, |info| &info.id);
self.get_computer_use_llm_choices(app)
.find(|info| &info.id == canonical_id)
}
/// Returns the default computer use model as a fallback. /// Returns the default computer use model as a fallback.
pub fn get_default_computer_use_model(&self) -> &LLMInfo { pub fn get_default_computer_use_model(&self) -> &LLMInfo {
self.get_computer_use_available().default_llm_info() self.get_computer_use_available().default_llm_info()
@@ -2719,8 +2785,7 @@ impl LLMPreferences {
} }
if let Some(preferred_llm_id) = &profile.data().computer_use_model { if let Some(preferred_llm_id) = &profile.data().computer_use_model {
if self if self
.get_computer_use_llm_choices(ctx) .computer_use_llm_info_for_id(preferred_llm_id, ctx)
.find(|info| info.id == *preferred_llm_id)
.filter(|info| { .filter(|info| {
let has_byok_key = let has_byok_key =
is_using_api_key_for_provider(&info.provider, ctx); is_using_api_key_for_provider(&info.provider, ctx);
@@ -2822,9 +2887,12 @@ pub(crate) fn openai_model_context_size(
.max_input_tokens .max_input_tokens
.unwrap_or(model.context_size) .unwrap_or(model.context_size)
.min(model.context_size); .min(model.context_size);
if provider_kind == OpenAIProviderKind::ChatGPTSubscription { if matches!(
// The catalog's default window is the supported session budget. The provider_kind,
// advertised maximum must not silently opt subscriptions into long context. OpenAIProviderKind::ChatGPTSubscription | OpenAIProviderKind::LiteLLM
) {
// Use the efficient session window for both subscriptions and proxies.
// An advertised maximum does not opt a connection into long context.
// Older saved catalogs kept the default in max_input_tokens instead. // Older saved catalogs kept the default in max_input_tokens instead.
return model return model
.default_context_size .default_context_size
@@ -2962,7 +3030,7 @@ fn openai_model_context_window(
is_configurable: max_context_size > default_context_size, is_configurable: max_context_size > default_context_size,
min: default_context_size, min: default_context_size,
max: max_context_size, max: max_context_size,
// Subscription limits are already capped to the catalog's default above. // Subscription and LiteLLM limits are capped to the catalog default above.
default_max: max_context_size, default_max: max_context_size,
} }
} }
@@ -2974,6 +3042,83 @@ fn u32_from_json_any(value: &serde_json::Value, keys: &[&str]) -> Option<u32> {
.and_then(|value| u32::try_from(value).ok()) .and_then(|value| u32::try_from(value).ok())
} }
/// Read enumerated effort support, never infer choices from model names or a
/// generic supports_reasoning flag. Explicit lists take precedence over flags.
#[cfg(not(target_family = "wasm"))]
fn advertised_reasoning_efforts(value: &serde_json::Value) -> Vec<String> {
for key in [
"reasoning_effort_levels",
"supported_reasoning_efforts",
"reasoning_efforts",
"supported_reasoning_levels",
] {
if let Some(levels) = value[key].as_array() {
let mut result = Vec::new();
for level in levels {
if let Some(effort) = level.as_str().or_else(|| level["effort"].as_str()) {
let effort = effort.trim();
if !effort.is_empty() && !result.iter().any(|entry| entry == effort) {
result.push(effort.to_string());
}
}
}
return result;
}
}
["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
.into_iter()
.filter(|level| value[format!("supports_{level}_reasoning_effort")].as_bool() == Some(true))
.map(str::to_string)
.collect()
}
/// Keep an endpoint's recommended window separate from its optional expanded
/// maximum. A bare model ID carries no token metadata, so it must not replace
/// richer limits when the two catalog endpoints are combined.
#[cfg(not(target_family = "wasm"))]
fn openai_context_limits(metadata: &serde_json::Value) -> (u32, Option<u32>, Option<u32>) {
let positive_limit = |keys: &[&str]| {
keys.iter().find_map(|key| {
metadata[*key]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.filter(|value| *value > 0)
})
};
let max_input_tokens =
positive_limit(&["max_input_tokens", "input_token_limit", "max_prompt_tokens"]);
let default_context_size = positive_limit(&[
"default_context_size",
"default_context_window",
"context_window",
]);
let maximum_context_size = positive_limit(&[
"max_context_window",
"max_model_len",
"context_size",
"token_size",
]);
let effective_context_percent = metadata["effective_context_window_percent"]
.as_u64()
.filter(|value| (1..=100).contains(value))
.unwrap_or(100);
// A proxy may already reduce max_input_tokens by this percentage. Apply it
// only to the original session window, never to that input limit again.
let default_context_size = default_context_size
.map(|value| {
u32::try_from(u64::from(value) * effective_context_percent / 100)
.unwrap_or(value)
.max(1)
})
.or(max_input_tokens)
.or(maximum_context_size);
let context_size = maximum_context_size
.or(default_context_size)
.or(max_input_tokens)
.unwrap_or(DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE);
(context_size, max_input_tokens, default_context_size)
}
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
fn normalize_codex_release_version(version: &str) -> Option<String> { fn normalize_codex_release_version(version: &str) -> Option<String> {
let version = version.trim(); let version = version.trim();
@@ -3125,17 +3270,7 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIMod
.any(|modality| modality.as_str() == Some("image")) .any(|modality| modality.as_str() == Some("image"))
}) })
.unwrap_or(true); .unwrap_or(true);
let reasoning_efforts = model["supported_reasoning_levels"] let reasoning_efforts = advertised_reasoning_efforts(model);
.as_array()
.map(|levels| {
levels
.iter()
.filter_map(|level| level["effort"].as_str())
.filter(|effort| !effort.trim().is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(OpenAIModelConfig { Some(OpenAIModelConfig {
model_id: model_id.to_string(), model_id: model_id.to_string(),
@@ -3212,19 +3347,11 @@ async fn fetch_from_litellm_model_info(
let model_name = entry["model_name"].as_str()?; let model_name = entry["model_name"].as_str()?;
let model_info = &entry["model_info"]; let model_info = &entry["model_info"];
let max_input_tokens = model_info["max_input_tokens"] let (context_size, max_input_tokens, default_context_size) =
.as_u64() openai_context_limits(model_info);
.and_then(|v| u32::try_from(v).ok());
let max_output_tokens = model_info["max_output_tokens"] let max_output_tokens = model_info["max_output_tokens"]
.as_u64() .as_u64()
.and_then(|v| u32::try_from(v).ok()); .and_then(|v| u32::try_from(v).ok());
let context_size = u32_from_json_any(
model_info,
&["max_context_window", "context_window", "max_model_len"],
)
.or(max_input_tokens)
.unwrap_or(200_000);
let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(true); let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(true);
let display_name = model_name.replace(['-', '_'], " "); let display_name = model_name.replace(['-', '_'], " ");
@@ -3274,7 +3401,7 @@ async fn fetch_from_litellm_model_info(
vision_supported, vision_supported,
context_size, context_size,
max_input_tokens, max_input_tokens,
default_context_size: None, default_context_size,
max_output_tokens, max_output_tokens,
provider, provider,
supports_system_messages: if model_name.starts_with("codex-gpt-") { supports_system_messages: if model_name.starts_with("codex-gpt-") {
@@ -3283,7 +3410,7 @@ async fn fetch_from_litellm_model_info(
model_info["supports_system_messages"].as_bool() model_info["supports_system_messages"].as_bool()
}, },
capability_overrides: std::collections::HashMap::new(), capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: advertised_reasoning_efforts(model_info),
enabled: true, enabled: true,
}) })
}) })
@@ -3321,7 +3448,33 @@ async fn fetch_litellm_catalog(
metadata metadata
.iter() .iter()
.find(|info| info.model_id == model.model_id) .find(|info| info.model_id == model.model_id)
.cloned() .map(|info| {
let mut enriched = info.clone();
// /model/info supplies capabilities, but must not erase a
// smaller session budget supplied by /models (or vice versa).
enriched.default_context_size = model
.default_context_size
.into_iter()
.chain(info.default_context_size)
.min();
enriched.max_input_tokens = model
.max_input_tokens
.into_iter()
.chain(info.max_input_tokens)
.min();
enriched.context_size = match (
model.default_context_size,
info.default_context_size,
) {
(Some(_), Some(_)) => model.context_size.max(info.context_size),
(Some(_), None) => model.context_size,
_ => info.context_size,
};
if enriched.reasoning_efforts.is_empty() {
enriched.reasoning_efforts = model.reasoning_efforts.clone();
}
enriched
})
.unwrap_or(model) .unwrap_or(model)
}) })
.collect()) .collect())
@@ -3372,21 +3525,7 @@ async fn fetch_from_openai_models(
.iter() .iter()
.filter_map(|m| { .filter_map(|m| {
let id = m["id"].as_str()?; let id = m["id"].as_str()?;
let max_input_tokens = u32_from_any( let (context_size, max_input_tokens, default_context_size) = openai_context_limits(m);
m,
&["max_input_tokens", "input_token_limit", "max_prompt_tokens"],
);
let context_size = u32_from_any(
m,
&[
"max_context_window",
"max_model_len",
"context_window",
"token_size",
],
)
.or(max_input_tokens)
.unwrap_or(200_000);
let max_output_tokens = u32_from_any( let max_output_tokens = u32_from_any(
m, m,
&[ &[
@@ -3433,7 +3572,7 @@ async fn fetch_from_openai_models(
.unwrap_or(true), .unwrap_or(true),
context_size, context_size,
max_input_tokens, max_input_tokens,
default_context_size: None, default_context_size,
max_output_tokens, max_output_tokens,
provider, provider,
supports_system_messages: if id.starts_with("codex-gpt-") { supports_system_messages: if id.starts_with("codex-gpt-") {
@@ -3442,7 +3581,7 @@ async fn fetch_from_openai_models(
m["supports_system_messages"].as_bool() m["supports_system_messages"].as_bool()
}, },
capability_overrides: std::collections::HashMap::new(), capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: advertised_reasoning_efforts(m),
enabled: true, enabled: true,
}) })
}) })
@@ -3471,13 +3610,5 @@ mod tests;
fn bedrock_discovery_config( fn bedrock_discovery_config(
settings: &AISettings, settings: &AISettings,
) -> crate::ai::provider::client::BedrockClientConfig { ) -> crate::ai::provider::client::BedrockClientConfig {
crate::ai::provider::client::BedrockClientConfig { crate::ai::provider::client::BedrockClientConfig::from_settings(settings)
auth_method: *settings.bedrock_auth_method.value(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
} }
+507
View File
@@ -1,6 +1,10 @@
use serde_json::json;
use warpui::App; use warpui::App;
use super::*; use super::*;
use crate::ai::execution_profiles::model_menu_items::{
collapse_model_reasoning_choices, reasoning_choices_for_model,
};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::mcp::TemplatableMCPServerManager; use crate::ai::mcp::TemplatableMCPServerManager;
use crate::auth::auth_manager::AuthManager; use crate::auth::auth_manager::AuthManager;
@@ -119,6 +123,7 @@ fn llm_info_round_trip_serializes_and_deserializes() {
let wire_json = r#"{ let wire_json = r#"{
"display_name": "claude-3", "display_name": "claude-3",
"base_model_name": "claude-3", "base_model_name": "claude-3",
"reasoning_model_id": "provider/claude-3",
"id": "claude-3", "id": "claude-3",
"usage_metadata": { "request_multiplier": 2, "credit_multiplier": 1.5 }, "usage_metadata": { "request_multiplier": 2, "credit_multiplier": 1.5 },
"description": "A powerful model", "description": "A powerful model",
@@ -159,6 +164,51 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
} }
} }
#[test]
fn live_bedrock_models_are_not_hidden_by_missing_catalog_metadata() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let id = "future.new-live-model";
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.bedrock_enabled.set_value(true, ctx).unwrap();
settings.bedrock_models.set_value(
vec![bedrock_model("future.other-model"), bedrock_model(id)], ctx,
).unwrap();
});
let mut preferences = empty_preferences();
preferences.models_by_feature.agent_mode.default_id = id.into();
preferences.models_by_feature.coding.default_id = id.into();
app.read(|ctx| {
preferences.bedrock_verified_config =
Some(super::bedrock_discovery_config(AISettings::as_ref(ctx)));
preferences.bedrock_verified_model_ids.insert(id.to_owned());
preferences.bedrock_verified_model_ids.insert("future.other-model".to_owned());
preferences.inject_bedrock_models(ctx);
});
assert!(preferences.models_by_feature.agent_mode.choices.iter().any(|model| model.id.as_str() == id));
assert_eq!(preferences.models_by_feature.agent_mode.default_id.as_str(), id);
assert_eq!(preferences.models_by_feature.coding.default_id.as_str(), id);
});
}
#[test]
fn newer_bedrock_discovery_invalidates_older_results() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let preferences = app.add_model(|_| empty_preferences());
let models = [bedrock_model("future.new-live-model")];
preferences.update(&mut app, |preferences, ctx| {
let first = preferences.begin_bedrock_verification();
let latest = preferences.begin_bedrock_verification();
let config = super::bedrock_discovery_config(AISettings::as_ref(ctx));
assert!(!preferences.record_bedrock_verification(first, config.clone(), &models, ctx));
assert!(preferences.bedrock_verified_model_ids.is_empty());
assert!(preferences.record_bedrock_verification(latest, config, &models, ctx));
assert!(preferences.bedrock_verified_model_ids.contains("future.new-live-model"));
});
});
}
fn bedrock_model(model_id: &str) -> BedrockModelConfig { fn bedrock_model(model_id: &str) -> BedrockModelConfig {
BedrockModelConfig { BedrockModelConfig {
enabled: true, enabled: true,
@@ -166,6 +216,7 @@ fn bedrock_model(model_id: &str) -> BedrockModelConfig {
model_id: model_id.to_string(), model_id: model_id.to_string(),
display_name: model_id.to_string(), display_name: model_id.to_string(),
vision_supported: false, vision_supported: false,
..Default::default()
} }
} }
@@ -235,6 +286,7 @@ fn empty_preferences() -> LLMPreferences {
catalog_refresh_generations: HashMap::new(), catalog_refresh_generations: HashMap::new(),
chatgpt_subscription_models_refresh_in_flight: false, chatgpt_subscription_models_refresh_in_flight: false,
bedrock_models_refresh_in_flight: false, bedrock_models_refresh_in_flight: false,
bedrock_verification_generation: 0,
bedrock_verified_config: None, bedrock_verified_config: None,
bedrock_verified_model_ids: HashSet::new(), bedrock_verified_model_ids: HashSet::new(),
acp_selections: HashMap::new(), acp_selections: HashMap::new(),
@@ -256,6 +308,7 @@ fn computer_use_picker_includes_vision_models_without_duplicate_entries() {
text_only.display_name = "Text-only model".to_owned(); text_only.display_name = "Text-only model".to_owned();
preferences.models_by_feature.agent_mode.choices = vec![visual.clone(), text_only]; preferences.models_by_feature.agent_mode.choices = vec![visual.clone(), text_only];
preferences.models_by_feature.computer_use = Some(AvailableLLMs { preferences.models_by_feature.computer_use = Some(AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "computer-use-agent-auto".into(), default_id: "computer-use-agent-auto".into(),
choices: vec![auto, visual], choices: vec![auto, visual],
preferred_codex_model_id: None, preferred_codex_model_id: None,
@@ -451,6 +504,7 @@ fn acp_models_are_injected_only_while_acp_is_enabled() {
catalog_refresh_generations: HashMap::new(), catalog_refresh_generations: HashMap::new(),
chatgpt_subscription_models_refresh_in_flight: false, chatgpt_subscription_models_refresh_in_flight: false,
bedrock_models_refresh_in_flight: false, bedrock_models_refresh_in_flight: false,
bedrock_verification_generation: 0,
bedrock_verified_config: None, bedrock_verified_config: None,
bedrock_verified_model_ids: HashSet::new(), bedrock_verified_model_ids: HashSet::new(),
acp_selections: HashMap::new(), acp_selections: HashMap::new(),
@@ -1141,6 +1195,155 @@ fn chatgpt_default_window_limits_routing_without_a_separate_input_limit() {
); );
} }
#[test]
fn litellm_efficient_windows_limit_model_picker_and_provider_routing() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let models = [("terminal-model", 272_000), ("main-model", 500_000)]
.into_iter()
.map(|(id, efficient_window)| {
let mut model = openai_model(id);
model.context_size = 872_000;
model.max_input_tokens = Some(872_000);
model.default_context_size = Some(efficient_window);
model
})
.collect();
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.openai_enabled.set_value(true, ctx).unwrap();
settings
.openai_providers
.set_value(
vec![OpenAIProviderConfig {
kind: OpenAIProviderKind::LiteLLM,
enabled: true,
name: "LiteLLM".to_owned(),
base_url: "https://proxy.test/v1".to_owned(),
api_key: None,
project_id: None,
location: None,
models,
}],
ctx,
)
.unwrap();
});
let mut preferences = empty_preferences();
app.read(|ctx| preferences.inject_openai_models(ctx));
for (id, efficient_window) in [("terminal-model", 272_000), ("main-model", 500_000)] {
let route = preferences.openai_client_config_for_model(id).unwrap();
assert_eq!(route.max_input_tokens, Some(efficient_window));
let model = preferences
.models_by_feature
.agent_mode
.choices
.iter()
.find(|model| model.id.as_str() == id)
.unwrap();
assert_eq!(model.context_window.max, efficient_window);
assert_eq!(model.context_window.default_max, efficient_window);
assert!(!model.context_window.is_configurable);
}
});
}
#[tokio::test]
async fn litellm_discovery_preserves_efficient_windows_across_catalog_endpoints() {
let mut server = mockito::Server::new_async().await;
for (model_metadata, rich_metadata, maximum, efficient_window, request_budget) in [
(
json!({"id": "model"}),
Some(json!({
"context_window": 500000,
"max_context_window": 872000,
"max_input_tokens": 872000
})),
872_000,
500_000,
500_000,
),
(
json!({"id": "model", "context_window": 272000, "max_context_window": 872000}),
Some(json!({"max_context_window": 872000})),
872_000,
272_000,
272_000,
),
(
json!({"id": "model", "context_window": 272000, "max_context_window": 872000}),
None,
872_000,
272_000,
272_000,
),
(
json!({"id": "model", "context_window": 500000, "max_context_window": 872000}),
Some(json!({"supports_vision": true})),
872_000,
500_000,
500_000,
),
(
json!({"id": "model"}),
Some(json!({
"context_window": 272000,
"max_context_window": 872000,
"max_input_tokens": 258400,
"effective_context_window_percent": 95
})),
872_000,
258_400,
258_400,
),
(
json!({"id": "model"}),
Some(json!({
"context_window": 272000,
"max_context_window": 872000,
"max_input_tokens": 128000
})),
872_000,
272_000,
128_000,
),
] {
let catalog = server
.mock("GET", "/models")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({"data": [model_metadata]}).to_string())
.create_async()
.await;
let metadata = server
.mock("GET", "/model/info")
.with_status(if rich_metadata.is_some() { 200 } else { 404 })
.with_header("content-type", "application/json")
.with_body(
json!({"data": [{"model_name": "model", "model_info": rich_metadata}]}).to_string(),
)
.create_async()
.await;
let models = fetch_litellm_catalog(&server.url(), None, &reqwest::Client::new())
.await
.unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0].context_size, maximum);
assert_eq!(models[0].default_context_size, Some(efficient_window));
assert_eq!(
openai_model_context_size(&models[0], OpenAIProviderKind::LiteLLM),
request_budget
);
let window = openai_model_context_window(&models[0], OpenAIProviderKind::LiteLLM);
assert_eq!(window.default_max, request_budget);
assert_eq!(window.max, request_budget);
assert!(!window.is_configurable);
catalog.assert_async().await;
metadata.assert_async().await;
catalog.remove_async().await;
metadata.remove_async().await;
}
}
#[test] #[test]
fn litellm_refresh_replaces_membership_and_preserves_preferences() { fn litellm_refresh_replaces_membership_and_preserves_preferences() {
let mut retained = openai_model("retained"); let mut retained = openai_model("retained");
@@ -1276,3 +1479,307 @@ fn model_selector_labels_and_reasoning_groups_include_configured_provider() {
other.id = LLMId::from("model-b::connection::second-connection"); other.id = LLMId::from("model-b::connection::second-connection");
assert_ne!(first.reasoning_group_key(), other.reasoning_group_key()); assert_ne!(first.reasoning_group_key(), other.reasoning_group_key());
} }
#[test]
fn reasoning_discovery_uses_explicit_lists_and_supported_flags_only() {
assert_eq!(
advertised_reasoning_efforts(&serde_json::json!({
"reasoning_effort_levels": [" low ", "high", "low", "", null],
"supports_max_reasoning_effort": true
})),
["low", "high"]
);
assert_eq!(
advertised_reasoning_efforts(&serde_json::json!({
"supported_reasoning_levels": [{"effort": "minimal"}, {"effort": "high"}]
})),
["minimal", "high"]
);
assert_eq!(
advertised_reasoning_efforts(&serde_json::json!({
"supports_reasoning": true,
"supports_low_reasoning_effort": true,
"supports_high_reasoning_effort": false,
"supports_xhigh_reasoning_effort": true
})),
["low", "xhigh"]
);
assert!(advertised_reasoning_efforts(&serde_json::json!({
"id": "gpt-test-high", "supports_reasoning": true
})).is_empty());
assert!(advertised_reasoning_efforts(&serde_json::json!({
"reasoning_effort_levels": [], "supports_low_reasoning_effort": true
})).is_empty());
}
#[test]
fn overlapping_connections_keep_all_reasoning_variants_in_one_family() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.openai_enabled.set_value(true, ctx).unwrap();
let mut reasoning_model = openai_model("shared-model");
reasoning_model.reasoning_efforts = vec!["low".to_owned(), "high".to_owned()];
settings.openai_providers.set_value(vec![
OpenAIProviderConfig {
kind: OpenAIProviderKind::LiteLLM,
name: "First connection".to_owned(),
base_url: "https://first.test/v1".to_owned(),
models: vec![openai_model("shared-model")],
enabled: true,
api_key: None,
project_id: None,
location: None,
},
OpenAIProviderConfig {
kind: OpenAIProviderKind::LiteLLM,
name: "Second connection".to_owned(),
base_url: "https://second.test/v1".to_owned(),
models: vec![reasoning_model],
enabled: true,
api_key: None,
project_id: None,
location: None,
},
], ctx).unwrap();
});
let mut preferences = empty_preferences();
app.read(|ctx| preferences.inject_openai_models(ctx));
let choices: Vec<_> = preferences.models_by_feature.agent_mode.choices.iter()
.filter(|model| model.description.as_deref() == Some("Second connection"))
.collect();
assert_eq!(choices.len(), 3);
let base = choices.iter().find(|model| model.reasoning_level.is_none()).unwrap();
assert!(base.id.as_str().contains("::connection::"));
let group_key = base.reasoning_group_key();
for model in choices {
assert_eq!(model.reasoning_group_key(), group_key);
let route = preferences.openai_client_config_for_model(model.id.as_str()).unwrap();
assert_eq!(route.model.as_deref(), Some("shared-model"));
assert_eq!(route.reasoning_effort, model.reasoning_level);
}
let old_id = LLMId::from("shared-model::reasoning::low");
let canonical = preferences.get_llm_info(&old_id).unwrap();
assert_eq!(canonical.description.as_deref(), Some("Second connection"));
assert!(canonical.id.as_str().contains("::connection::"));
assert_eq!(preferences.openai_client_config_for_model(old_id.as_str()).unwrap().reasoning_effort.as_deref(), Some("low"));
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
providers[1].enabled = false;
let mut third = providers[1].clone();
third.enabled = true;
third.name = "Third connection".to_owned();
third.base_url = "https://third.test/v1".to_owned();
providers.push(third);
settings.openai_providers.set_value(providers, ctx).unwrap();
});
app.read(|ctx| preferences.inject_openai_models(ctx));
// A disabled second connection still reserves its legacy IDs: they
// must not silently migrate to the third connection.
assert!(preferences.get_llm_info(&old_id).is_none());
assert!(preferences
.openai_client_config_for_model(old_id.as_str())
.is_none());
});
}
#[test]
fn discovered_models_with_the_same_label_keep_separate_reasoning_choices() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let models = ["route-a", "route-b"].map(|id| {
let mut model = openai_model(id);
model.display_name = "Shared model name".to_owned();
model.reasoning_efforts = vec!["low".to_owned(), "high".to_owned()];
model
});
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.openai_enabled.set_value(true, ctx).unwrap();
settings
.openai_providers
.set_value(
vec![OpenAIProviderConfig {
kind: OpenAIProviderKind::LiteLLM,
name: "Shared connection".to_owned(),
base_url: "https://models.test/v1".to_owned(),
models: models.into(),
enabled: true,
api_key: None,
project_id: None,
location: None,
}],
ctx,
)
.unwrap();
});
let mut preferences = empty_preferences();
app.read(|ctx| preferences.inject_openai_models(ctx));
let choices: Vec<_> = preferences
.models_by_feature
.agent_mode
.choices
.iter()
.collect();
let selected_id = LLMId::from("route-b::reasoning::high");
let collapsed = collapse_model_reasoning_choices(choices.clone(), Some(&selected_id));
assert_eq!(collapsed.len(), 2);
assert_eq!(collapsed[0].id.as_str(), "route-a");
assert_eq!(collapsed[1].id, selected_id);
let levels = reasoning_choices_for_model(&selected_id, &choices);
assert_eq!(levels.len(), 3);
for model in levels {
let route = preferences
.openai_client_config_for_model(model.id.as_str())
.unwrap();
assert_eq!(route.model.as_deref(), Some("route-b"));
assert_eq!(route.reasoning_effort, model.reasoning_level);
}
});
}
#[test]
fn migrated_computer_use_reasoning_selection_survives_reconciliation() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AuthManager::new_for_test);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(UserWorkspaces::default_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(TeamTesterStatus::mock);
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
let profiles = app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
});
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.openai_enabled.set_value(true, ctx).unwrap();
let mut visual = openai_model("shared-model");
visual.vision_supported = true;
visual.reasoning_efforts = vec!["high".to_owned()];
settings
.openai_providers
.set_value(
vec![
OpenAIProviderConfig {
kind: OpenAIProviderKind::LiteLLM,
name: "First connection".to_owned(),
base_url: "https://first.test/v1".to_owned(),
models: vec![openai_model("shared-model")],
enabled: true,
api_key: None,
project_id: None,
location: None,
},
OpenAIProviderConfig {
kind: OpenAIProviderKind::ChatGPTSubscription,
name: "Second connection".to_owned(),
base_url: "https://second.test/v1".to_owned(),
models: vec![visual],
enabled: true,
api_key: None,
project_id: None,
location: None,
},
],
ctx,
)
.unwrap();
});
let old_id = LLMId::from("shared-model::reasoning::high");
let mut preferences = empty_preferences();
preferences.models_by_feature.computer_use = Some(default_computer_use_llms());
app.read(|ctx| preferences.inject_openai_models(ctx));
let canonical_id = preferences.get_llm_info(&old_id).unwrap().id.clone();
assert_ne!(canonical_id, old_id);
preferences.models_by_feature.agent_mode.default_id = "shared-model".into();
let preferences = app.add_singleton_model(|_| preferences);
let profile_id = profiles.as_ref(&app).default_profile_id();
profiles.update(&mut app, |profiles, ctx| {
profiles.set_computer_use_model(profile_id, Some(old_id.clone()), ctx);
});
app.read(|ctx| {
let selected = preferences
.as_ref(ctx)
.get_active_computer_use_model(ctx, None);
assert_eq!(selected.id, canonical_id);
assert_eq!(selected.reasoning_level.as_deref(), Some("high"));
});
preferences.update(&mut app, |preferences, ctx| {
preferences.reconcile_disabled_model_preferences(ctx);
});
app.read(|ctx| {
assert_eq!(
profiles
.as_ref(ctx)
.default_profile(ctx)
.data()
.computer_use_model,
Some(old_id.clone())
);
});
// A migrated ID must still obey the normal permanent-disable policy.
preferences.update(&mut app, |preferences, ctx| {
preferences
.models_by_feature
.agent_mode
.choices
.iter_mut()
.find(|model| model.id == canonical_id)
.unwrap()
.disable_reason = Some(DisableReason::AdminDisabled);
preferences.reconcile_disabled_model_preferences(ctx);
});
app.read(|ctx| {
assert_eq!(
profiles
.as_ref(ctx)
.default_profile(ctx)
.data()
.computer_use_model,
None
);
});
// Losing screenshot support must also invalidate the migrated selection.
profiles.update(&mut app, |profiles, ctx| {
profiles.set_computer_use_model(profile_id, Some(old_id.clone()), ctx);
});
preferences.update(&mut app, |preferences, ctx| {
let model = preferences
.models_by_feature
.agent_mode
.choices
.iter_mut()
.find(|model| model.id == canonical_id)
.unwrap();
model.disable_reason = None;
model.vision_supported = false;
assert_eq!(
preferences
.get_active_computer_use_model(ctx, None)
.id
.as_str(),
"computer-use-agent-auto"
);
preferences.reconcile_disabled_model_preferences(ctx);
});
app.read(|ctx| {
assert_eq!(
profiles
.as_ref(ctx)
.default_profile(ctx)
.data()
.computer_use_model,
None
);
});
});
}
@@ -150,6 +150,7 @@ impl TemplatableMCPServerManager {
// Pass the state value through as the CSRF token; rmcp will validate it // Pass the state value through as the CSRF token; rmcp will validate it
// against the token it stored when generating the authorization URL. // against the token it stored when generating the authorization URL.
csrf_token: state.to_string(), csrf_token: state.to_string(),
issuer: query_params.get("iss").map(|issuer| issuer.to_string()),
}, },
None => CallbackResult::Error { None => CallbackResult::Error {
error: error.map(|e| e.to_string()), error: error.map(|e| e.to_string()),
@@ -1887,3 +1888,7 @@ impl TemplatableMCPServerManager {
.contains_key(&installation_hash) .contains_key(&installation_hash)
} }
} }
#[cfg(test)]
#[path = "native_tests.rs"]
mod tests;
@@ -0,0 +1,37 @@
use futures_util::stream::AbortHandle;
use super::*;
#[test]
fn oauth_callback_preserves_decoded_issuer() {
let mut manager = TemplatableMCPServerManager::default();
let installation_uuid = Uuid::new_v4();
let (abort_handle, _) = AbortHandle::new_pair();
let (oauth_result_tx, oauth_result_rx) = async_channel::unbounded();
manager.spawned_servers.insert(
installation_uuid,
SpawnedServerInfo {
abort_handle,
oauth_result_tx,
},
);
manager
.pending_oauth_csrf
.insert("test-state".to_string(), installation_uuid);
let callback_url = Url::parse(
"galaxydev://mcp/oauth2callback?code=test-code&state=test-state&iss=https%3A%2F%2Fmcp.linear.app",
).unwrap();
manager.handle_oauth_callback(&callback_url).unwrap();
match oauth_result_rx.try_recv().unwrap() {
CallbackResult::Success {
code,
csrf_token,
issuer,
} => {
assert_eq!(code, "test-code");
assert_eq!(csrf_token, "test-state");
assert_eq!(issuer.as_deref(), Some("https://mcp.linear.app"));
}
CallbackResult::Error { error } => panic!("unexpected callback error: {error:?}"),
}
}
+10 -1
View File
@@ -4,6 +4,8 @@
//! The side panel Warp AI implementation lives in `super::ai_assistant`. //! The side panel Warp AI implementation lives in `super::ai_assistant`.
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub(crate) mod acp; pub(crate) mod acp;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod activity;
pub(crate) mod active_agent_views_model; pub(crate) mod active_agent_views_model;
pub(crate) mod agent; pub(crate) mod agent;
pub(crate) mod agent_conversations_model; pub(crate) mod agent_conversations_model;
@@ -18,6 +20,8 @@ pub(crate) mod attachment_utils;
pub mod auth_secret_types; pub mod auth_secret_types;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub mod aws_credentials; pub mod aws_credentials;
#[cfg(all(not(target_family = "wasm"), feature = "local_fs"))]
pub(crate) mod background_jobs;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub(crate) mod bedrock_credentials; pub(crate) mod bedrock_credentials;
pub(crate) mod block_context; pub(crate) mod block_context;
@@ -77,6 +81,7 @@ pub mod connected_self_hosted_workers;
pub mod execution_profiles; pub mod execution_profiles;
pub mod facts; pub mod facts;
pub(crate) mod generate_block_title; pub(crate) mod generate_block_title;
pub(crate) mod grep_query;
pub(crate) mod generate_code_review_content; pub(crate) mod generate_code_review_content;
pub(crate) mod loading; pub(crate) mod loading;
pub mod mcp; pub mod mcp;
@@ -85,10 +90,14 @@ pub mod outline;
pub(crate) use ai::paths; pub(crate) use ai::paths;
pub fn init(app: &mut AppContext) { pub fn init(app: &mut AppContext) {
#[cfg(all(not(target_family = "wasm"), feature = "local_fs"))]
app.add_singleton_model(background_jobs::BackgroundJobsModel::new);
#[cfg(not(target_family = "wasm"))]
app.add_singleton_model(provider::authentication::BedrockAuthentication::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(acp::AcpRuntimeModel::new); app.add_singleton_model(acp::AcpRuntimeModel::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new()); app.add_singleton_model(chatgpt_auth::ChatGPTAuthModel::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new); app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
blocklist::keyboard_navigable_buttons::init(app); blocklist::keyboard_navigable_buttons::init(app);
+8 -1
View File
@@ -9,6 +9,8 @@ use crate::settings::OpenAIProviderKind;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct OpenAIClientConfig { pub struct OpenAIClientConfig {
pub kind: OpenAIProviderKind, pub kind: OpenAIProviderKind,
/// Stable registry connection identity, excluding credentials.
pub connection_identity: Option<String>,
pub base_url: String, pub base_url: String,
pub api_key: Option<String>, pub api_key: Option<String>,
pub project_id: Option<String>, pub project_id: Option<String>,
@@ -21,6 +23,7 @@ pub struct OpenAIClientConfig {
} }
pub struct OpenAIClient { pub struct OpenAIClient {
reasoning_effort: Option<String>,
http: reqwest::Client, http: reqwest::Client,
base_url: String, base_url: String,
api_key: Option<String>, api_key: Option<String>,
@@ -54,6 +57,7 @@ impl OpenAIClient {
pub fn from_config(config: OpenAIClientConfig) -> Self { pub fn from_config(config: OpenAIClientConfig) -> Self {
let http = reqwest::Client::new(); let http = reqwest::Client::new();
Self { Self {
reasoning_effort: config.reasoning_effort,
http, http,
base_url: config.base_url, base_url: config.base_url,
api_key: config.api_key, api_key: config.api_key,
@@ -62,9 +66,12 @@ impl OpenAIClient {
pub async fn chat_completions_stream( pub async fn chat_completions_stream(
&self, &self,
request_body: serde_json::Value, mut request_body: serde_json::Value,
) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>>, OpenAIError> { ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>>, OpenAIError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
if let Some(effort) = &self.reasoning_effort {
request_body["reasoning_effort"] = effort.clone().into();
}
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
+2
View File
@@ -663,6 +663,8 @@ fn estimate_cost_cents(
} }
const KNOWN_TOOLS: &[&str] = &[ const KNOWN_TOOLS: &[&str] = &[
"request_computer_use",
"use_computer",
"run_shell_command", "run_shell_command",
"read_files", "read_files",
"apply_file_diffs", "apply_file_diffs",
+3 -2
View File
@@ -136,11 +136,12 @@ fn apply_file_diffs() -> ToolDefinition {
fn grep() -> ToolDefinition { fn grep() -> ToolDefinition {
ToolDefinition { ToolDefinition {
name: "grep".to_string(), name: "grep".to_string(),
description: "Search for up to 3 focused regex patterns per call. Use for exact symbols, strings, imports, and call sites in source code or other text files. Scope to known files or directories when possible; use search_codebase for semantic discovery. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches.".to_string(), description: "Search for up to 3 focused patterns per call. Regex is the default; set fixed_strings=true for literal source text containing brackets, parentheses, or other regex metacharacters. Use for exact symbols, strings, imports, and call sites in source code or other text files. Scope to known files or directories when possible; use search_codebase for semantic discovery. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"queries": { "type": "array", "maxItems": 3, "items": { "type": "string" }, "description": "Regex patterns to search for (maximum 3 per call)" }, "queries": { "type": "array", "maxItems": 3, "items": { "type": "string" }, "description": "Patterns (literal when fixed_strings=true) to search for (maximum 3 per call)" },
"fixed_strings": { "type": "boolean", "default": false, "description": "Treat every query as literal text instead of regex" },
"path": { "type": "string", "description": "Directory to scope the search to" } "path": { "type": "string", "description": "Directory to scope the search to" }
}, },
"required": ["queries"] "required": ["queries"]
+333
View File
@@ -0,0 +1,333 @@
use std::collections::HashMap;
use std::time::SystemTime;
use ai::api_keys::{ApiKeyManager, AwsCredentials, AwsCredentialsRefreshStrategy, AwsCredentialsState};
use futures::channel::oneshot;
use futures::future::{BoxFuture, Shared};
use futures::FutureExt;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use settings::Setting;
use super::client::{BedrockClientConfig, BedrockError};
use crate::ai::agent::RenderableAIError;
use crate::settings::{AISettings, AISettingsChangedEvent, BedrockAuthMethod};
impl BedrockClientConfig {
pub(crate) fn from_settings(settings: &AISettings) -> Self {
Self {
auth_method: *settings.bedrock_auth_method.value(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
credentials_expiry: None,
invocation_model_id: None,
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
}
pub(crate) fn with_model_route(
mut self,
model_id: &str,
models: &[crate::settings::BedrockModelConfig],
) -> Self {
self.invocation_model_id = models.iter().find(|model| {
model.model_id == model_id
|| model.invocation_model_id == model_id
|| super::models::foundation_model_id(&model.model_id)
== super::models::foundation_model_id(model_id)
}).and_then(|model| {
(!model.invocation_model_id.is_empty()).then(|| model.invocation_model_id.clone())
});
self
}
pub(crate) fn with_managed_credentials(
mut self,
strategy: &AwsCredentialsRefreshStrategy,
state: &AwsCredentialsState,
) -> Self {
match strategy {
AwsCredentialsRefreshStrategy::LocalChain => self,
AwsCredentialsRefreshStrategy::OidcManaged { region, .. } => {
self.auth_method = BedrockAuthMethod::StaticKeys;
self.region.clone_from(region);
self.access_key_id.clear();
self.secret_access_key.clear();
self.session_token = None;
self.credentials_expiry = None;
if let AwsCredentialsState::Loaded { credentials, .. } = state {
self.access_key_id = credentials.access_key().to_owned();
self.secret_access_key = credentials.secret_key().to_owned();
self.session_token = credentials.session_token().map(str::to_owned);
self.credentials_expiry = Some(
credentials.expires_at().unwrap_or(SystemTime::UNIX_EPOCH),
);
}
self
}
}
}
}
pub(crate) fn credentials_are_current(expiry: Option<SystemTime>, now: SystemTime) -> bool {
expiry.is_none_or(|expiry| expiry > now)
}
pub(crate) fn provider_startup_error(error: &anyhow::Error, model: &str) -> RenderableAIError {
match error.downcast_ref::<BedrockError>() {
Some(BedrockError::CredentialsNotConfigured | BedrockError::CredentialsLoadFailed(_)) => {
RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid {
model_name: model.to_owned(),
}
}
Some(
BedrockError::RegionNotConfigured
| BedrockError::ApiError(_)
| BedrockError::ModelNotFound(_)
| BedrockError::AccessDenied(_)
| BedrockError::Throttling(_)
| BedrockError::ValidationError(_),
) | None => RenderableAIError::other(error.to_string(), false),
}
}
pub(crate) fn login_command(
configured: &str,
profile: &str,
) -> String {
if configured != "aws sso login" {
return configured.to_owned();
}
if profile.is_empty() {
configured.to_owned()
} else {
format!("aws sso login --profile {}", shell_words::quote(profile))
}
}
pub(crate) fn login_command_parts(command: &str) -> Result<Vec<String>, String> {
let parts = shell_words::split(command)
.map_err(|_| "AWS login command contains invalid quoting".to_owned())?;
if parts.is_empty() {
return Err("AWS login command is not configured".to_owned());
}
Ok(parts)
}
#[derive(Clone, PartialEq)]
pub(crate) struct BedrockAuthConnection {
pub(crate) config: BedrockClientConfig,
pub(crate) enabled: bool,
pub(crate) auto_login: bool,
pub(crate) command: String,
}
impl BedrockAuthConnection {
pub(crate) fn current(ctx: &AppContext) -> Self {
let settings = AISettings::as_ref(ctx);
let config = BedrockClientConfig::from_settings(settings);
Self {
command: match config.auth_method {
BedrockAuthMethod::Sso => login_command(
settings.bedrock_auth_refresh_command.value(),
&config.profile,
),
BedrockAuthMethod::Profile | BedrockAuthMethod::StaticKeys => String::new(),
},
config,
enabled: *settings.bedrock_enabled.value(),
auto_login: *settings.bedrock_auto_login.value(),
}
}
fn identity(&self) -> String {
format!(
"{:?}:{}:{}:{}",
self.config.auth_method, self.config.profile, self.config.region, self.command
)
}
}
#[derive(Default)]
struct RecoveryAttempts {
exchanges: HashMap<String, (String, String)>,
}
impl RecoveryAttempts {
fn begin(&mut self, conversation: &str, exchange: &str, connection: &str, manual: bool) -> bool {
let attempt = (exchange.to_owned(), connection.to_owned());
if !manual && self.exchanges.get(conversation) == Some(&attempt) {
return false;
}
self.exchanges.insert(conversation.to_owned(), attempt);
true
}
}
type CredentialResult = Result<AwsCredentials, String>;
type CredentialFuture = Shared<BoxFuture<'static, CredentialResult>>;
#[derive(Default)]
pub(crate) struct BedrockAuthentication {
attempts: RecoveryAttempts,
active: Vec<(u64, BedrockAuthConnection, CredentialFuture)>,
generation: u64,
revision: u64,
}
impl BedrockAuthentication {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, _| {
if matches!(
event,
AISettingsChangedEvent::BedrockEnabled { .. }
| AISettingsChangedEvent::BedrockAuthMethod { .. }
| AISettingsChangedEvent::BedrockProfile { .. }
| AISettingsChangedEvent::BedrockRegion { .. }
| AISettingsChangedEvent::BedrockAuthRefreshCommand { .. }
| AISettingsChangedEvent::BedrockAccessKeyId { .. }
| AISettingsChangedEvent::BedrockSecretAccessKey { .. }
) {
me.revision += 1;
me.active.clear();
}
});
Self::default()
}
pub(crate) fn revision(&self) -> u64 {
self.revision
}
pub(crate) fn recover(
&mut self,
conversation: &str,
exchange: &str,
manual: bool,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, Result<(), String>> {
let connection = BedrockAuthConnection::current(ctx);
if !connection.enabled || (!manual && !connection.auto_login) {
return Box::pin(async { Err("Automatic AWS login is disabled".to_owned()) });
}
if !self.attempts.begin(conversation, exchange, &connection.identity(), manual) {
return Box::pin(async {
Err("AWS authentication still failed after recovery. Use Refresh AWS Credentials to retry.".to_owned())
});
}
let strategy = ApiKeyManager::as_ref(ctx).aws_credentials_refresh_strategy();
if matches!(strategy, AwsCredentialsRefreshStrategy::OidcManaged { .. }) {
return ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx)
});
}
if connection.config.auth_method == BedrockAuthMethod::StaticKeys {
return Box::pin(async {
Err("Update the configured AWS static keys to authenticate.".to_owned())
});
}
let (generation, future) = match self.active.iter().find(|(_, active, _)| active == &connection) {
Some((generation, _, future)) => (*generation, future.clone()),
None => {
self.generation += 1;
let config = connection.config.clone();
let command = connection.command.clone();
let future = recover_credentials(
connection.config.auth_method,
manual,
move || {
let config = config.clone();
async move {
crate::ai::aws_credentials::load_aws_credentials_from_sdk(&config)
.await
.map_err(|error| error.to_string())
}.boxed()
},
move || {
let command = command.clone();
async move {
let parts = login_command_parts(&command)?;
let status = tokio::process::Command::new(&parts[0])
.args(&parts[1..])
.status()
.await
.map_err(|error| format!("Failed to start AWS login: {error}"))?;
if !status.success() {
return Err(format!("AWS login command exited with status: {status}"));
}
Ok(())
}.boxed()
},
).boxed().shared();
self.active.push((self.generation, connection.clone(), future.clone()));
(self.generation, future)
}
};
let revision = self.revision;
let (tx, rx) = oneshot::channel();
let _ = ctx.spawn(future, move |me, result, ctx| {
me.active.retain(|(active, _, _)| *active != generation);
if me.revision != revision
|| BedrockAuthConnection::current(ctx) != connection
|| ApiKeyManager::as_ref(ctx).aws_credentials_refresh_strategy() != strategy
{
let _ = tx.send(Err("AWS connection changed during authentication".to_owned()));
return;
}
let outcome = result.as_ref().map(|_| ()).map_err(Clone::clone);
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
let state = match result {
Ok(credentials) => AwsCredentialsState::Loaded {
credentials,
loaded_at: SystemTime::now(),
},
Err(message) => AwsCredentialsState::Failed { message },
};
manager.set_aws_credentials_state(state, ctx);
});
let _ = tx.send(outcome);
});
async move {
rx.await.unwrap_or_else(|_| Err("AWS authentication was interrupted".to_owned()))
}.boxed()
}
}
async fn recover_credentials(
auth_method: BedrockAuthMethod,
login_first: bool,
load: impl Fn() -> BoxFuture<'static, CredentialResult>,
login: impl Fn() -> BoxFuture<'static, Result<(), String>>,
) -> CredentialResult {
match auth_method {
BedrockAuthMethod::Profile => return load().await,
BedrockAuthMethod::StaticKeys => {
return Err("Update the configured AWS static keys to authenticate.".to_owned());
}
BedrockAuthMethod::Sso => {}
}
if !login_first {
if let Ok(credentials) = load().await {
return Ok(credentials);
}
}
login().await?;
load().await
}
impl Entity for BedrockAuthentication {
type Event = ();
}
impl SingletonEntity for BedrockAuthentication {}
pub(crate) fn connection_revision(ctx: &AppContext) -> u64 {
ctx.try_get_singleton_model_as_ref::<BedrockAuthentication>()
.map_or(0, |authentication| authentication.revision())
}
#[cfg(test)]
#[path = "authentication_tests.rs"]
mod tests;
+294
View File
@@ -0,0 +1,294 @@
use std::time::{Duration, SystemTime};
use ai::api_keys::{AwsCredentials, AwsCredentialsRefreshStrategy, AwsCredentialsState};
use super::*;
fn config(auth_method: BedrockAuthMethod) -> BedrockClientConfig {
BedrockClientConfig {
auth_method,
profile: "default".to_owned(),
region: String::new(),
access_key_id: "configured-key".to_owned(),
secret_access_key: "configured-secret".to_owned(),
session_token: None,
credentials_expiry: None,
invocation_model_id: None,
cross_region_inference: true,
}
}
#[test]
fn provider_profile_and_region_are_authoritative_including_defaults() {
galaxyui::App::test((), |mut app| async move {
crate::test_util::settings::initialize_settings_for_tests(&mut app);
for (profile, region) in [("default", ""), ("selected-profile", "eu-west-1")] {
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.bedrock_profile.set_value(profile.to_owned(), ctx).unwrap();
settings.bedrock_region.set_value(region.to_owned(), ctx).unwrap();
});
app.read(|ctx| {
let resolved = BedrockClientConfig::from_settings(AISettings::as_ref(ctx));
assert_eq!(resolved.profile, profile);
assert_eq!(resolved.region, region);
});
}
});
}
#[test]
fn custom_login_commands_are_not_rewritten() {
for command in ["", "company-login --interactive", "aws sso login --profile custom"] {
assert_eq!(login_command(command, "selected"), command);
}
}
#[test]
fn default_login_targets_and_quotes_the_effective_profile() {
assert_eq!(
login_command("aws sso login", "default"),
"aws sso login --profile default"
);
let command = login_command(
"aws sso login",
"team profile",
);
assert_eq!(
login_command_parts(&command).unwrap(),
["aws", "sso", "login", "--profile", "team profile"]
);
assert!(login_command_parts("").is_err());
assert!(login_command_parts("aws 'unterminated").is_err());
}
#[test]
fn local_credential_snapshots_never_override_configured_authentication() {
let now = SystemTime::now();
for auth in [
BedrockAuthMethod::Profile,
BedrockAuthMethod::Sso,
BedrockAuthMethod::StaticKeys,
] {
for expiry in [None, Some(now - Duration::from_secs(1)), Some(now + Duration::from_secs(60))] {
let configured = config(auth);
let loaded = AwsCredentialsState::Loaded {
credentials: AwsCredentials::new(
"snapshot-key".to_owned(),
"snapshot-secret".to_owned(),
Some("snapshot-token".to_owned()),
expiry,
),
loaded_at: now,
};
assert_eq!(
configured.clone().with_managed_credentials(
&AwsCredentialsRefreshStrategy::LocalChain,
&loaded,
),
configured
);
}
}
}
#[test]
fn managed_oidc_credentials_preserve_expiry_and_do_not_fall_back_to_local_keys() {
let now = SystemTime::now();
let strategy = AwsCredentialsRefreshStrategy::OidcManaged {
task_id: Some("task".to_owned()),
role_arn: "test-role".to_owned(),
region: "us-east-1".to_owned(),
};
let loaded = AwsCredentialsState::Loaded {
credentials: AwsCredentials::new(
"managed-key".to_owned(),
"managed-secret".to_owned(),
Some("managed-token".to_owned()),
Some(now + Duration::from_secs(60)),
),
loaded_at: now,
};
let resolved = config(BedrockAuthMethod::Profile).with_managed_credentials(&strategy, &loaded);
assert_eq!(resolved.auth_method, BedrockAuthMethod::StaticKeys);
assert_eq!(resolved.region, "us-east-1");
assert_eq!(resolved.access_key_id, "managed-key");
assert_eq!(resolved.credentials_expiry, Some(now + Duration::from_secs(60)));
for state in [
AwsCredentialsState::Missing,
AwsCredentialsState::Disabled,
AwsCredentialsState::Refreshing,
AwsCredentialsState::Failed { message: "failed".to_owned() },
] {
let resolved = config(BedrockAuthMethod::Profile).with_managed_credentials(&strategy, &state);
assert!(resolved.access_key_id.is_empty());
assert!(resolved.secret_access_key.is_empty());
}
}
#[test]
fn automatic_recovery_is_bounded_per_user_exchange_but_manual_retry_is_available() {
let mut attempts = RecoveryAttempts::default();
assert!(attempts.begin("conversation", "query-1", "connection", false));
assert!(!attempts.begin("conversation", "query-1", "connection", false));
assert!(attempts.begin("conversation", "query-1", "connection", true));
assert!(attempts.begin("conversation", "query-2", "connection", false));
assert!(attempts.begin("conversation", "query-2", "new-connection", false));
assert!(attempts.begin("another-conversation", "query-2", "connection", false));
}
#[test]
fn credential_expiry_is_checked_at_the_boundary() {
let now = SystemTime::now();
assert!(credentials_are_current(None, now));
assert!(credentials_are_current(Some(now + Duration::from_secs(1)), now));
assert!(!credentials_are_current(Some(now), now));
assert!(!credentials_are_current(Some(now - Duration::from_secs(1)), now));
}
#[test]
fn model_routes_use_discovered_ids_and_match_legacy_cross_region_selections() {
let model = crate::settings::BedrockModelConfig {
model_id: "future.new-model".to_owned(),
invocation_model_id: "arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.future.new-model".to_owned(),
..Default::default()
};
for selected in ["future.new-model", "us.future.new-model"] {
let resolved = config(BedrockAuthMethod::Profile)
.with_model_route(selected, &[model.clone()]);
assert_eq!(resolved.invocation_model_id.as_deref(), Some(model.invocation_model_id.as_str()));
}
let resolved = config(BedrockAuthMethod::Profile).with_model_route("unrelated.model", &[model]);
assert_eq!(resolved.invocation_model_id, None);
}
fn test_credentials() -> AwsCredentials {
AwsCredentials::new("test-key".to_owned(), "test-secret".to_owned(), None, None)
}
#[tokio::test]
async fn external_refresh_recovers_without_running_login() {
let credentials = recover_credentials(
BedrockAuthMethod::Sso,
false,
|| futures::future::ready(Ok(test_credentials())).boxed(),
|| futures::future::ready(Err("Login must not run".to_owned())).boxed(),
).await.unwrap();
assert_eq!(credentials, test_credentials());
}
#[tokio::test]
async fn expired_session_runs_login_then_awaits_a_fresh_reload() {
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
let calls = Arc::new(AtomicUsize::new(0));
let load_calls = calls.clone();
let login_calls = calls.clone();
let result = recover_credentials(
BedrockAuthMethod::Sso,
false,
move || {
let index = load_calls.fetch_add(1, Ordering::SeqCst);
let result = match index {
0 => Err("expired".to_owned()),
2 => Ok(test_credentials()),
index => panic!("Unexpected reload order: {index}"),
};
futures::future::ready(result).boxed()
},
move || {
assert_eq!(login_calls.fetch_add(1, Ordering::SeqCst), 1);
futures::future::ready(Ok(())).boxed()
},
).await;
assert_eq!(result, Ok(test_credentials()));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn manual_login_failure_does_not_reload_or_resume() {
let result = recover_credentials(
BedrockAuthMethod::Sso,
true,
|| panic!("Credentials must not reload after failed login"),
|| futures::future::ready(Err("login failed".to_owned())).boxed(),
).await;
assert_eq!(result, Err("login failed".to_owned()));
}
#[tokio::test]
async fn login_success_with_failed_reload_remains_an_error() {
let result = recover_credentials(
BedrockAuthMethod::Sso,
true,
|| futures::future::ready(Err("reload failed".to_owned())).boxed(),
|| futures::future::ready(Ok(())).boxed(),
).await;
assert_eq!(result, Err("reload failed".to_owned()));
}
#[tokio::test]
async fn shared_recovery_executes_login_once_for_overlapping_waiters() {
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
let calls = Arc::new(AtomicUsize::new(0));
let login_calls = calls.clone();
let recovery = recover_credentials(
BedrockAuthMethod::Sso,
true,
|| futures::future::ready(Ok(test_credentials())).boxed(),
move || {
login_calls.fetch_add(1, Ordering::SeqCst);
futures::future::ready(Ok(())).boxed()
},
).boxed().shared();
let (first, second) = futures::join!(recovery.clone(), recovery);
assert_eq!(first, Ok(test_credentials()));
assert_eq!(second, Ok(test_credentials()));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn credential_preparation_failures_surface_authentication_recovery() {
use super::super::client::BedrockError;
for error in [
BedrockError::CredentialsNotConfigured,
BedrockError::CredentialsLoadFailed("SSO session invalid".to_owned()),
] {
let error = provider_startup_error(&anyhow::Error::new(error), "selected-model");
assert!(error.is_aws_bedrock_credentials_error());
}
for error in [
BedrockError::RegionNotConfigured,
BedrockError::ApiError("transport".to_owned()),
BedrockError::ModelNotFound("model".to_owned()),
BedrockError::AccessDenied("policy".to_owned()),
BedrockError::Throttling("busy".to_owned()),
BedrockError::ValidationError("request".to_owned()),
] {
let error = provider_startup_error(&anyhow::Error::new(error), "selected-model");
assert!(!error.is_aws_bedrock_credentials_error());
}
assert!(!provider_startup_error(&anyhow::anyhow!("unrelated"), "selected-model")
.is_aws_bedrock_credentials_error());
}
#[tokio::test]
async fn profile_authentication_never_starts_a_login_flow() {
for manual in [false, true] {
let result = recover_credentials(
BedrockAuthMethod::Profile,
manual,
|| futures::future::ready(Ok(test_credentials())).boxed(),
|| panic!("Profile authentication must not execute login commands"),
).await;
assert_eq!(result, Ok(test_credentials()));
let result = recover_credentials(
BedrockAuthMethod::Profile,
manual,
|| futures::future::ready(Err("profile needs fresh credentials".to_owned())).boxed(),
|| panic!("A failed profile reload must not execute login commands"),
).await;
assert_eq!(result, Err("profile needs fresh credentials".to_owned()));
}
}
+28 -31
View File
@@ -1,4 +1,5 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use anyhow::Result; use anyhow::Result;
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
@@ -27,6 +28,7 @@ fn strip_context_marker(model_id: &str) -> String {
pub struct BedrockClient { pub struct BedrockClient {
runtime_client: BedrockRuntimeClient, runtime_client: BedrockRuntimeClient,
region: String, region: String,
invocation_model_id: Option<String>,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -37,40 +39,18 @@ pub struct BedrockClientConfig {
pub access_key_id: String, pub access_key_id: String,
pub secret_access_key: String, pub secret_access_key: String,
pub session_token: Option<String>, pub session_token: Option<String>,
pub credentials_expiry: Option<SystemTime>,
pub invocation_model_id: Option<String>,
pub cross_region_inference: bool, pub cross_region_inference: bool,
} }
impl BedrockClientConfig {
/// Applies external config (from Claude Code / OpenCode) as fallback values
/// when Galaxy's own settings are at their defaults.
pub fn with_external_fallbacks(mut self) -> Self {
let external = ExternalBedrockConfig::load();
if external.is_empty() {
return self;
}
if self.profile == "default" {
if let Some(profile) = external.profile {
log::info!("[bedrock] Using profile from external config: {profile}");
self.profile = profile;
}
}
if self.region.is_empty() {
if let Some(region) = external.region {
log::info!("[bedrock] Using region from external config: {region}");
self.region = region;
}
}
self
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum BedrockError { pub enum BedrockError {
#[error("Bedrock credentials not configured")] #[error("Bedrock credentials not configured")]
CredentialsNotConfigured, CredentialsNotConfigured,
#[error("Failed to load AWS credentials: {0}")]
CredentialsLoadFailed(String),
#[error("Bedrock region not configured and could not be auto-detected")] #[error("Bedrock region not configured and could not be auto-detected")]
RegionNotConfigured, RegionNotConfigured,
#[error("Bedrock API error: {0}")] #[error("Bedrock API error: {0}")]
@@ -115,12 +95,20 @@ impl BedrockClient {
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() { if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
return Err(BedrockError::CredentialsNotConfigured); return Err(BedrockError::CredentialsNotConfigured);
} }
if !super::authentication::credentials_are_current(
config.credentials_expiry,
SystemTime::now(),
) {
return Err(BedrockError::CredentialsLoadFailed(
"AWS credentials have expired".to_owned(),
));
}
let creds = aws_credential_types::Credentials::new( let creds = aws_credential_types::Credentials::new(
&config.access_key_id, &config.access_key_id,
&config.secret_access_key, &config.secret_access_key,
config.session_token, config.session_token,
None, config.credentials_expiry,
"warp-bedrock-static", "warp-bedrock-static",
); );
@@ -148,11 +136,15 @@ impl BedrockClient {
); );
} }
Err(e) => { Err(e) => {
log::warn!("[bedrock] Failed to resolve AWS credentials from provider: {e:?}"); let message = crate::ai::aws_credentials::user_facing_aws_credentials_error_message(
&e,
&config.profile,
);
return Err(BedrockError::CredentialsLoadFailed(message));
} }
} }
} else { } else {
log::warn!("[bedrock] No credentials provider found in resolved AWS config"); return Err(BedrockError::CredentialsNotConfigured);
} }
let region = aws_config let region = aws_config
@@ -165,6 +157,7 @@ impl BedrockClient {
Ok(Self { Ok(Self {
runtime_client, runtime_client,
region, region,
invocation_model_id: config.invocation_model_id,
}) })
} }
@@ -195,14 +188,16 @@ impl BedrockClient {
tool_result_archive: Vec<ConversationMessage>, tool_result_archive: Vec<ConversationMessage>,
) -> Result<LegacyResponseStream, 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 let Some(model) = &self.invocation_model_id {
model.clone()
} else if cross_region_inference {
apply_cross_region_prefix(&base_model_id, &self.region) apply_cross_region_prefix(&base_model_id, &self.region)
} else { } else {
base_model_id base_model_id
}; };
let external_config = ExternalBedrockConfig::load(); let external_config = ExternalBedrockConfig::load();
let caching_config = CachingConfig::from_external_config(&external_config); let caching_config = CachingConfig::from_external_config(&external_config).for_model(model_id);
if !caching_config.enabled { if !caching_config.enabled {
log::info!("[bedrock] Prompt caching disabled (DISABLE_PROMPT_CACHING=1)"); log::info!("[bedrock] Prompt caching disabled (DISABLE_PROMPT_CACHING=1)");
@@ -216,6 +211,7 @@ impl BedrockClient {
); );
let converted = build_converse_request( let converted = build_converse_request(
model_id,
messages.clone(), messages.clone(),
system_prompt.clone(), system_prompt.clone(),
compact_summary, compact_summary,
@@ -326,6 +322,7 @@ impl BedrockClient {
let caching_config = CachingConfig::from_external_config(&external_config); let caching_config = CachingConfig::from_external_config(&external_config);
let converted = build_converse_request( let converted = build_converse_request(
model_id,
messages, messages,
system_prompt, system_prompt,
None, None,
+19
View File
@@ -8,6 +8,7 @@ use aws_sdk_bedrockruntime::types::{
ToolUseBlock, ToolUseBlock,
}; };
use aws_smithy_types::{Blob, Document}; use aws_smithy_types::{Blob, Document};
use galaxy_agent_rig::prepare_bedrock_history;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use super::external_config::ExternalBedrockConfig; use super::external_config::ExternalBedrockConfig;
@@ -38,6 +39,22 @@ impl CachingConfig {
extended_ttl_requested: external.enable_prompt_caching_1h, extended_ttl_requested: external.enable_prompt_caching_1h,
} }
} }
pub fn for_model(mut self, model_id: &str) -> Self {
let model = galaxy_bedrock_model_catalog::model_metadata(model_id);
let known_claude_4 = model.is_some_and(|metadata| {
metadata.provider_name == "Anthropic"
&& metadata.supports_agent_runtime() == Some(true)
&& (metadata.model_id.starts_with("anthropic.claude-opus-4")
|| metadata.model_id.starts_with("anthropic.claude-sonnet-4")
|| metadata.model_id.starts_with("anthropic.claude-haiku-4"))
});
self.enabled &= known_claude_4;
self.extended_ttl_requested &= self.enabled && model.is_some_and(|metadata| {
["4-5", "4-6", "4-7", "4-8"].iter().any(|version| metadata.model_id.contains(version))
});
self
}
} }
pub struct ConvertedRequest { pub struct ConvertedRequest {
@@ -49,6 +66,7 @@ pub struct ConvertedRequest {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn build_converse_request( pub fn build_converse_request(
model_id: &str,
messages: Vec<ConversationMessage>, messages: Vec<ConversationMessage>,
system_prompt: Option<String>, system_prompt: Option<String>,
compact_summary: Option<String>, compact_summary: Option<String>,
@@ -59,6 +77,7 @@ pub fn build_converse_request(
stop_sequences: Option<Vec<String>>, stop_sequences: Option<Vec<String>>,
caching_config: CachingConfig, caching_config: CachingConfig,
) -> ConvertedRequest { ) -> ConvertedRequest {
let messages = prepare_bedrock_history(model_id, messages);
let bedrock_messages = convert_messages(messages, &caching_config); let bedrock_messages = convert_messages(messages, &caching_config);
let system = convert_system_prompt(system_prompt, compact_summary, &caching_config); let system = convert_system_prompt(system_prompt, compact_summary, &caching_config);
let inference_config = build_inference_config(max_tokens, temperature, top_p, stop_sequences); let inference_config = build_inference_config(max_tokens, temperature, top_p, stop_sequences);

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