feat: persist GooseMode per-session via session DB (#7854)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user