Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut,
load_managed_agents, managed_agent_avatar_url, missing_command_message,
known_acp_runtime, load_managed_agents, managed_agent_avatar_url, missing_command_message,
normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest,
UpdateManagedAgentResponse, DEFAULT_MCP_COMMAND,
Expand Down Expand Up @@ -84,11 +84,14 @@ pub async fn get_agent_models(
cmd.arg("models")
.arg("--json")
.env("SPROUT_ACP_AGENT_COMMAND", &agent_command)
.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","))
.env(
"GOOSE_MODE",
std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".into()),
);
.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","));
if let Some(meta) = known_acp_runtime(&agent_command) {
for (key, value) in meta.default_env {
if std::env::var(key).is_err() {
cmd.env(key, value);
}
}
}
// User env layering — written LAST so it overrides any Sprout-set env above.
for (k, v) in &merged_env {
cmd.env(k, v);
Expand Down
4 changes: 0 additions & 4 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,12 @@ pub(crate) struct KnownAcpRuntime {
/// pointing to the canonical `.agents/skills/sprout-cli`. `None` → this
/// runtime reads the canonical path directly or has no skill support.
pub skill_dir: Option<&'static str>,
// Phase 3: these fields are consumed by runtime.rs spawn logic to replace ad-hoc env var injection.
#[allow(dead_code)]
pub supports_acp_model_switching: bool,
#[allow(dead_code)]
pub model_env_var: Option<&'static str>,
#[allow(dead_code)]
pub provider_env_var: Option<&'static str>,
#[allow(dead_code)]
pub provider_locked: bool,
#[allow(dead_code)]
pub default_env: &'static [(&'static str, &'static str)],
}

Expand Down
21 changes: 16 additions & 5 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,8 @@ pub fn spawn_agent_child(
}
// Enable MCP hook tools (_Stop, _PostCompact) for agents that need them.
// Uses "*" because build_mcp_servers() hard-codes the server name to "sprout-mcp".
if known_acp_runtime(&record.agent_command).is_some_and(|p| p.mcp_hooks) {
let runtime_meta = known_acp_runtime(&record.agent_command);
if runtime_meta.is_some_and(|r| r.mcp_hooks) {
command.env("MCP_HOOK_SERVERS", "*");
}
// Only emit SPROUT_ACP_IDLE_TIMEOUT when the user has explicitly set an
Expand All @@ -613,10 +614,13 @@ pub fn spawn_agent_child(
command.env("SPROUT_ACP_AGENTS", record.parallelism.to_string());
command.env("SPROUT_ACP_MULTIPLE_EVENT_HANDLING", "owner-interrupt");
command.env("SPROUT_ACP_DEDUP", "queue");
command.env(
"GOOSE_MODE",
std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()),
);
if let Some(meta) = runtime_meta {
for (key, value) in meta.default_env {
if std::env::var(key).is_err() {
command.env(key, value);
}
}
}
if let (Some(pack_path), Some(persona_name)) =
(&record.persona_pack_path, &record.persona_name_in_pack)
{
Expand Down Expand Up @@ -659,6 +663,13 @@ pub fn spawn_agent_child(
} else {
command.env_remove("SPROUT_ACP_MODEL");
}
if let Some(meta) = runtime_meta {
if !meta.supports_acp_model_switching {
if let (Some(env_key), Some(model)) = (meta.model_env_var, &effective_model) {
command.env(env_key, model);
}
}
}
if let Some(toolsets) = &record.mcp_toolsets {
command.env("SPROUT_TOOLSETS", toolsets);
} else {
Expand Down
207 changes: 207 additions & 0 deletions desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
collectRuntimeWarnings,
resolvePersonaRuntime,
} from "./resolvePersonaRuntime.ts";

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

function makeRuntime(id, label = `${id} label`) {
return { id, label, command: id, avatarUrl: "" };
}

const goose = makeRuntime("goose", "Goose");
const claude = makeRuntime("claude", "Claude");
const runtimes = [goose, claude];

// ---------------------------------------------------------------------------
// resolvePersonaRuntime — Case 1: no personaRuntimeId
// ---------------------------------------------------------------------------

test("resolvePersonaRuntime — no personaRuntimeId returns defaultRuntime with no warnings", () => {
const result = resolvePersonaRuntime(null, runtimes, goose);
assert.deepEqual(result, {
runtime: goose,
warnings: [],
isOverridden: false,
});
});

test("resolvePersonaRuntime — undefined personaRuntimeId also returns defaultRuntime", () => {
const result = resolvePersonaRuntime(undefined, runtimes, goose);
assert.deepEqual(result, {
runtime: goose,
warnings: [],
isOverridden: false,
});
});

test("resolvePersonaRuntime — no personaRuntimeId and no defaultRuntime returns null with warning", () => {
const result = resolvePersonaRuntime(null, runtimes, null);
assert.equal(result.runtime, null);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /No agent runtimes are available/);
assert.equal(result.isOverridden, false);
});

// ---------------------------------------------------------------------------
// resolvePersonaRuntime — Case 2: matching runtime found
// ---------------------------------------------------------------------------

test("resolvePersonaRuntime — matching runtime found returns matched runtime, no warnings", () => {
const result = resolvePersonaRuntime("goose", runtimes, claude);
assert.deepEqual(result, {
runtime: goose,
warnings: [],
isOverridden: false,
});
});

test("resolvePersonaRuntime — override=true with same runtime as default returns default, no warnings", () => {
const result = resolvePersonaRuntime("goose", runtimes, goose, true);
assert.deepEqual(result, {
runtime: goose,
warnings: [],
isOverridden: false,
});
});

test("resolvePersonaRuntime — override=true with different default emits override warning and returns default", () => {
const result = resolvePersonaRuntime("goose", runtimes, claude, true);
assert.equal(result.runtime, claude);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /Runtime override/);
assert.match(result.warnings[0], /Claude/);
assert.match(result.warnings[0], /Goose/);
assert.equal(result.isOverridden, true);
});

test("resolvePersonaRuntime — override=false returns matched runtime, ignores override flag", () => {
const result = resolvePersonaRuntime("goose", runtimes, claude, false);
assert.deepEqual(result, {
runtime: goose,
warnings: [],
isOverridden: false,
});
});

test("resolvePersonaRuntime — override=true but no defaultRuntime returns matched runtime, no warnings", () => {
const result = resolvePersonaRuntime("goose", runtimes, null, true);
assert.deepEqual(result, {
runtime: goose,
warnings: [],
isOverridden: false,
});
});

// ---------------------------------------------------------------------------
// resolvePersonaRuntime — Case 3: personaRuntimeId not in runtimes, has default
// ---------------------------------------------------------------------------

test("resolvePersonaRuntime — unrecognised runtimeId falls back to defaultRuntime with warning", () => {
const result = resolvePersonaRuntime("unknown-rt", runtimes, goose);
assert.equal(result.runtime, goose);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /unknown-rt/);
assert.match(result.warnings[0], /Goose/);
assert.match(result.warnings[0], /not available/);
assert.equal(result.isOverridden, true);
});

