feat: persist GooseMode per-session via session DB (#7854)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
@@ -663,6 +663,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agent/update_session": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::agent"
|
||||
],
|
||||
"operationId": "update_session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateSessionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Session updated"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agent/update_working_dir": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -5198,6 +5227,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"GooseMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"approve",
|
||||
"smart_approve",
|
||||
"chat"
|
||||
]
|
||||
},
|
||||
"HfGgufFile": {
|
||||
"type": "object",
|
||||
"description": "A single downloadable GGUF file (used internally and for downloads).",
|
||||
@@ -7478,6 +7516,9 @@
|
||||
"extension_data": {
|
||||
"$ref": "#/components/schemas/ExtensionData"
|
||||
},
|
||||
"goose_mode": {
|
||||
"$ref": "#/components/schemas/GooseMode"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8496,6 +8537,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateSessionRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session_id"
|
||||
],
|
||||
"properties": {
|
||||
"goose_mode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateSessionUserRecipeValuesRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
listSessions,
|
||||
getSession,
|
||||
updateAgentProvider,
|
||||
updateSession,
|
||||
upsertConfig,
|
||||
reply,
|
||||
} from '../../src/api';
|
||||
import { execSync } from 'child_process';
|
||||
@@ -110,6 +112,7 @@ extensions:
|
||||
const session = startResponse.data!;
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.name).toBeDefined();
|
||||
expect(session.goose_mode).toBe('auto');
|
||||
|
||||
const getResponse = await getSession({
|
||||
client: ctx.client,
|
||||
@@ -129,6 +132,105 @@ extensions:
|
||||
expect(sessionsResponse.data!.sessions).toBeDefined();
|
||||
expect(Array.isArray(sessionsResponse.data!.sessions)).toBe(true);
|
||||
});
|
||||
|
||||
it('should persist goose_mode on the session', async () => {
|
||||
await upsertConfig({
|
||||
client: ctx.client,
|
||||
body: { key: 'GOOSE_MODE', value: 'approve', is_secret: false },
|
||||
});
|
||||
|
||||
try {
|
||||
const startResponse = await startAgent({
|
||||
client: ctx.client,
|
||||
body: { working_dir: os.tmpdir() },
|
||||
});
|
||||
expect(startResponse.response).toBeOkResponse();
|
||||
expect(startResponse.data!.goose_mode).toBe('approve');
|
||||
|
||||
const getResponse = await getSession({
|
||||
client: ctx.client,
|
||||
path: { session_id: startResponse.data!.id },
|
||||
});
|
||||
expect(getResponse.response).toBeOkResponse();
|
||||
expect(getResponse.data!.goose_mode).toBe('approve');
|
||||
} finally {
|
||||
// Restore default so subsequent tests don't inherit approve mode
|
||||
await upsertConfig({
|
||||
client: ctx.client,
|
||||
body: { key: 'GOOSE_MODE', value: 'auto', is_secret: false },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should update goose_mode on an active session via /agent/update_session', async () => {
|
||||
const startResponse = await startAgent({
|
||||
client: ctx.client,
|
||||
body: { working_dir: os.tmpdir() },
|
||||
});
|
||||
expect(startResponse.response).toBeOkResponse();
|
||||
const sessionId = startResponse.data!.id;
|
||||
|
||||
const updateResponse = await updateSession({
|
||||
client: ctx.client,
|
||||
body: { session_id: sessionId, goose_mode: 'approve' },
|
||||
});
|
||||
expect(updateResponse.response).toBeOkResponse();
|
||||
|
||||
const getResponse = await getSession({
|
||||
client: ctx.client,
|
||||
path: { session_id: sessionId },
|
||||
});
|
||||
expect(getResponse.response).toBeOkResponse();
|
||||
expect(getResponse.data!.goose_mode).toBe('approve');
|
||||
});
|
||||
|
||||
it('should preserve goose_mode after provider swap via /agent/update_provider', async (testContext) => {
|
||||
const configResponse = await readConfig({
|
||||
client: ctx.client,
|
||||
body: { key: 'GOOSE_PROVIDER', is_secret: false },
|
||||
});
|
||||
const providerName = configResponse.data as string | null | undefined;
|
||||
if (!providerName) {
|
||||
testContext.skip('Skipping - no GOOSE_PROVIDER configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelResponse = await readConfig({
|
||||
client: ctx.client,
|
||||
body: { key: 'GOOSE_MODEL', is_secret: false },
|
||||
});
|
||||
const modelName = (modelResponse.data as string | null) || undefined;
|
||||
|
||||
const startResponse = await startAgent({
|
||||
client: ctx.client,
|
||||
body: { working_dir: os.tmpdir() },
|
||||
});
|
||||
expect(startResponse.response).toBeOkResponse();
|
||||
const sessionId = startResponse.data!.id;
|
||||
|
||||
const updateResponse = await updateSession({
|
||||
client: ctx.client,
|
||||
body: { session_id: sessionId, goose_mode: 'approve' },
|
||||
});
|
||||
expect(updateResponse.response).toBeOkResponse();
|
||||
|
||||
const providerResponse = await updateAgentProvider({
|
||||
client: ctx.client,
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
provider: providerName,
|
||||
model: modelName,
|
||||
},
|
||||
});
|
||||
expect(providerResponse.response).toBeOkResponse();
|
||||
|
||||
const getResponse = await getSession({
|
||||
client: ctx.client,
|
||||
path: { session_id: sessionId },
|
||||
});
|
||||
expect(getResponse.response).toBeOkResponse();
|
||||
expect(getResponse.data!.goose_mode).toBe('approve');
|
||||
});
|
||||
});
|
||||
|
||||
describe('messaging', () => {
|
||||
|
||||
Reference in New Issue
Block a user