feat: persist GooseMode per-session via session DB (#7854)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-03-16 19:37:21 +08:00
committed by GitHub
parent 2631095f20
commit 94fdcdd07a
42 changed files with 953 additions and 147 deletions
+3 -3
View File
@@ -158,7 +158,7 @@ const PairRouteWrapper = ({
return null;
};
const SettingsRoute = () => {
const SettingsRoute = ({ activeSessionId }: { activeSessionId?: string }) => {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -174,7 +174,7 @@ const SettingsRoute = () => {
viewOptions.section = sectionFromUrl;
}
return <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={viewOptions} />;
return <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={{...viewOptions, sessionId: activeSessionId}} />;
};
const SessionsRoute = () => {
@@ -667,7 +667,7 @@ export function AppInner() {
/>
}
/>
<Route path="settings" element={<SettingsRoute />} />
<Route path="settings" element={<SettingsRoute activeSessionId={activeSessions[activeSessions.length - 1]?.sessionId} />} />
<Route
path="extensions"
element={
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+33
View File
@@ -492,6 +492,8 @@ export type GooseApp = McpAppResource & (WindowProps | null) & {
prd?: string | null;
};
export type GooseMode = 'auto' | 'approve' | 'smart_approve' | 'chat';
/**
* A single downloadable GGUF file (used internally and for downloads).
*/
@@ -1200,6 +1202,7 @@ export type Session = {
conversation?: Conversation | null;
created_at: string;
extension_data: ExtensionData;
goose_mode?: GooseMode;
id: string;
input_tokens?: number | null;
message_count: number;
@@ -1548,6 +1551,11 @@ export type UpdateSessionNameRequest = {
name: string;
};
export type UpdateSessionRequest = {
goose_mode?: string | null;
session_id: string;
};
export type UpdateSessionUserRecipeValuesRequest = {
/**
* Recipe parameter values entered by the user
@@ -2079,6 +2087,31 @@ export type UpdateAgentProviderResponses = {
200: unknown;
};
export type UpdateSessionData = {
body: UpdateSessionRequest;
path?: never;
query?: never;
url: '/agent/update_session';
};
export type UpdateSessionErrors = {
/**
* Invalid request
*/
400: unknown;
/**
* Internal error
*/
500: unknown;
};
export type UpdateSessionResponses = {
/**
* Session updated
*/
200: unknown;
};
export type UpdateWorkingDirData = {
body: UpdateWorkingDirRequest;
path?: never;
+1 -1
View File
@@ -1571,7 +1571,7 @@ export default function ChatInput({
</div>
</Tooltip>
<div className="w-px h-4 bg-border-primary mx-2" />
<BottomMenuModeSelection />
<BottomMenuModeSelection sessionId={sessionId} />
<div className="w-px h-4 bg-border-primary mx-2" />
<BottomMenuExtensionSelection sessionId={sessionId} />
{sessionId && messages.length > 0 && (
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { BottomMenuModeSelection } from './BottomMenuModeSelection';
let mockConfig: Record<string, unknown> = {};
const mockUpdateSession = vi.fn().mockResolvedValue({});
const mockGetSession = vi.fn().mockResolvedValue({ data: null });
vi.mock('../ConfigContext', () => ({
useConfig: () => ({
config: mockConfig,
}),
}));
vi.mock('../../utils/analytics', () => ({
trackModeChanged: vi.fn(),
}));
vi.mock('../../api', () => ({
updateSession: (...args: unknown[]) => mockUpdateSession(...args),
getSession: (...args: unknown[]) => mockGetSession(...args),
}));
// Radix dropdown doesn't open in jsdom — render children directly
vi.mock('../ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('BottomMenuModeSelection', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig = {};
});
it('displays mode from config when no session', async () => {
mockConfig.GOOSE_MODE = 'approve';
render(<BottomMenuModeSelection sessionId={null} />);
await waitFor(() => {
expect(screen.getByText('manual')).toBeInTheDocument();
});
});
it('defaults to auto when config has no mode', async () => {
mockConfig.GOOSE_MODE = undefined;
render(<BottomMenuModeSelection sessionId={null} />);
await waitFor(() => {
expect(screen.getByText('autonomous')).toBeInTheDocument();
});
});
it('fetches mode from session when sessionId is present', async () => {
mockConfig.GOOSE_MODE = 'auto';
mockGetSession.mockResolvedValue({ data: { goose_mode: 'approve' } });
render(<BottomMenuModeSelection sessionId="test-session-123" />);
await waitFor(() => {
expect(screen.getByText('manual')).toBeInTheDocument();
});
expect(mockGetSession).toHaveBeenCalledWith({
path: { session_id: 'test-session-123' },
});
});
it('calls updateSession and does not write global config', async () => {
mockConfig.GOOSE_MODE = 'auto';
render(<BottomMenuModeSelection sessionId="test-session-123" />);
fireEvent.click(screen.getByText('Manual'));
await waitFor(() => {
expect(mockUpdateSession).toHaveBeenCalledWith({
body: { session_id: 'test-session-123', goose_mode: 'approve' },
});
});
});
it('does not call updateSession when sessionId is null', async () => {
mockConfig.GOOSE_MODE = 'auto';
render(<BottomMenuModeSelection sessionId={null} />);
fireEvent.click(screen.getByText('Manual'));
await waitFor(() => {
expect(screen.getByText('manual')).toBeInTheDocument();
});
expect(mockUpdateSession).not.toHaveBeenCalled();
});
it('ignores stale session fetch after sessionId changes', async () => {
let resolveA: (value: unknown) => void;
const promiseA = new Promise((resolve) => {
resolveA = resolve;
});
mockGetSession
.mockImplementationOnce(() => promiseA)
.mockResolvedValueOnce({ data: { goose_mode: 'auto' } });
const { rerender } = render(<BottomMenuModeSelection sessionId="session-A" />);
rerender(<BottomMenuModeSelection sessionId="session-B" />);
await waitFor(() => {
expect(screen.getByText('autonomous')).toBeInTheDocument();
});
resolveA!({ data: { goose_mode: 'approve' } });
await waitFor(() => {
expect(screen.getByText('autonomous')).toBeInTheDocument();
});
expect(screen.queryByText('manual')).not.toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Tornado } from 'lucide-react';
import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem';
import { useConfig } from '../ConfigContext';
@@ -9,25 +9,30 @@ import {
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { trackModeChanged } from '../../utils/analytics';
import { getSession, updateSession } from '../../api';
export const BottomMenuModeSelection = () => {
export const BottomMenuModeSelection = ({ sessionId }: { sessionId: string | null }) => {
const [gooseMode, setGooseMode] = useState('auto');
const { read, upsert } = useConfig();
const { config } = useConfig();
const fetchCurrentMode = useCallback(async () => {
try {
const mode = (await read('GOOSE_MODE', false)) as string;
useEffect(() => {
let cancelled = false;
if (sessionId) {
getSession({ path: { session_id: sessionId } }).then((res) => {
if (!cancelled && res.data?.goose_mode) {
setGooseMode(res.data.goose_mode);
}
});
} else {
const mode = config.GOOSE_MODE as string | undefined;
if (mode) {
setGooseMode(mode);
}
} catch (error) {
console.error('Error fetching current mode:', error);
}
}, [read]);
useEffect(() => {
fetchCurrentMode();
}, [fetchCurrentMode]);
return () => {
cancelled = true;
};
}, [sessionId, config.GOOSE_MODE]);
const handleModeChange = async (newMode: string) => {
if (gooseMode === newMode) {
@@ -35,7 +40,9 @@ export const BottomMenuModeSelection = () => {
}
try {
await upsert('GOOSE_MODE', newMode, false);
if (sessionId) {
await updateSession({ body: { session_id: sessionId, goose_mode: newMode } });
}
setGooseMode(newMode);
trackModeChanged(gooseMode, newMode);
} catch (error) {
@@ -24,6 +24,7 @@ export type SettingsViewOptions = {
deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean;
section?: string;
sessionId?: string;
};
export default function SettingsView({
@@ -191,7 +192,7 @@ export default function SettingsView({
value="chat"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<ChatSettingsSection />
<ChatSettingsSection sessionId={viewOptions.sessionId} />
</TabsContent>
<TabsContent
@@ -6,7 +6,7 @@ import { GoosehintsSection } from './GoosehintsSection';
import { SpellcheckToggle } from './SpellcheckToggle';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
export default function ChatSettingsSection() {
export default function ChatSettingsSection({ sessionId }: { sessionId?: string }) {
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
<Card className="pb-2 rounded-lg">
@@ -15,7 +15,7 @@ export default function ChatSettingsSection() {
<CardDescription>Configure how Goose interacts with tools and extensions</CardDescription>
</CardHeader>
<CardContent className="px-2">
<ModeSection />
<ModeSection sessionId={sessionId} />
</CardContent>
</Card>
@@ -2,14 +2,18 @@ import { useEffect, useState, useCallback } from 'react';
import { all_goose_modes, ModeSelectionItem } from './ModeSelectionItem';
import { useConfig } from '../../ConfigContext';
import { ConversationLimitsDropdown } from './ConversationLimitsDropdown';
import { updateSession } from '../../../api';
export const ModeSection = () => {
export const ModeSection = ({ sessionId }: { sessionId?: string }) => {
const [currentMode, setCurrentMode] = useState('auto');
const [maxTurns, setMaxTurns] = useState<number>(1000);
const { read, upsert } = useConfig();
const { config, read, upsert } = useConfig();
const handleModeChange = async (newMode: string) => {
try {
if (sessionId) {
await updateSession({ body: { session_id: sessionId, goose_mode: newMode } });
}
await upsert('GOOSE_MODE', newMode, false);
setCurrentMode(newMode);
} catch (error) {
@@ -18,16 +22,12 @@ export const ModeSection = () => {
}
};
const fetchCurrentMode = useCallback(async () => {
try {
const mode = (await read('GOOSE_MODE', false)) as string;
if (mode) {
setCurrentMode(mode);
}
} catch (error) {
console.error('Error fetching current mode:', error);
useEffect(() => {
const mode = config.GOOSE_MODE as string | undefined;
if (mode) {
setCurrentMode(mode);
}
}, [read]);
}, [config.GOOSE_MODE]);
const fetchMaxTurns = useCallback(async () => {
try {
@@ -50,9 +50,8 @@ export const ModeSection = () => {
};
useEffect(() => {
fetchCurrentMode();
fetchMaxTurns();
}, [fetchCurrentMode, fetchMaxTurns]);
}, [fetchMaxTurns]);
return (
<div className="space-y-1">