Skip to content
Open
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
1 change: 1 addition & 0 deletions src/features/chat/hooks/useAgentModelPickerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export function useAgentModelPickerState({
availableModels.find((model) => model.id === modelId);
onModelSelected?.({
id: modelId,
agentId: selectedModelOverride?.agentId,
name: selectedModel?.name ?? modelId,
displayName: selectedModel?.displayName ?? modelId,
provider: selectedModel?.provider,
Expand Down
28 changes: 13 additions & 15 deletions src/features/chat/hooks/useResolvedAgentModelPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,20 +506,21 @@ export function useResolvedAgentModelPicker({
});
},
onModelSelected: (model) => {
const targetAgentId = model.agentId ?? selectedAgentId;
const modelId = model.id;
const modelName = model.displayName ?? model.name ?? model.id;
const nextModelProviderId =
model.providerId ??
session?.executionTarget?.modelProviderId ??
(selectedAgentId === "goose" ? undefined : selectedAgentId);
(targetAgentId === "goose" ? undefined : targetAgentId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P1 · Cross-agent star borrows provider (blocking)

When a starred row belongs to a different agent but has no providerId, nextModelProviderId falls back to the current session's modelProviderId. A Claude star selected from a Goose/OpenAI session can therefore produce a target that combines the Claude harness with the unrelated OpenAI provider. The added component test checks only the callback shape and does not exercise this target derivation.

User effect: Choosing a valid favorite from another agent can fail, roll back, or configure the chat for a provider that does not match the model they chose.

Recommended fix: Reuse the session provider only when the target agent is still the selected agent. Resolve cross-agent selections from that target agent's current inventory; derive non-Goose provider identity from the target agent and require a concrete provider for Goose.

Test: Add a hook-level regression test that selects a providerless claude-acp star from a Goose session using OpenAI and asserts the resulting target uses claude-acp rather than openai.

if (!nextModelProviderId) {
console.warn("Dropped model selection without a model provider", {
harnessId: selectedAgentId,
harnessId: targetAgentId,
modelId,
});
return;
}
const nextTarget = targetFromAgentModelSelection(selectedAgentId, {
const nextTarget = targetFromAgentModelSelection(targetAgentId, {
modelProviderId: nextModelProviderId,
modelId,
modelName,
Expand All @@ -541,7 +542,7 @@ export function useResolvedAgentModelPicker({

if (!sessionId) {
setPendingExecutionTarget(nextTarget);
setGlobalSelectedProvider(selectedAgentId);
setGlobalSelectedProvider(targetAgentId);
setPendingModelSelection(nextModelSelection);
return;
}
Expand All @@ -562,7 +563,7 @@ export function useResolvedAgentModelPicker({
const requestId = createModelSelectionRequestId();

const previousStoredModelPreference =
getStoredModelPreference(selectedAgentId);
getStoredModelPreference(targetAgentId);
const previousTarget = session.executionTarget;
const providerChanged =
nextTarget.modelProviderId !== previousTarget?.modelProviderId;
Expand All @@ -571,13 +572,13 @@ export function useResolvedAgentModelPicker({
// the draft and let draft promotion configure the real backend session.
if (session.creationState === "pending") {
if (providerChanged && !sessionHasStarted) {
setGlobalSelectedProvider(selectedAgentId);
setGlobalSelectedProvider(targetAgentId);
}
beginModelSelectionIntent(sessionId, {
requestId,
target: nextTarget,
previousTarget,
preferenceAgentId: selectedAgentId,
preferenceAgentId: targetAgentId,
});
return;
}
Expand All @@ -588,7 +589,7 @@ export function useResolvedAgentModelPicker({
previousTarget,
});
if (providerChanged && !sessionHasStarted) {
setGlobalSelectedProvider(selectedAgentId);
setGlobalSelectedProvider(targetAgentId);
}

void (async () => {
Expand All @@ -609,10 +610,7 @@ export function useResolvedAgentModelPicker({
return;
}
if (!sessionHasStarted) {
setStoredModelPreference(
selectedAgentId,
nextStoredModelPreference,
);
setStoredModelPreference(targetAgentId, nextStoredModelPreference);
}
} catch (error) {
const intentStillMatches = clearCurrentModelSelectionIntent(
Expand All @@ -635,7 +633,7 @@ export function useResolvedAgentModelPicker({
? undefined
: () =>
setStoredModelPreference(
selectedAgentId,
targetAgentId,
nextStoredModelPreference,
),
)
Expand All @@ -649,11 +647,11 @@ export function useResolvedAgentModelPicker({
if (!sessionHasStarted) {
if (previousStoredModelPreference) {
setStoredModelPreference(
selectedAgentId,
targetAgentId,
previousStoredModelPreference,
);
} else {
clearStoredModelPreference(selectedAgentId);
clearStoredModelPreference(targetAgentId);
}
}
rollbackToPreviousModel({
Expand Down
62 changes: 62 additions & 0 deletions src/features/chat/hooks/useStarredModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useCallback, useSyncExternalStore } from "react";
import type { ModelOption } from "../types";
import {
getStarredModels,
modelStarKey,
STARRED_MODELS_CHANGED_EVENT,
STARRED_MODELS_STORAGE_KEY,
starredModelKey,
toggleModelStar,
type StarredModelRecord,
} from "../lib/starredModels";

const EMPTY_RECORDS: StarredModelRecord[] = [];
let cachedSnapshot: StarredModelRecord[] | null = null;

export function __resetStarredModelsCacheForTests(): void {
cachedSnapshot = null;
}

function getSnapshot(): StarredModelRecord[] {
cachedSnapshot ??= getStarredModels();
return cachedSnapshot;
}

function subscribe(callback: () => void): () => void {
const update = () => {
cachedSnapshot = null;
callback();
};
const onStorage = (event: StorageEvent) => {
if (event.key === null || event.key === STARRED_MODELS_STORAGE_KEY)
update();
};
window.addEventListener(STARRED_MODELS_CHANGED_EVENT, update);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(STARRED_MODELS_CHANGED_EVENT, update);
window.removeEventListener("storage", onStorage);
};
}

export function useStarredModels() {
const starredModels = useSyncExternalStore(
subscribe,
getSnapshot,
() => EMPTY_RECORDS,
);
const starredKeys = new Set(starredModels.map(starredModelKey));
const isStarred = useCallback(
(agentId: string, model: ModelOption) =>
starredKeys.has(modelStarKey(agentId, model.providerId, model.id)),
[starredKeys],
);

return {
starredModels,
isStarred,
toggleStar: useCallback((agentId: string, model: ModelOption) => {
toggleModelStar(agentId, model);
}, []),
};
}
86 changes: 86 additions & 0 deletions src/features/chat/lib/starredModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ModelOption } from "../types";

export const STARRED_MODELS_STORAGE_KEY = "berd:starred-models-v2";
export const STARRED_MODELS_CHANGED_EVENT = "berd:starred-models-v2-changed";

export interface StarredModelRecord {
agentId: string;
model: ModelOption;
}

export function modelStarKey(
agentId: string,
modelProviderId: string | undefined,
modelId: string,
): string {
return JSON.stringify([agentId, modelProviderId ?? "", modelId]);
}

export function starredModelKey(record: StarredModelRecord): string {
return modelStarKey(record.agentId, record.model.providerId, record.model.id);
}

function isModelOption(value: unknown): value is ModelOption {
if (!value || typeof value !== "object") return false;
const model = value as Partial<ModelOption>;
return typeof model.id === "string" && typeof model.name === "string";
}

function isStarredModelRecord(value: unknown): value is StarredModelRecord {
if (!value || typeof value !== "object") return false;
const record = value as Partial<StarredModelRecord>;
return typeof record.agentId === "string" && isModelOption(record.model);
}

export function getStarredModels(): StarredModelRecord[] {
if (typeof window === "undefined") return [];

try {
const parsed: unknown = JSON.parse(
window.localStorage.getItem(STARRED_MODELS_STORAGE_KEY) ?? "[]",
);
if (!Array.isArray(parsed)) return [];

const seen = new Set<string>();
return parsed.filter((value): value is StarredModelRecord => {
if (!isStarredModelRecord(value)) return false;
const key = starredModelKey(value);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
} catch {
return [];
}
}

function persistStarredModels(records: StarredModelRecord[]): void {
try {
if (records.length === 0) {
window.localStorage.removeItem(STARRED_MODELS_STORAGE_KEY);
} else {
window.localStorage.setItem(
STARRED_MODELS_STORAGE_KEY,
JSON.stringify(records),
);
}
} catch {
// localStorage may be unavailable.
}
window.dispatchEvent(new CustomEvent(STARRED_MODELS_CHANGED_EVENT));
}

export function toggleModelStar(agentId: string, model: ModelOption): void {
const records = getStarredModels();
const key = modelStarKey(agentId, model.providerId, model.id);
const existingIndex = records.findIndex(
(record) => starredModelKey(record) === key,
);

if (existingIndex >= 0) {
records.splice(existingIndex, 1);
} else {
records.push({ agentId, model });
}
persistStarredModels(records);
}
2 changes: 2 additions & 0 deletions src/features/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type { SessionExecutionTarget } from "./lib/sessionExecutionTarget";

export interface ModelOption {
id: string;
/** Agent that owns this row when it comes from the global starred section. */
agentId?: string;
name: string;
displayName?: string;
provider?: string;
Expand Down
17 changes: 11 additions & 6 deletions src/features/chat/ui/AgentModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ type PopoverContentAlign = NonNullable<
ComponentProps<typeof PopoverContent>["align"]
>;
const REASONING_EFFORT_COLUMN_TRANSITION_MS = 240;
const PICKER_WIDTH_COMPACT_PX = 420;
const PICKER_WIDTH_EXPANDED_PX = 596;
const PICKER_WIDTH_COMPACT_PX = 452;
const PICKER_WIDTH_EXPANDED_PX = 628;

function toSentenceCaseLabel(value: string | undefined): string {
const trimmed = value?.trim();
Expand Down Expand Up @@ -367,8 +367,13 @@ export function AgentModelPicker({
};

const handleModelSelect = (model: ModelOption) => {
recordModelSelection(selectedAgentId, model);
onModelChange?.(model.id, model);
const targetAgentId = model.agentId ?? selectedAgentId;
const selectedModel = { ...model, agentId: undefined };
recordModelSelection(targetAgentId, selectedModel);
onModelChange?.(selectedModel.id, {
...selectedModel,
agentId: targetAgentId,
});
};

// Re-gate the provider column when the popover closes, so every reopen
Expand Down Expand Up @@ -500,7 +505,7 @@ export function AgentModelPicker({
// gated single-column layout has no dead vertical space below the
// model list.
"flex max-h-[min(24rem,50vh)] flex-col overflow-hidden p-1 transition-[width] duration-[240ms] ease-[cubic-bezier(0.2,0,0,1)]",
isWidePicker ? "w-[37.25rem]" : "w-[26.25rem]",
isWidePicker ? "w-[39.25rem]" : "w-[28.25rem]",
)}
onInteractOutside={(event) => {
classifyOutsideInteraction(event.target);
Expand Down Expand Up @@ -672,7 +677,7 @@ export function AgentModelPicker({
data-col="model"
className={cn(
"flex min-h-0 min-w-0 overflow-hidden p-1",
showAgentColumn ? "ml-1 w-56 shrink-0" : "flex-1",
showAgentColumn ? "ml-1 w-64 shrink-0" : "flex-1",
)}
>
{modelsLoading ? (
Expand Down
Loading
Loading