From 7bc7bf49d6e0635115ae6a2c671e9ffefb21be5d Mon Sep 17 00:00:00 2001 From: Debug Agent Date: Fri, 29 May 2026 15:48:43 +0200 Subject: [PATCH 1/4] feat(chat): runtime-compatibility AgentProfile picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the in-conversation profile picker around AgentProfiles and their runtime compatibility with the running conversation (agent-canvas#669): incompatible profiles are shown but disabled with a reason, and a profile is only ever switched live when the whole profile applies — never a silent partial application. - src/utils/agent-profiles/runtime-plan.ts: pure `deriveProfileRuntimePlan` implementing the issue's compatibility matrix (current | switch-live | disabled+reason) over a normalized AgentProfile / ConversationRuntimeContext, plus `normalizeLlmProfile`. Fully unit-tested incl. ACP/non-runtime reasons. - src/utils/agent-profiles/reason-labels.ts: reason → i18n key (exhaustive). - src/hooks/use-profile-runtime-plans.ts: pairs each saved profile with a plan for the current conversation/home context (cloud-gated; ACP-aware). - switch-profile picker: renders plan-driven rows (current checked, switch-live actionable, disabled greyed + reason); switching is blocked unless the plan is switch-live. - ACP/cloud model menu: shows incompatible saved profiles disabled with "Requires a new conversation" instead of hiding them. - i18n: PROFILE_PICKER reason strings across all supported languages. Backend persistence for ACP profiles lands separately in software-agent-sdk#3433; until the typescript-client exposes the new profile fields, ACP profiles still surface here as disabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../chat-input-model-profiles.test.tsx | 110 ++++++++ .../utils/agent-profiles/runtime-plan.test.ts | 266 ++++++++++++++++++ .../chat/components/chat-input-model.tsx | 67 ++++- .../features/chat/switch-profile-button.tsx | 60 +--- .../chat/switch-profile-context-menu.tsx | 70 +++-- src/hooks/use-profile-runtime-plans.ts | 153 ++++++++++ src/i18n/translation.json | 102 +++++++ src/utils/agent-profiles/reason-labels.ts | 36 +++ src/utils/agent-profiles/runtime-plan.ts | 252 +++++++++++++++++ 9 files changed, 1041 insertions(+), 75 deletions(-) create mode 100644 __tests__/components/features/chat/components/chat-input-model-profiles.test.tsx create mode 100644 __tests__/utils/agent-profiles/runtime-plan.test.ts create mode 100644 src/hooks/use-profile-runtime-plans.ts create mode 100644 src/utils/agent-profiles/reason-labels.ts create mode 100644 src/utils/agent-profiles/runtime-plan.ts diff --git a/__tests__/components/features/chat/components/chat-input-model-profiles.test.tsx b/__tests__/components/features/chat/components/chat-input-model-profiles.test.tsx new file mode 100644 index 000000000..0fa410895 --- /dev/null +++ b/__tests__/components/features/chat/components/chat-input-model-profiles.test.tsx @@ -0,0 +1,110 @@ +import { screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderWithProviders } from "test-utils"; +import type { ChatInputModelState } from "#/hooks/use-chat-input-model-state"; +import type { ProfileWithPlan } from "#/hooks/use-profile-runtime-plans"; + +const useProfileRuntimePlansMock = vi.fn(); +const switchAcpModelMutate = vi.fn(); + +vi.mock("#/hooks/use-profile-runtime-plans", () => ({ + useProfileRuntimePlans: () => useProfileRuntimePlansMock(), +})); + +vi.mock("#/hooks/mutation/use-switch-acp-model", () => ({ + useSwitchAcpModel: () => ({ mutate: switchAcpModelMutate }), +})); + +import { ChatInputModelMenuContent } from "#/components/features/chat/components/chat-input-model"; + +const acpModelState: ChatInputModelState = { + isAcpContext: true, + displayModel: "Claude Opus 4.7", + currentModelId: "claude-opus-4-7", + availableAcpModels: [{ id: "claude-opus-4-7", label: "Claude Opus 4.7" }], + showAcpPicker: true, + switchConversationId: "conv-1", + destinationPath: "/settings/agent", + destinationLabel: "Agent", +}; + +const disabledProfile: ProfileWithPlan = { + profile: { + name: "Cheap GPT daily driver", + model: "openai/gpt-4o", + base_url: null, + api_key_set: true, + }, + plan: { action: "disabled", reason: "different-agent-kind" }, +}; + +describe("ChatInputModelMenuContent disabled-profile section", () => { + beforeEach(() => { + useProfileRuntimePlansMock.mockReset(); + switchAcpModelMutate.mockReset(); + }); + + it("shows incompatible profiles visible-but-disabled with a reason in an ACP conversation", () => { + useProfileRuntimePlansMock.mockReturnValue({ + profiles: [disabledProfile], + activeProfileName: null, + isAcpContext: true, + }); + + renderWithProviders( + {}} />, + ); + + const row = screen.getByTestId( + "chat-input-profile-option-Cheap GPT daily driver", + ); + expect(row).toBeDisabled(); + expect(row).toHaveTextContent("Cheap GPT daily driver"); + // The reason is surfaced inline (not silently swallowed). + expect( + screen.getByTestId( + "chat-input-profile-reason-Cheap GPT daily driver", + ), + ).toBeInTheDocument(); + }); + + it("clicking a disabled profile never triggers a model switch", () => { + useProfileRuntimePlansMock.mockReturnValue({ + profiles: [disabledProfile], + activeProfileName: null, + isAcpContext: true, + }); + + renderWithProviders( + {}} />, + ); + + // disabled