Skip to content

Commit 22afde0

Browse files
morgmartspikewang
authored andcommitted
persist and reliably apply chat model selection (aaif-goose#8734)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
1 parent 6fa2c65 commit 22afde0

12 files changed

Lines changed: 1734 additions & 210 deletions

ui/goose2/src/app/AppShell.tsx

Lines changed: 53 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,25 @@ import { TopBar } from "./ui/TopBar";
1010
import { useChatStore } from "@/features/chat/stores/chatStore";
1111
import {
1212
type ChatSession,
13-
hasSessionStarted,
1413
useChatSessionStore,
1514
} from "@/features/chat/stores/chatSessionStore";
1615
import { useAgentStore } from "@/features/agents/stores/agentStore";
1716
import { useProjectStore } from "@/features/projects/stores/projectStore";
1817
import { findExistingDraft } from "@/features/chat/lib/newChat";
1918
import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
2019
import { useAppStartup } from "./hooks/useAppStartup";
20+
import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync";
21+
import { loadStoredHomeSessionId } from "./lib/homeSessionStorage";
22+
import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference";
2123
import { AppShellContent } from "./ui/AppShellContent";
22-
import { acpPrepareSession } from "@/shared/api/acp";
24+
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
2325
import {
2426
clearReplayBuffer,
2527
getAndDeleteReplayBuffer,
2628
} from "@/features/chat/hooks/replayBuffer";
2729
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
2830
import { perfLog } from "@/shared/lib/perfLog";
31+
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
2932

3033
export type AppView =
3134
| "home"
@@ -40,34 +43,6 @@ const SIDEBAR_MIN_WIDTH = 180;
4043
const SIDEBAR_MAX_WIDTH = 380;
4144
const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100;
4245
const SIDEBAR_COLLAPSED_WIDTH = 48;
43-
const HOME_SESSION_STORAGE_KEY = "goose:home-session-id";
44-
45-
function loadStoredHomeSessionId(): string | null {
46-
if (typeof window === "undefined") {
47-
return null;
48-
}
49-
try {
50-
return window.localStorage.getItem(HOME_SESSION_STORAGE_KEY);
51-
} catch {
52-
return null;
53-
}
54-
}
55-
56-
function persistHomeSessionId(sessionId: string | null): void {
57-
if (typeof window === "undefined") {
58-
return;
59-
}
60-
try {
61-
if (sessionId) {
62-
window.localStorage.setItem(HOME_SESSION_STORAGE_KEY, sessionId);
63-
return;
64-
}
65-
window.localStorage.removeItem(HOME_SESSION_STORAGE_KEY);
66-
} catch {
67-
// localStorage may be unavailable
68-
}
69-
}
70-
7146
export function AppShell({ children }: { children?: React.ReactNode }) {
7247
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
7348
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
@@ -90,6 +65,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
9065
const sessionStore = useChatSessionStore();
9166
const agentStore = useAgentStore();
9267
const projectStore = useProjectStore();
68+
const providerInventoryEntries = useProviderInventoryStore((s) => s.entries);
9369

9470
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
9571
null,
@@ -173,37 +149,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
173149
? sessionStore.getSession(homeSessionId)
174150
: undefined;
175151

176-
useEffect(() => {
177-
if (
178-
!homeSessionId ||
179-
!sessionStore.hasHydratedSessions ||
180-
sessionStore.isLoading
181-
) {
182-
return;
183-
}
184-
if (
185-
!homeSession ||
186-
homeSession.archivedAt ||
187-
hasSessionStarted(
188-
homeSession,
189-
chatStore.messagesBySession[homeSession.id],
190-
)
191-
) {
192-
setHomeSessionId(null);
193-
}
194-
}, [
195-
chatStore.messagesBySession,
196-
homeSession,
197-
homeSession?.archivedAt,
198-
homeSession?.messageCount,
152+
useHomeSessionStateSync({
199153
homeSessionId,
200-
sessionStore.hasHydratedSessions,
201-
sessionStore.isLoading,
202-
]);
203-
204-
useEffect(() => {
205-
persistHomeSessionId(homeSessionId);
206-
}, [homeSessionId]);
154+
homeSession,
155+
messagesBySession: chatStore.messagesBySession,
156+
hasHydratedSessions: sessionStore.hasHydratedSessions,
157+
isLoading: sessionStore.isLoading,
158+
setHomeSessionId,
159+
});
207160

208161
const ensureHomeSession = useCallback(async () => {
209162
if (!sessionStore.hasHydratedSessions || sessionStore.isLoading) {
@@ -220,6 +173,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
220173
!homeSession.archivedAt &&
221174
homeSession.messageCount === 0
222175
) {
176+
const sessionModelPreference =
177+
await resolveSupportedSessionModelPreference(
178+
agentStore.selectedProvider ?? "goose",
179+
providerInventoryEntries,
180+
);
223181
const project = homeSession.projectId
224182
? (projectStore.projects.find(
225183
(candidate) => candidate.id === homeSession.projectId,
@@ -228,20 +186,42 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
228186
const workingDir = await resolveSessionCwd(project);
229187
await acpPrepareSession(
230188
homeSession.id,
231-
homeSession.providerId ?? agentStore.selectedProvider ?? "goose",
189+
sessionModelPreference.providerId,
232190
workingDir,
233191
{
234192
personaId: homeSession.personaId,
235193
},
236194
);
195+
const shouldClearHomeModel =
196+
sessionModelPreference.providerId !== homeSession.providerId ||
197+
!sessionModelPreference.modelId;
198+
sessionStore.updateSession(homeSession.id, {
199+
providerId: sessionModelPreference.providerId,
200+
modelId: shouldClearHomeModel ? undefined : homeSession.modelId,
201+
modelName: shouldClearHomeModel ? undefined : homeSession.modelName,
202+
});
203+
if (sessionModelPreference.modelId) {
204+
await acpSetModel(homeSession.id, sessionModelPreference.modelId);
205+
sessionStore.updateSession(homeSession.id, {
206+
modelId: sessionModelPreference.modelId,
207+
modelName: sessionModelPreference.modelName,
208+
});
209+
}
237210
return homeSession;
238211
}
239212

240213
const workingDir = await resolveSessionCwd(null);
214+
const sessionModelPreference =
215+
await resolveSupportedSessionModelPreference(
216+
agentStore.selectedProvider ?? "goose",
217+
providerInventoryEntries,
218+
);
241219
const session = await sessionStore.createSession({
242220
title: DEFAULT_CHAT_TITLE,
243-
providerId: agentStore.selectedProvider ?? "goose",
221+
providerId: sessionModelPreference.providerId,
244222
workingDir,
223+
modelId: sessionModelPreference.modelId,
224+
modelName: sessionModelPreference.modelName,
245225
});
246226
setHomeSessionId(session.id);
247227
return session;
@@ -258,6 +238,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
258238
}, [
259239
agentStore.selectedProvider,
260240
homeSession,
241+
providerInventoryEntries,
261242
projectStore.projects,
262243
sessionStore.hasHydratedSessions,
263244
sessionStore,
@@ -282,7 +263,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
282263
const agentId = agentStore.activeAgentId ?? undefined;
283264
const providerId =
284265
project?.preferredProvider ?? agentStore.selectedProvider ?? "goose";
285-
const modelId = project?.preferredModel ?? undefined;
266+
const sessionModelPreference =
267+
await resolveSupportedSessionModelPreference(
268+
providerId,
269+
providerInventoryEntries,
270+
project?.preferredModel ?? undefined,
271+
);
286272
const sessionState = useChatSessionStore.getState();
287273
const chatState = useChatStore.getState();
288274
const existingDraft = findExistingDraft({
@@ -311,10 +297,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
311297
title,
312298
projectId: project?.id,
313299
agentId,
314-
providerId,
300+
providerId: sessionModelPreference.providerId,
315301
workingDir,
316-
modelId,
317-
modelName: modelId,
302+
modelId: sessionModelPreference.modelId,
303+
modelName: sessionModelPreference.modelName,
318304
});
319305
sessionStore.setActiveSession(session.id);
320306
setActiveView("chat");
@@ -328,6 +314,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
328314
agentStore.activeAgentId,
329315
agentStore.selectedProvider,
330316
chatStore,
317+
providerInventoryEntries,
331318
sessionStore,
332319
],
333320
);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useEffect } from "react";
2+
import {
3+
hasSessionStarted,
4+
type ChatSession,
5+
} from "@/features/chat/stores/chatSessionStore";
6+
import { persistHomeSessionId } from "../lib/homeSessionStorage";
7+
8+
interface UseHomeSessionStateSyncOptions {
9+
homeSessionId: string | null;
10+
homeSession?: ChatSession;
11+
messagesBySession: Record<string, ArrayLike<unknown> | undefined>;
12+
hasHydratedSessions: boolean;
13+
isLoading: boolean;
14+
setHomeSessionId: (sessionId: string | null) => void;
15+
}
16+
17+
export function useHomeSessionStateSync({
18+
homeSessionId,
19+
homeSession,
20+
messagesBySession,
21+
hasHydratedSessions,
22+
isLoading,
23+
setHomeSessionId,
24+
}: UseHomeSessionStateSyncOptions): void {
25+
useEffect(() => {
26+
if (!homeSessionId || !hasHydratedSessions || isLoading) {
27+
return;
28+
}
29+
30+
if (
31+
!homeSession ||
32+
homeSession.archivedAt ||
33+
hasSessionStarted(homeSession, messagesBySession[homeSession.id])
34+
) {
35+
setHomeSessionId(null);
36+
}
37+
}, [
38+
hasHydratedSessions,
39+
homeSession,
40+
homeSession?.archivedAt,
41+
homeSession?.messageCount,
42+
homeSessionId,
43+
isLoading,
44+
messagesBySession,
45+
setHomeSessionId,
46+
]);
47+
48+
useEffect(() => {
49+
persistHomeSessionId(homeSessionId);
50+
}, [homeSessionId]);
51+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const HOME_SESSION_STORAGE_KEY = "goose:home-session-id";
2+
3+
export function loadStoredHomeSessionId(): string | null {
4+
if (typeof window === "undefined") {
5+
return null;
6+
}
7+
try {
8+
return window.localStorage.getItem(HOME_SESSION_STORAGE_KEY);
9+
} catch {
10+
return null;
11+
}
12+
}
13+
14+
export function persistHomeSessionId(sessionId: string | null): void {
15+
if (typeof window === "undefined") {
16+
return;
17+
}
18+
try {
19+
if (sessionId) {
20+
window.localStorage.setItem(HOME_SESSION_STORAGE_KEY, sessionId);
21+
return;
22+
}
23+
window.localStorage.removeItem(HOME_SESSION_STORAGE_KEY);
24+
} catch {
25+
// localStorage may be unavailable
26+
}
27+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { resolveSupportedSessionModelPreference } from "./resolveSupportedSessionModelPreference";
3+
4+
const mockGetProviderInventory = vi.fn();
5+
6+
vi.mock("@/features/providers/api/inventory", () => ({
7+
getProviderInventory: (...args: unknown[]) =>
8+
mockGetProviderInventory(...args),
9+
}));
10+
11+
describe("resolveSupportedSessionModelPreference", () => {
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
window.localStorage.clear();
15+
});
16+
17+
it("drops the model when provider inventory lookup fails", async () => {
18+
window.localStorage.setItem(
19+
"goose:preferredModelsByAgent",
20+
JSON.stringify({
21+
goose: {
22+
modelId: "gpt-5.4",
23+
modelName: "GPT-5.4",
24+
providerId: "openai",
25+
},
26+
}),
27+
);
28+
mockGetProviderInventory.mockRejectedValue(
29+
new Error("inventory unavailable"),
30+
);
31+
32+
await expect(
33+
resolveSupportedSessionModelPreference("goose", new Map()),
34+
).resolves.toEqual({
35+
providerId: "openai",
36+
});
37+
});
38+
39+
it("drops the model when provider inventory has no matching entry", async () => {
40+
window.localStorage.setItem(
41+
"goose:preferredModelsByAgent",
42+
JSON.stringify({
43+
goose: {
44+
modelId: "gpt-5.4",
45+
modelName: "GPT-5.4",
46+
providerId: "openai",
47+
},
48+
}),
49+
);
50+
mockGetProviderInventory.mockResolvedValue([]);
51+
52+
await expect(
53+
resolveSupportedSessionModelPreference("goose", new Map()),
54+
).resolves.toEqual({
55+
providerId: "openai",
56+
});
57+
});
58+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
2+
import { getProviderInventory } from "@/features/providers/api/inventory";
3+
import {
4+
resolveSessionModelPreference,
5+
sanitizeSessionModelPreference,
6+
type SessionModelPreference,
7+
} from "@/features/chat/lib/sessionModelPreference";
8+
9+
export async function resolveSupportedSessionModelPreference(
10+
providerId: string,
11+
inventoryEntries: Map<string, ProviderInventoryEntryDto>,
12+
preferredModel?: string,
13+
): Promise<SessionModelPreference> {
14+
const sessionModelPreference = resolveSessionModelPreference({
15+
providerId,
16+
preferredModel,
17+
});
18+
19+
if (!sessionModelPreference.modelId) {
20+
return sessionModelPreference;
21+
}
22+
23+
const inventoryEntry =
24+
inventoryEntries.get(sessionModelPreference.providerId) ??
25+
(await getProviderInventory([sessionModelPreference.providerId])
26+
.then(([entry]) => entry)
27+
.catch(() => undefined));
28+
29+
if (!inventoryEntry) {
30+
return {
31+
providerId: sessionModelPreference.providerId,
32+
};
33+
}
34+
35+
return sanitizeSessionModelPreference(sessionModelPreference, inventoryEntry);
36+
}

0 commit comments

Comments
 (0)