fix: inherit default for unavailable utility actions (#2599)

This commit is contained in:
Carlos Florêncio
2026-08-13 10:23:45 +01:00
committed by GitHub
parent 675c913f99
commit e377809d78
16 changed files with 703 additions and 49 deletions
@@ -76,8 +76,8 @@ func (s *Service) ClearAgentProfileBindings(ctx context.Context, profileID strin
// MigrateLegacyBindings upgrades old agent/model selections after profile
// reconciliation. The operation is idempotent and leaves explicit bindings
// untouched. An empty unconfigured built-in is also left untouched because an
// older release could have erased the ID of a deleted explicit binding.
// untouched. An empty unconfigured built-in is normalized to inherit because
// no concrete override remains to preserve.
func (s *Service) MigrateLegacyBindings(ctx context.Context) (int, error) {
if s.profileResolver == nil {
return 0, nil
@@ -88,9 +88,24 @@ func (s *Service) MigrateLegacyBindings(ctx context.Context) (int, error) {
}
updated := 0
for _, agent := range agents {
// Skip already-inherited rows and any row that still carries a concrete
// profile ID. The latter includes stale/unconfigured overrides whose
// original intent must remain available for explicit user repair.
if agent == nil || agent.AgentProfileID != "" ||
agent.ProfileBindingState == models.ProfileBindingInherit ||
agent.ProfileBindingState == models.ProfileBindingUnconfigured {
agent.ProfileBindingState == models.ProfileBindingInherit {
continue
}
if agent.ProfileBindingState == models.ProfileBindingUnconfigured {
if !agent.Builtin {
continue
}
changed, err := s.repo.NormalizeEmptyBuiltinBinding(ctx, agent.ID)
if err != nil {
return updated, err
}
if changed {
updated++
}
continue
}
profile, matchErr := s.profileResolver.MatchLegacy(ctx, agent.AgentID, agent.Model)
@@ -43,7 +43,7 @@ func TestMigrateLegacyBindingsUpdatesOnlyUnambiguousRows(t *testing.T) {
}
}
func TestMigrateLegacyBindingsPreservesEmptyUnconfiguredBuiltin(t *testing.T) {
func TestMigrateLegacyBindingsNormalizesEmptyUnconfiguredBuiltin(t *testing.T) {
repo := &fakeRepository{agents: map[string]*models.UtilityAgent{
"builtin": {
ID: "builtin",
@@ -58,11 +58,74 @@ func TestMigrateLegacyBindingsPreservesEmptyUnconfiguredBuiltin(t *testing.T) {
if err != nil {
t.Fatalf("MigrateLegacyBindings() error = %v", err)
}
if updated != 0 {
t.Fatalf("MigrateLegacyBindings() updated = %d, want 0", updated)
if updated != 1 {
t.Fatalf("MigrateLegacyBindings() updated = %d, want 1", updated)
}
if got := repo.agents["builtin"].ProfileBindingState; got != models.ProfileBindingUnconfigured {
t.Fatalf("profile binding state = %q, want %q", got, models.ProfileBindingUnconfigured)
if got := repo.agents["builtin"].ProfileBindingState; got != models.ProfileBindingInherit {
t.Fatalf("profile binding state = %q, want %q", got, models.ProfileBindingInherit)
}
if repo.agents["builtin"].Enabled {
t.Fatal("migration enabled the built-in action")
}
updated, err = svc.MigrateLegacyBindings(context.Background())
if err != nil || updated != 0 {
t.Fatalf("second MigrateLegacyBindings() = (%d, %v), want (0, nil)", updated, err)
}
}
func TestMigrateLegacyBindingsDoesNotOverwriteConcurrentRepair(t *testing.T) {
repo := &fakeRepository{agents: map[string]*models.UtilityAgent{
"builtin": {
ID: "builtin",
Builtin: true,
ProfileBindingState: models.ProfileBindingUnconfigured,
},
}}
repo.normalizeEmptyBuiltinBindingHook = func(agent *models.UtilityAgent) {
agent.AgentProfileID = "profile-1"
agent.ProfileBindingState = models.ProfileBindingExplicit
}
svc := NewService(repo)
svc.SetProfileResolver(fakeProfileResolver{})
updated, err := svc.MigrateLegacyBindings(context.Background())
if err != nil || updated != 0 {
t.Fatalf("MigrateLegacyBindings() = (%d, %v), want (0, nil)", updated, err)
}
got := repo.agents["builtin"]
if got.AgentProfileID != "profile-1" || got.ProfileBindingState != models.ProfileBindingExplicit {
t.Fatalf("concurrent repair was overwritten: %#v", got)
}
}
func TestMigrateLegacyBindingsPreservesConcreteAndCustomUnconfiguredBindings(t *testing.T) {
repo := &fakeRepository{agents: map[string]*models.UtilityAgent{
"builtin": {
ID: "builtin",
Builtin: true,
AgentProfileID: "deleted-profile",
ProfileBindingState: models.ProfileBindingUnconfigured,
},
"custom": {
ID: "custom",
ProfileBindingState: models.ProfileBindingUnconfigured,
},
}}
svc := NewService(repo)
svc.SetProfileResolver(fakeProfileResolver{})
updated, err := svc.MigrateLegacyBindings(context.Background())
if err != nil || updated != 0 {
t.Fatalf("MigrateLegacyBindings() = (%d, %v), want (0, nil)", updated, err)
}
for id, agent := range repo.agents {
if agent.ProfileBindingState != models.ProfileBindingUnconfigured {
t.Fatalf("%s binding state = %q, want %q", id, agent.ProfileBindingState, models.ProfileBindingUnconfigured)
}
}
if got := repo.agents["builtin"].AgentProfileID; got != "deleted-profile" {
t.Fatalf("builtin profile ID = %q, want %q", got, "deleted-profile")
}
}
@@ -155,7 +218,8 @@ func TestClearAgentProfileBindingsRetainsStaleProfileID(t *testing.T) {
}
type fakeRepository struct {
agents map[string]*models.UtilityAgent
agents map[string]*models.UtilityAgent
normalizeEmptyBuiltinBindingHook func(*models.UtilityAgent)
}
func (r *fakeRepository) ListAgents(context.Context) ([]*models.UtilityAgent, error) {
@@ -193,6 +257,19 @@ func (r *fakeRepository) UpdateAgent(_ context.Context, agent *models.UtilityAge
return nil
}
func (r *fakeRepository) NormalizeEmptyBuiltinBinding(_ context.Context, id string) (bool, error) {
agent := r.agents[id]
if r.normalizeEmptyBuiltinBindingHook != nil {
r.normalizeEmptyBuiltinBindingHook(agent)
}
if agent == nil || !agent.Builtin || agent.AgentProfileID != "" ||
agent.ProfileBindingState != models.ProfileBindingUnconfigured {
return false, nil
}
agent.ProfileBindingState = models.ProfileBindingInherit
return true, nil
}
func (r *fakeRepository) DeleteAgent(_ context.Context, id string) error {
delete(r.agents, id)
return nil
@@ -14,6 +14,9 @@ type Repository interface {
GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error)
CreateAgent(ctx context.Context, agent *models.UtilityAgent) error
UpdateAgent(ctx context.Context, agent *models.UtilityAgent) error
// NormalizeEmptyBuiltinBinding changes an empty, unconfigured built-in to
// inherit only while the row still has that exact stale binding.
NormalizeEmptyBuiltinBinding(ctx context.Context, id string) (bool, error)
DeleteAgent(ctx context.Context, id string) error
// Calls (history)
@@ -277,6 +277,22 @@ func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Utilit
return err
}
func (r *sqliteRepository) NormalizeEmptyBuiltinBinding(ctx context.Context, id string) (bool, error) {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE utility_agents
SET profile_binding_state = ?, updated_at = ?
WHERE id = ? AND builtin = 1 AND agent_profile_id = '' AND profile_binding_state = ?
`), models.ProfileBindingInherit, time.Now().UTC(), id, models.ProfileBindingUnconfigured)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
if err != nil {
return false, err
}
return rows == 1, nil
}
func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM utility_agents WHERE id = ? AND builtin = 0`), id)
return err
@@ -117,3 +117,42 @@ func TestSeedBuiltinAgents_UsesClaudeACP(t *testing.T) {
t.Fatalf("expected at least one built-in row, found 0")
}
}
func TestNormalizeEmptyBuiltinBindingUsesStalePredicate(t *testing.T) {
sqlxDB := openTestDB(t)
repo, err := newSQLiteRepositoryWithDB(sqlxDB, sqlxDB)
if err != nil {
t.Fatalf("schema init: %v", err)
}
now := time.Now().UTC()
if _, err := sqlxDB.Exec(`INSERT OR REPLACE INTO utility_agents
(id, name, description, prompt, agent_id, model, agent_profile_id, profile_binding_state, builtin, enabled, created_at, updated_at)
VALUES (?, ?, '', '', 'claude-acp', '', '', 'unconfigured', 1, 0, ?, ?)`,
"stale", "stale", now, now); err != nil {
t.Fatalf("seed stale builtin: %v", err)
}
changed, err := repo.NormalizeEmptyBuiltinBinding(context.Background(), "stale")
if err != nil || !changed {
t.Fatalf("NormalizeEmptyBuiltinBinding() = (%v, %v), want (true, nil)", changed, err)
}
if _, err := sqlxDB.Exec(`UPDATE utility_agents
SET agent_profile_id = 'profile-1', profile_binding_state = 'explicit'
WHERE id = ?`, "stale"); err != nil {
t.Fatalf("simulate concurrent repair: %v", err)
}
changed, err = repo.NormalizeEmptyBuiltinBinding(context.Background(), "stale")
if err != nil || changed {
t.Fatalf("NormalizeEmptyBuiltinBinding() after repair = (%v, %v), want (false, nil)", changed, err)
}
var profileID, bindingState string
if err := sqlxDB.QueryRowx(`SELECT agent_profile_id, profile_binding_state FROM utility_agents WHERE id = ?`, "stale").Scan(&profileID, &bindingState); err != nil {
t.Fatalf("read repaired builtin: %v", err)
}
if profileID != "profile-1" || bindingState != "explicit" {
t.Fatalf("concurrent repair was overwritten: profile=%q state=%q", profileID, bindingState)
}
}
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { UtilityAgent } from "@/lib/api/domains/utility-api";
import { USE_DEFAULT } from "./utility-sections";
import {
isUtilityAgentDirty,
mergeRefreshedUtilityAgents,
replaceCustomUtilityAgents,
updateBuiltinProfileDraft,
} from "./utility-agents-section";
const DRAFT_MODEL = "draft-model";
@@ -23,7 +25,7 @@ function agent(id: string, builtin: boolean, model: string): UtilityAgent {
};
}
describe("replaceCustomUtilityAgents", () => {
describe("utility agent helpers", () => {
it("compares only draftable built-in fields", () => {
const baseline = agent("commit", true, "saved-model");
@@ -54,3 +56,30 @@ describe("replaceCustomUtilityAgents", () => {
]);
});
});
describe("updateBuiltinProfileDraft", () => {
it("treats the picker fallback value as an inherited binding", () => {
const base = {
...agent("commit", true, ""),
enabled: false,
agent_profile_id: "deleted-profile",
profile_binding_state: "unconfigured",
};
for (const pickerValue of ["", USE_DEFAULT]) {
const draft = updateBuiltinProfileDraft(base, pickerValue);
expect(draft.agent_profile_id).toBe("");
expect(draft.profile_binding_state).toBe("inherit");
expect(draft.enabled).toBe(true);
}
});
it("keeps a concrete profile selection explicit", () => {
const draft = updateBuiltinProfileDraft(agent("commit", true, ""), "profile-1");
expect(draft.agent_profile_id).toBe("profile-1");
expect(draft.profile_binding_state).toBe("explicit");
expect(draft.enabled).toBe(true);
});
});
@@ -57,23 +57,23 @@ export function mergeRefreshedUtilityAgents(
});
}
export function updateBuiltinProfileDraft(agent: UtilityAgent, value: string): UtilityAgent {
const inherit = value === "" || value === USE_DEFAULT;
return {
...agent,
agent_profile_id: inherit ? "" : value,
profile_binding_state: inherit ? "inherit" : "explicit",
enabled: true,
};
}
function updateBuiltinDraft(
agent: UtilityAgent,
value: string,
setAgents: React.Dispatch<React.SetStateAction<UtilityAgent[]>>,
) {
const inherit = value === USE_DEFAULT;
setAgents((prev) =>
prev.map((item) =>
item.id === agent.id
? {
...item,
agent_profile_id: inherit ? "" : value,
profile_binding_state: inherit ? "inherit" : "explicit",
enabled: true,
}
: item,
),
prev.map((item) => (item.id === agent.id ? updateBuiltinProfileDraft(item, value) : item)),
);
}
@@ -8,6 +8,88 @@ import { test, expect } from "../../fixtures/test-base";
* stacked. See PR #1654 review discussion.
*/
test.describe("Mobile utility agents action rows", () => {
test("repairs an unavailable action with the default by touch", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const agentId = "builtin-commit-message";
const { agents } = await apiClient.listAgents();
const profile = agents
.flatMap((agent) => agent.profiles ?? [])
.find((candidate) => candidate.id === seedData.agentProfileId);
if (!profile) throw new Error(`seed profile ${seedData.agentProfileId} was not found`);
let savedBinding: Record<string, unknown> | undefined;
const unavailable = {
id: agentId,
name: "commit-message",
description: "Generate a commit message.",
prompt: "Generate a commit message.",
builtin: true,
enabled: false,
agent_id: "",
model: "",
agent_profile_id: "",
profile_binding_state: "unconfigured",
};
await testPage.route("**/api/v1/user/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { default_utility_agent_profile_id: seedData.agentProfileId },
}),
});
});
await testPage.route(`**/api/v1/utility/agents/${agentId}`, async (route) => {
if (route.request().method() !== "PATCH") return route.continue();
savedBinding = route.request().postDataJSON() as Record<string, unknown>;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ...unavailable, ...savedBinding }),
});
});
await testPage.route("**/api/v1/utility/agents", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ agents: [unavailable] }),
});
});
await testPage.goto("/settings/utility-agents");
await expect(
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
).toBeVisible({ timeout: 15_000 });
const card = testPage.getByTestId("utility-actions-card");
const row = testPage.getByTestId(`utility-action-row-${agentId}`);
const picker = row.getByTestId(`utility-profile-picker-action-${agentId}`);
await picker.tap();
const listbox = testPage.getByRole("listbox");
await listbox.locator('[data-value="__USE_DEFAULT__"]').tap();
const floatingSave = testPage.getByTestId("settings-floating-save");
await floatingSave.getByRole("button", { name: "Save changes" }).tap();
await expect(floatingSave).not.toBeVisible({ timeout: 10_000 });
expect(savedBinding).toMatchObject({
agent_profile_id: "",
profile_binding_state: "inherit",
enabled: true,
});
expect(await card.evaluate((element) => element.scrollWidth > element.clientWidth + 1)).toBe(
false,
);
await prCapture.screenshot("utility-action-inherits-default-mobile", {
caption: "Default inheritance remains reachable on a narrow touch viewport.",
});
});
test("profile select and edit button stay reachable on a narrow viewport", async ({
testPage,
apiClient,
@@ -10,6 +10,111 @@ import { test, expect } from "../../fixtures/test-base";
* interactions (open the page, inspect sections, open the create dialog).
*/
test.describe("Utility Agents settings page", () => {
test("repairs an unavailable action with the default", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const agentId = "builtin-commit-message";
const { agents } = await apiClient.listAgents();
const profile = agents
.flatMap((agent) => agent.profiles ?? [])
.find((candidate) => candidate.id === seedData.agentProfileId);
if (!profile) throw new Error(`seed profile ${seedData.agentProfileId} was not found`);
const profileLabel = `${profile.agentDisplayName} • ${profile.name}`;
const baselineResponse = await apiClient.rawRequest("GET", `/api/v1/utility/agents/${agentId}`);
expect(baselineResponse.ok).toBe(true);
const baseline = await baselineResponse.json();
await testPage.route("**/api/v1/user/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
settings: { default_utility_agent_profile_id: seedData.agentProfileId },
}),
});
});
await testPage.route("**/api/v1/utility/agents", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
agents: [
{
...baseline,
agent_profile_id: "",
profile_binding_state: "unconfigured",
enabled: false,
},
],
}),
});
});
try {
await testPage.goto("/settings/utility-agents");
await expect(
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
).toBeVisible({ timeout: 15_000 });
const row = testPage.getByTestId(`utility-action-row-${agentId}`);
const picker = row.getByTestId(`utility-profile-picker-action-${agentId}`);
await expect(
row.getByText("This profile is unavailable. Select an enabled inference profile.", {
exact: true,
}),
).toBeVisible();
await picker.click();
const listbox = testPage.getByRole("listbox");
await listbox.locator('[data-value="__USE_DEFAULT__"]').click();
const floatingSave = testPage.getByTestId("settings-floating-save");
await expect(floatingSave).toBeVisible();
await floatingSave.getByRole("button", { name: "Save changes" }).click();
await expect(floatingSave).not.toBeVisible({ timeout: 10_000 });
await testPage.unroute("**/api/v1/utility/agents");
await testPage.reload();
await expect(picker).toContainText(profileLabel, { timeout: 15_000 });
await expect(
row.getByText("This profile is unavailable. Select an enabled inference profile.", {
exact: true,
}),
).toHaveCount(0);
await prCapture.screenshot("utility-action-inherits-default-desktop", {
caption: "The repaired utility action inherits the selected default profile.",
});
const persistedResponse = await apiClient.rawRequest(
"GET",
`/api/v1/utility/agents/${agentId}`,
);
expect(persistedResponse.ok).toBe(true);
const persisted = await persistedResponse.json();
expect(persisted).toMatchObject({
agent_profile_id: "",
profile_binding_state: "inherit",
enabled: true,
});
} finally {
const restoreResponse = await apiClient.rawRequest(
"PATCH",
`/api/v1/utility/agents/${agentId}`,
{
agent_profile_id: baseline.agent_profile_id,
profile_binding_state: baseline.profile_binding_state,
enabled: baseline.enabled,
},
);
expect(restoreResponse.ok).toBe(true);
}
});
test("renders the default for an inherited built-in action", async ({
testPage,
apiClient,
@@ -1,6 +1,6 @@
# ADR-2026-08-12-built-in-utility-default-inheritance: Built-in Utility Actions Inherit the Global Profile by Default
**Status:** accepted
**Status:** superseded by 2026-08-12-empty-utility-bindings-inherit-default
**Date:** 2026-08-12
**Area:** backend, frontend
@@ -0,0 +1,47 @@
# ADR-2026-08-12-empty-utility-bindings-inherit-default: Empty Built-in Utility Bindings Inherit the Default
**Status:** accepted
**Date:** 2026-08-12
**Area:** backend, frontend
## Context
Older profile migration code persisted built-in utility actions with an empty profile ID and an
`unconfigured` binding. The previous default-inheritance decision preserved that state because it
did not distinguish a deleted explicit binding from a failed legacy migration. In practice, this
leaves every built-in action unavailable when the user has selected a valid global default. The
settings repair flow can also send an invalid explicit binding when the user chooses Default.
## Decision
A built-in utility action with an empty profile ID always means that no concrete override is
recoverable. The backend normalizes an empty `unconfigured` built-in binding to `inherit`, and the
action resolves through the user's saved global default profile. This normalization is idempotent
and persists the inherited state so the database does not continue to expose the action as
unavailable.
The Utility Agents picker treats its fallback callback value, the empty string, as an inherited
selection. Saving Default sends an empty `agent_profile_id` with `profile_binding_state: "inherit"`.
It never sends an empty explicit binding.
A non-empty stale profile ID still represents a recoverable explicit choice. Such a binding remains
`unconfigured` and fails closed until the user selects Default or another eligible profile. Custom
utility agents still require a concrete eligible profile.
## Consequences
- Existing installations with empty unavailable built-in actions recover on the next binding
migration and use the selected global default.
- Selecting Default repairs the action through the normal Settings save flow.
- The system no longer preserves hypothetical explicit intent when no profile identity remains.
- Concrete stale overrides remain diagnosable and do not silently change model, permissions,
credentials, or launch policy.
## Alternatives Considered
1. **Keep every empty `unconfigured` row fail-closed.** Rejected because no concrete identity can be
repaired or audited, while the product contract already gives every built-in action a default.
2. **Copy the current default profile ID into every repaired row.** Rejected because built-in
actions then stop following later changes to the global default.
3. **Convert every stale binding to `inherit`.** Rejected because a non-empty stale ID preserves a
real explicit user choice and its security-relevant launch configuration.
+2 -1
View File
@@ -158,5 +158,6 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-10-remote-contribution-head-drift | [Separate Current Contribution and Local Checkout Histories](2026-08-10-remote-contribution-head-drift.md) | accepted (remote-action policy superseded) | backend, frontend, protocol, GitHub, GitLab | 2026-08-10 |
| 2026-08-12-local-first-contribution-replacement | [Keep Remote Contribution Work Local-First](2026-08-12-local-first-contribution-replacement.md) | accepted | backend, frontend, protocol, security, GitHub, GitLab | 2026-08-12 |
| 2026-08-11-user-owned-status-bar-visibility | [Make Status Bar Visibility a Portable User Preference](2026-08-11-user-owned-status-bar-visibility.md) | accepted | backend, frontend, protocol | 2026-08-11 |
| 2026-08-12-built-in-utility-default-inheritance | [Built-in Utility Actions Inherit the Global Profile by Default](2026-08-12-built-in-utility-default-inheritance.md) | accepted | backend, frontend | 2026-08-12 |
| 2026-08-12-built-in-utility-default-inheritance | [Built-in Utility Actions Inherit the Global Profile by Default](2026-08-12-built-in-utility-default-inheritance.md) | superseded by 2026-08-12-empty-utility-bindings-inherit-default | backend, frontend | 2026-08-12 |
| 2026-08-12-empty-utility-bindings-inherit-default | [Empty Built-in Utility Bindings Inherit the Default](2026-08-12-empty-utility-bindings-inherit-default.md) | accepted | backend, frontend | 2026-08-12 |
| 2026-08-10-no-em-dash-public-copy | [Keep Em Dashes Out of Public Copy](2026-08-10-no-em-dash-public-copy.md) | accepted | frontend, infra | 2026-08-10 |
@@ -0,0 +1,101 @@
---
spec: docs/specs/agents/utility-agent-profiles.md
created: 2026-08-12
status: complete
---
# Implementation Plan: Repair Unavailable Utility Actions
## Overview
The live database contains all eight built-in utility actions as empty `unconfigured` rows. A valid
default profile exists. The settings picker translates its Default option to an empty callback
value. The draft code treats only `__USE_DEFAULT__` as inherited and submits an invalid empty
explicit binding. The repair first normalizes legacy empty built-ins to `inherit`. Then it fixes the
shared desktop and mobile save path and proves persistence through the real backend.
---
## Backend
### Legacy binding normalization
- `apps/backend/internal/utility/service/service.go`: update `MigrateLegacyBindings` so a built-in
with `profile_binding_state = 'unconfigured'` and an empty `agent_profile_id` is persisted as
`inherit` through a conditional repository update. Preserve non-empty stale IDs and every custom
`unconfigured` row, including when a concurrent settings save changes the stale row first.
- Keep the operation idempotent and preserve the existing `enabled` value. This repair changes only
binding ownership. It does not silently enable an action or copy a concrete profile ID.
## Frontend
### Default action selection
- `apps/web/components/settings/utility-agents-section.tsx`: extract the built-in profile draft
transition into a small pure helper. Treat the profile picker's empty fallback callback as Default,
producing an empty profile ID, `inherit`, and `enabled: true`. Keep concrete values explicit.
- Do not change `UtilityAgentProfilePicker`'s global callback contract because the default-profile
card intentionally uses the empty value to mean no saved default.
### Mobile design contract
The existing stacked action row remains the desktop and phone entry point. The picker, save bar,
single page scroll owner, touch targets, and responsive composition do not change. The state
transition is shared across viewports. The existing mobile action-row spec is the nearest shipped
mobile surface and will be extended to prove that choosing Default by touch submits an inherited
binding.
## Tests
- **What:** Empty unavailable built-ins normalize to inherited state, while concrete stale and custom
rows remain unconfigured.
**File:** `apps/backend/internal/utility/service/service_test.go`.
**How:** Replace the preservation regression with table-driven service migration tests that assert
repository updates and idempotent second-run behavior.
- **What:** The fallback value from the shared picker creates a valid inherited draft, and a concrete
profile creates an explicit draft.
**File:** `apps/web/components/settings/utility-agents-section.test.ts`.
**How:** Pure helper tests, including the exact empty-string callback that caused the failure.
- **What:** Selecting Default traverses the handler, service, and SQLite repository without an empty
explicit binding.
**File:** `apps/web/e2e/tests/settings/utility-agents.spec.ts`.
**How:** Serve one unavailable GET state, continue the PATCH to the real isolated backend, wait for
Saved, reload from the real backend, and assert the inherited default remains selected.
## E2E Tests
- **Scenario:** GIVEN an empty unavailable built-in action and a valid global default, WHEN the user
selects Default and saves, THEN the real API save succeeds and reload shows the inherited default.
**File:** `apps/web/e2e/tests/settings/utility-agents.spec.ts`.
- **Scenario:** GIVEN the same action on a Pixel 5 viewport, WHEN the user selects Default with the
touch picker and saves, THEN the outgoing binding is `inherit` and all controls remain reachable.
**File:** `apps/web/e2e/tests/settings/mobile-utility-agents.spec.ts`.
## Verification Results
- Backend utility and backend-app suites: `cd apps/backend && go test ./internal/utility/... ./internal/backendapp/...`
passed 378 tests; `go vet ./internal/utility/...` passed. The SQLite stale-predicate regression is
covered by the utility store migration suite.
- Frontend utility suites: `cd apps/web && pnpm exec vitest run
components/settings/utility-agents-section.test.ts
components/settings/utility-sections.test.ts` passed 8 tests.
- Frontend type check: `cd apps/web && pnpm run typecheck` passed.
- Internationalization checks: `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet`
passed. Existing translation parity notices remained advisory.
- Desktop E2E: the unavailable action saved through the real backend, reloaded as `inherit`, and
restored its isolated fixture baseline.
- Mobile E2E: both Utility Agents scenarios passed. The touch flow sent the inherited binding and
the actions card had no horizontal overflow.
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [task-01-normalize-empty-bindings](task-01-normalize-empty-bindings.md)
Wave 2:
- [x] [task-02-save-default-selection](task-02-save-default-selection.md)
The tasks are sequential because the UI persistence scenario verifies the backend binding contract.
No subagent delegation is authorized by this plan.
@@ -0,0 +1,64 @@
---
id: "01-normalize-empty-bindings"
title: "Normalize empty utility bindings"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agents/utility-agent-profiles.md"
---
# Task 01: Normalize empty utility bindings
Repair legacy built-in rows before changing the settings save path.
- **Acceptance:** A built-in with an empty profile ID and `unconfigured` state is persisted as
`inherit`, and a second migration run makes no further update.
- **Acceptance:** A concrete stale built-in binding and every custom unconfigured binding remain
unchanged and fail closed.
- **Acceptance:** Migration preserves the row's existing `enabled` value and does not copy a concrete
default profile ID.
- **Acceptance:** A concurrent concrete repair cannot be overwritten by the startup normalization.
- **Verification:** Add the failing migration tests first, then run:
```bash
cd apps/backend
go test -run 'TestMigrateLegacyBindings' ./internal/utility/service/...
```
- **Files likely touched:** `apps/backend/internal/utility/service/service.go`,
`apps/backend/internal/utility/service/service_test.go`,
`apps/backend/internal/utility/store/interface.go`, `apps/backend/internal/utility/store/sqlite.go`,
and `apps/backend/internal/utility/store/sqlite_migration_test.go`.
- **Dependencies:** None.
- **Parallelism:** sequential.
- **Inputs:** The legacy migration, failure-mode, persistence, and scenario sections in
`docs/specs/agents/utility-agent-profiles.md`, plus
ADR-2026-08-12-empty-utility-bindings-inherit-default.
- **Output contract:** Report files changed, the RED and GREEN test commands and results, idempotency
evidence, preserved stale/custom behavior, and synchronized task/plan status.
## Results
Implemented idempotent, conditional normalization for empty `unconfigured` built-in rows. The
migration now persists only the binding-state field to `inherit` without changing `enabled`, and a
stale-row predicate prevents a concurrent concrete repair from being overwritten. Concrete stale
built-in bindings and custom unconfigured bindings remain unchanged.
- RED: `cd apps/backend && go test -run 'TestMigrateLegacyBindingsNormalizesEmptyUnconfiguredBuiltin' ./internal/utility/service/...`
(failed as expected: `updated = 0, want 1`)
- GREEN: `cd apps/backend && go test -run 'TestMigrateLegacyBindingsNormalizesEmptyUnconfiguredBuiltin' ./internal/utility/service/...`
(pass: 1 test)
- REFACTOR: `cd apps/backend && go test -run 'TestMigrateLegacyBindings' ./internal/utility/service/...`
(pass: 5 tests)
- Concurrency guard: `cd apps/backend && go test -run 'TestMigrateLegacyBindingsDoesNotOverwriteConcurrentRepair' ./internal/utility/service/...`
(pass: conditional repository fake preserves the concurrent explicit repair)
- SQLite predicate: `cd apps/backend && go test -run 'TestNormalizeEmptyBuiltinBindingUsesStalePredicate' ./internal/utility/store/...`
(pass: first stale row updates, second repaired row is not changed)
- Package verification: `cd apps/backend && go test ./internal/utility/... ./internal/backendapp/...`
(pass: 378 tests) and `go vet ./internal/utility/...` (pass).
- Fixup review: preserved the stale concrete profile ID assertion and added a conditional SQLite
update so concurrent settings repairs cannot be overwritten.
- Generated artifacts: None.
- Cleanup: No temporary artifacts.
- Security or external side effects: None. Tests use the in-memory fake repository.
@@ -0,0 +1,75 @@
---
id: "02-save-default-selection"
title: "Save inherited utility selection"
status: done
wave: 2
depends_on: ["01-normalize-empty-bindings"]
plan: "plan.md"
spec: "../../specs/agents/utility-agent-profiles.md"
---
# Task 02: Save inherited utility selection
Fix the shared Utility Agents draft transition and prove the real persistence path on desktop and
phone viewports.
- **Acceptance:** The picker's empty fallback callback creates `agent_profile_id: ""`,
`profile_binding_state: "inherit"`, and `enabled: true`. A concrete profile remains explicit.
- **Acceptance:** Selecting Default from an unavailable built-in action saves through the real
backend and remains selected after reload.
- **Acceptance:** The existing stacked mobile action row remains reachable and the touch flow sends
the same inherited binding without horizontal overflow.
- **Verification:** Bootstrap dependencies if needed, add the failing unit and E2E regressions first,
then run:
```bash
cd apps
pnpm install --frozen-lockfile
pnpm --filter @kandev/web test -- --run components/settings/utility-agents-section.test.ts
cd web
pnpm run typecheck
pnpm run i18n:check
pnpm run i18n:ratchet
pnpm e2e:run --project chromium tests/settings/utility-agents.spec.ts -- --grep "repairs an unavailable action with the default"
pnpm e2e:run --project mobile-chrome tests/settings/mobile-utility-agents.spec.ts
```
- **Files likely touched:** `apps/web/components/settings/utility-agents-section.tsx`,
`apps/web/components/settings/utility-agents-section.test.ts`,
`apps/web/e2e/tests/settings/utility-agents.spec.ts`, and
`apps/web/e2e/tests/settings/mobile-utility-agents.spec.ts`.
- **Dependencies:** Task 01.
- **Parallelism:** sequential.
- **Inputs:** The Utility Agents save and persistence scenarios in
`docs/specs/agents/utility-agent-profiles.md`, the current shared picker empty-value contract, the
existing desktop Utility Agents spec, and the existing stacked mobile action-row spec.
- **Output contract:** Report files changed, RED and GREEN test commands and results, saved PATCH
payload and reload evidence, desktop/mobile outcomes, cleanup, and synchronized task/plan status.
## Results
Implemented a shared draft transition that treats both the Default sentinel and the picker's empty
callback as inherited state. Concrete profile IDs remain explicit. The desktop regression serves a
synthetic unavailable GET response, lets the PATCH reach the isolated Go backend, reloads from that
backend, and restores the original fixture row. The mobile regression selects Default by touch,
captures the inherited PATCH body, and verifies that the actions card does not overflow.
- RED: `cd apps/web && pnpm exec vitest run
components/settings/utility-agents-section.test.ts` failed as expected because the empty callback
produced `explicit` instead of `inherit`.
- GREEN: the same command passed 5 tests after the draft transition repair.
- Component regression: `cd apps/web && pnpm exec vitest run
components/settings/utility-agents-section.test.ts
components/settings/utility-sections.test.ts` passed 8 tests.
- Desktop E2E: `cd apps/web && pnpm exec playwright test --config=e2e/playwright.config.ts
e2e/tests/settings/utility-agents.spec.ts --project=chromium --grep "repairs an unavailable
action with the default"` passed 1 test.
- Mobile E2E: `cd apps/web && pnpm exec playwright test --config=e2e/playwright.config.ts
e2e/tests/settings/mobile-utility-agents.spec.ts --project=mobile-chrome` passed 2 tests.
- Saved mobile PATCH: `agent_profile_id: ""`, `profile_binding_state: "inherit"`, and
`enabled: true`.
- Fixup review: both desktop and mobile option selection now scope through the page-level listbox
role, while the picker test ID remains available for the trigger and dropdown semantics.
- Generated artifacts: None.
- Cleanup: The desktop E2E restores the original built-in row in a `finally` block.
- Security or external side effects: None. Both browser projects use an isolated E2E backend.
+25 -25
View File
@@ -10,11 +10,11 @@ Decision: [ADR-2026-08-08-utility-agent-profile-execution](../../decisions/2026-
Safety decision: [ADR-2026-08-08-utility-profile-dependency-safety](../../decisions/2026-08-08-utility-profile-dependency-safety.md)
Default inheritance repair: [ADR-2026-08-12-built-in-utility-default-inheritance](../../decisions/2026-08-12-built-in-utility-default-inheritance.md)
Default inheritance repair: [ADR-2026-08-12-empty-utility-bindings-inherit-default](../../decisions/2026-08-12-empty-utility-bindings-inherit-default.md)
Implementation plan: [utility-agent-profiles](../../plans/utility-agent-profiles/plan.md)
Repair plan: [utility-action-default-profile](../../plans/utility-action-default-profile/plan.md)
Latest repair plan: [utility-agent-unavailable-repair](../../plans/utility-agent-unavailable-repair/plan.md)
## Why
@@ -34,6 +34,8 @@ permission choice that its caller cannot answer.
- A built-in utility action without a concrete profile override inherits the default utility
profile. A stale concrete override remains **unconfigured** after its profile is deleted or
disabled and cannot run until the user repairs it.
- An empty `unconfigured` built-in binding is normalized to `inherit`. Selecting Default in an
action picker persists the same inherited state and never submits an empty explicit binding.
- Eligible choices are enabled, non-deleted, global profiles for ACP inference-capable agents.
CLI-passthrough profiles and workspace-scoped Office profiles are not eligible.
- A utility invocation resolves its effective profile at the start of the call. It uses that
@@ -67,10 +69,9 @@ permission choice that its caller cannot answer.
`inherit` is valid only for built-in rows. `explicit` requires an eligible profile. `unconfigured`
is used for a custom row without a profile and for a built-in row whose concrete profile binding is
stale or unavailable. Only a built-in row whose persisted state is `inherit` uses the selected
default. A legacy built-in row still in the migration state's `explicit` form with no concrete
profile ID is normalized to `inherit`. An empty `unconfigured` row remains fail-closed because an
older release could have erased the ID of a deleted explicit binding, making that row's original
intent impossible to recover safely.
default. A built-in row with an empty profile ID in either the legacy `explicit` or `unconfigured`
state is normalized to `inherit`. A concrete stale profile ID remains `unconfigured` so the user can
repair or replace the known override explicitly.
The legacy `agent_id` and `model` columns may remain temporarily as migration inputs, but they are
not execution inputs after this feature ships and are not writable through the utility-agent API.
@@ -91,14 +92,13 @@ non-deleted, non-passthrough global profiles whose parent agent matches the lega
and whose configured model matches the legacy model (including an explicit empty model). Exactly
one match is copied to `agent_profile_id` and sets the row state to `explicit`.
Zero matches or multiple matches set a custom row to `unconfigured`. A built-in row in the legacy
`explicit` migration state with no concrete profile ID becomes `inherit`. Existing `unconfigured`
rows are not remigrated: an older release could have erased the ID of a deleted explicit binding,
so an empty `unconfigured` row remains fail-closed until the user repairs it. The backend does not
pick the first profile, infer from a display name, or silently use a provider default. Custom utility
agents that cannot be migrated remain stored and editable but are not executable until the user
selects a profile. The legacy values remain available to a later retry or diagnostic report, but the
new state is authoritative after the migration.
Zero matches or multiple matches set a custom row to `unconfigured`. A built-in row with no concrete
profile ID becomes `inherit`, including an empty `unconfigured` row written by an older release.
The backend does not pick the first profile, infer from a display name, or silently use a provider
default. Concrete stale built-in IDs remain `unconfigured`. Custom utility agents that cannot be
migrated remain stored and editable but are not executable until the user selects a profile. The
legacy values remain available to a later retry or diagnostic report, but the new state is
authoritative after the migration.
## API surface
@@ -133,10 +133,9 @@ new state is authoritative after the migration.
- With no effective profile, invocation fails before a call is dispatched and tells the user to
select a profile in Settings > Utility Agents.
- A missing, deleted, disabled, CLI-passthrough, workspace-scoped, or non-inference-capable
concrete profile binding fails closed. An unconfigured binding also fails closed when its profile
ID is empty because its origin may be an older deleted override. Only an inherited built-in action
uses the saved global default. The backend never falls back to another profile, the active task
profile, a raw agent/model pair, or the first available agent.
concrete profile binding fails closed. An empty built-in binding is normalized to `inherit` and
uses the saved global default. The backend never falls back to another concrete profile, the active
task profile, a raw agent/model pair, or the first available agent.
- A profile launch-policy error (invalid command prefix, unresolved required secret, invalid config
option, or unavailable agent runtime) is surfaced as the utility call failure. The runner does not
retry without that setting.
@@ -158,9 +157,8 @@ new state is authoritative after the migration.
The warning dialog is confirmation only; it does not perform reassignment. Built-in actions that
inherit the default remain inherited when the default profile is deleted, so selecting a new
default repairs them without editing each action.
- Built-in rows left as empty `unconfigured` by an older release remain fail-closed because their
original explicit-versus-inherited intent cannot be reconstructed safely. Selecting the default
in the action picker repairs the row by persisting `inherit`.
- Built-in rows left as empty `unconfigured` by an older release are repaired idempotently to
`inherit`. Selecting Default in the action picker also persists `inherit` and survives reload.
- Utility call history retains the effective profile ID and resolved model even if the profile is
later edited or deleted.
@@ -202,10 +200,12 @@ new state is authoritative after the migration.
backend upgrades, **THEN** the row state becomes `inherit` and the action uses the selected default
utility profile. A custom utility agent with the same migration result becomes `unconfigured` and
remains unavailable until the user chooses a profile.
- **GIVEN** an older release left a built-in action `unconfigured` with an empty profile ID after
deleting or clearing a binding, **WHEN** the backend upgrades, **THEN** the row remains
`unconfigured` and fails closed until the user repairs the action because its original intent is
ambiguous.
- **GIVEN** an older release left a built-in action `unconfigured` with an empty profile ID,
**WHEN** the backend runs utility binding migration, **THEN** the row is persisted as `inherit` and
resolves through the saved global default profile.
- **GIVEN** an unavailable built-in action and a valid global default, **WHEN** the user selects
Default and saves, **THEN** the save succeeds, the row persists `inherit` with an empty profile ID,
and the default profile remains selected after reload.
- **GIVEN** an inherited built-in action and a deleted default profile, **WHEN** the user selects a
new eligible default profile, **THEN** the action uses the new default without an action-level
edit.