// ---------------------------------------------------------------------------
// resolvePersonaRuntime — Case 4: personaRuntimeId not in runtimes, no default
// ---------------------------------------------------------------------------

test("resolvePersonaRuntime — unrecognised runtimeId and no defaultRuntime returns null with error warning", () => {
const result = resolvePersonaRuntime("unknown-rt", [], null);
assert.equal(result.runtime, null);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /unknown-rt/);
assert.match(result.warnings[0], /no other runtimes were found/);
assert.equal(result.isOverridden, false);
});

// ---------------------------------------------------------------------------
// resolvePersonaRuntime — isOverridden field
// ---------------------------------------------------------------------------

test("resolvePersonaRuntime — isOverridden is true when override redirects to different runtime", () => {
const result = resolvePersonaRuntime("goose", runtimes, claude, true);
assert.equal(result.isOverridden, true);
});

test("resolvePersonaRuntime — isOverridden is false when no override active", () => {
const result = resolvePersonaRuntime("goose", runtimes, claude);
assert.equal(result.isOverridden, false);
});

test("resolvePersonaRuntime — isOverridden is true when persona's runtime is unavailable and falls back", () => {
const result = resolvePersonaRuntime("unknown-rt", runtimes, goose);
assert.equal(result.isOverridden, true);
});

test("resolvePersonaRuntime — isOverridden is false when override selects same runtime as persona", () => {
const result = resolvePersonaRuntime("goose", runtimes, goose, true);
assert.equal(result.isOverridden, false);
});

// ---------------------------------------------------------------------------
// collectRuntimeWarnings
// ---------------------------------------------------------------------------

test("collectRuntimeWarnings — no fallbackRuntime returns empty array regardless of personas", () => {
const personas = [{ runtime: "goose" }, { runtime: "unknown-rt" }];
const warnings = collectRuntimeWarnings(personas, runtimes, null);
assert.deepEqual(warnings, []);
});

test("collectRuntimeWarnings — all personas match their runtimes returns empty array", () => {
const personas = [{ runtime: "goose" }, { runtime: "claude" }];
const warnings = collectRuntimeWarnings(personas, runtimes, goose);
assert.deepEqual(warnings, []);
});

test("collectRuntimeWarnings — persona with no runtime preference produces no warning", () => {
const personas = [{ runtime: null }];
const warnings = collectRuntimeWarnings(personas, runtimes, goose);
assert.deepEqual(warnings, []);
});

test("collectRuntimeWarnings — mixed personas: matching ones are silent, non-matching emit warnings", () => {
const personas = [{ runtime: "goose" }, { runtime: "unknown-rt" }];
const warnings = collectRuntimeWarnings(personas, runtimes, goose);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /unknown-rt/);
});

