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
+56
View File
@@ -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": [
+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">
+102
View File
@@ -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', () => {