test("collectRuntimeWarnings — override mode collects one warning per persona whose runtime differs from default", () => {
const personas = [{ runtime: "goose" }, { runtime: "goose" }];
const warnings = collectRuntimeWarnings(personas, runtimes, claude, true);
assert.equal(warnings.length, 2);
for (const w of warnings) {
assert.match(w, /Runtime override/);
}
});

test("collectRuntimeWarnings — override with one matching, one mismatching persona emits one warning", () => {
const personas = [{ runtime: "claude" }, { runtime: "goose" }];
const warnings = collectRuntimeWarnings(personas, runtimes, claude, true);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Runtime override/);
assert.match(warnings[0], /Goose/);
});

test("collectRuntimeWarnings — override=false behaves identically to no override flag", () => {
const personas = [{ runtime: "goose" }, { runtime: "claude" }];
const withoutFlag = collectRuntimeWarnings(personas, runtimes, goose);
const withFalse = collectRuntimeWarnings(personas, runtimes, goose, false);
assert.deepEqual(withoutFlag, withFalse);
});

test("collectRuntimeWarnings — empty personas array always returns empty", () => {
assert.deepEqual(collectRuntimeWarnings([], runtimes, goose, true), []);
});
31 changes: 29 additions & 2 deletions desktop/src/features/agents/lib/resolvePersonaRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@ import type { AcpRuntime } from "@/shared/api/types";
* `warnings` contains user-visible messages when the resolved runtime
* differs from what the persona requested (e.g. the configured runtime
* was uninstalled) or when no runtime is available at all.
* `isOverridden` is true when the resolved runtime differs from what the
* persona originally requested (either via explicit override or fallback).
*/
export type ResolvePersonaRuntimeResult = {
runtime: AcpRuntime | null;
warnings: string[];
isOverridden: boolean;
};

/**
* Resolve which ACP runtime to use when deploying an agent from a persona.
*
* Resolution order:
* 1. If the persona has no `runtimeId` → use `defaultRuntime`, no warnings.
* 2. If the persona's `runtimeId` matches an available runtime → use it.
* 2. If the persona's `runtimeId` matches an available runtime → use it,
* unless `forceOverride` is true and `defaultRuntime` is set, in which case
* `defaultRuntime` is used instead (with an info warning if they differ).
* 3. If the persona's `runtimeId` is set but not found in `runtimes` →
* fall back to `defaultRuntime` and emit a warning.
* 4. If there is no `defaultRuntime` either → return `null` with an error
Expand All @@ -29,6 +34,7 @@ export function resolvePersonaRuntime(
personaRuntimeId: string | undefined | null,
runtimes: readonly AcpRuntime[],
defaultRuntime: AcpRuntime | null,
forceOverride?: boolean,
): ResolvePersonaRuntimeResult {
// Case 1: Persona has no runtime preference — use the default.
if (!personaRuntimeId) {
Expand All @@ -39,13 +45,27 @@ export function resolvePersonaRuntime(
: [
"No agent runtimes are available. Install a runtime (e.g. Goose) to deploy agents.",
],
isOverridden: false,
};
}

// Case 2: Persona's preferred runtime is available.
const matched = runtimes.find((p) => p.id === personaRuntimeId);
if (matched) {
return { runtime: matched, warnings: [] };
if (forceOverride && defaultRuntime && matched.id !== defaultRuntime.id) {
return {
runtime: defaultRuntime,
warnings: [
`Runtime override: using ${defaultRuntime.label} instead of ${matched.label}.`,
],
isOverridden: true,
};
}
return {
runtime: forceOverride && defaultRuntime ? defaultRuntime : matched,
warnings: [],
isOverridden: false,
};
}

// Case 3 & 4: Persona's runtime is not available — fall back.
Expand All @@ -55,6 +75,7 @@ export function resolvePersonaRuntime(
warnings: [
`Persona is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`,
],
isOverridden: true,
};
}

Expand All @@ -63,6 +84,7 @@ export function resolvePersonaRuntime(
warnings: [
`Persona is configured for runtime "${personaRuntimeId}" but it is not available, and no other runtimes were found.`,
],
isOverridden: false,
};
}

Expand All @@ -76,14 +98,19 @@ export function collectRuntimeWarnings(
personas: readonly { runtime: string | null }[],
runtimes: readonly AcpRuntime[],
fallbackRuntime: AcpRuntime | null,
forceOverride?: boolean,
): string[] {
// When no fallback runtime exists, the caller's UI is responsible for
// showing the global "no runtimes found" state. Per-persona warnings
// would be redundant noise alongside that.
if (!fallbackRuntime) return [];
const warnings: string[] = [];
for (const persona of personas) {
const { warnings: w } = resolvePersonaRuntime(
persona.runtime,
runtimes,
fallbackRuntime,
forceOverride,
);
warnings.push(...w);
}
Expand Down
Loading