diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index dde58162c57..565b14af083 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -12,6 +12,7 @@ import { readLiveTurnSnapshot, } from '@/lib/mothership/chat/live-turn-snapshot' import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -32,6 +33,7 @@ function transformChat(chat: { model: string | null messages: unknown config?: unknown + effort?: MothershipEffort | null conversationId?: string | null resources?: unknown createdAt: Date | null @@ -44,6 +46,7 @@ function transformChat(chat: { messages: Array.isArray(chat.messages) ? chat.messages : [], messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, config: chat.config || null, + effort: chat.effort ?? null, ...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}), ...('resources' in chat ? { resources: Array.isArray(chat.resources) ? chat.resources : [] } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/effort/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/effort/route.ts new file mode 100644 index 00000000000..dfa0ddc94b3 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/effort/route.ts @@ -0,0 +1,23 @@ +import { setMothershipChatEffortContract } from '@/lib/api/contracts/mothership-chats' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { setChatEffort } from '@/lib/mothership/chat/application/set-effort' + +/** Records the reasoning effort the owner picked for one of their chats. */ +export const PUT = defineInternalJsonRoute({ + contract: setMothershipChatEffortContract, + operation: setChatEffort.operation, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Personal chat settings updates have no separate rate bucket.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ chatId: params.chatId, effort: body.effort }), + useCase: setChatEffort, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index bd975a2dd99..6be0e4ada77 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -94,6 +94,7 @@ export const GET = withRouteHandler( messages: effectiveMessages, activeStreamId: liveStreamId, resources: Array.isArray(chat.resources) ? chat.resources : [], + effort: chat.effort, createdAt: chat.createdAt, updatedAt: chat.updatedAt, // Events stay out of the payload (the resume endpoint replays them), diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index 0348cbed5c9..aa1466b9499 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -102,10 +102,7 @@ let queryClient: QueryClient beforeEach(() => { mocks.advanced = false - useMothershipEffortStore.setState({ - effort: 'high', - modelSelection: { model: 'gpt-6-astra', fastMode: false }, - }) + useMothershipEffortStore.getState().reset() mocks.plan = false vi.clearAllMocks() mocks.workspaces = [ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx index 9ba8dfaf2e9..9616cbf980a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -11,12 +12,18 @@ import { Brain, Check, Sparkles } from '@sim/emcn/icons' import { MOTHERSHIP_MODEL_OPTIONS, MOTHERSHIP_SIMPLE_EFFORT_OPTIONS, + type MothershipEffort, mothershipEffortOptions, resolveMothershipModelSettings, } from '@/lib/mothership/model-options' +import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { FastModeToggle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/fast-mode-toggle' import { ModelSettingTrigger } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-setting-trigger' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { + useMothershipChatHistory, + useSetMothershipChatEffort, +} from '@/hooks/queries/mothership-chats' import { useMothershipEffortStore } from '@/stores/mothership-effort/store' /** Model, reasoning effort, and Fast mode for Build chat composers. */ @@ -25,15 +32,32 @@ export function ModelSelector() { const selection = useMothershipEffortStore((state) => state.modelSelection) const setModel = useMothershipEffortStore((state) => state.setModel) const setFastMode = useMothershipEffortStore((state) => state.setFastMode) - const storedEffort = useMothershipEffortStore((state) => state.effort) + const { chatId } = useChatSurface() + const { data: chatHistory } = useMothershipChatHistory(chatId) + const chatPick = useMothershipEffortStore((state) => + chatId ? state.chatEfforts[chatId] : undefined + ) + const newChatEffort = useMothershipEffortStore((state) => state.newChatEffort) + const setNewChatEffort = useMothershipEffortStore((state) => state.setNewChatEffort) + const { mutate: saveChatEffort } = useSetMothershipChatEffort(chatId) + const effortChoice = chatId ? (chatPick ?? chatHistory?.effort) : newChatEffort const { effort, modelSelection } = resolveMothershipModelSettings( - { effort: storedEffort, modelSelection: selection }, + { effort: effortChoice ?? undefined, modelSelection: selection }, advanced ) const options = advanced ? mothershipEffortOptions(modelSelection.model) : MOTHERSHIP_SIMPLE_EFFORT_OPTIONS - const setEffort = useMothershipEffortStore((state) => state.setEffort) + const setEffort = (choice: MothershipEffort) => { + if (chatId) saveChatEffort(choice) + else setNewChatEffort(choice) + } + + useEffect(() => { + if (chatId) return + return () => useMothershipEffortStore.getState().setNewChatEffort(null) + }, [chatId]) + const effortLabel = options.find((option) => option.value === effort)?.label ?? effort const modelLabel = MOTHERSHIP_MODEL_OPTIONS.find((option) => option.value === modelSelection.model)?.label ?? diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index b8b6d1a9f76..0a85a441003 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -926,6 +926,14 @@ export function useChat( new Set()) const streamReaderRef = useRef | null>(null) const chatIdRef = useRef(initialChatId) + /** Cleared on unmount, so a late rollback cannot hand a pick to a surface the user left. */ + const surfaceMountedRef = useRef(true) + useEffect(() => { + surfaceMountedRef.current = true + return () => { + surfaceMountedRef.current = false + } + }, []) const tableViewContextsRef = useRef({ scopeId: desktopScopeId, views: new Map(), @@ -3396,6 +3404,18 @@ export function useChat( let requestChatId = queuedSendHandoff?.chatId ?? selectedChatIdRef.current ?? chatIdRef.current + // Read before the composer can unmount. Sent only when picked; otherwise the server + // uses the chat's stored pick or the default. + const effortStore = useMothershipEffortStore.getState() + const effortChoice = + options?.requestMode === 'assistant' + ? undefined + : requestChatId + ? (effortStore.chatEfforts[requestChatId] ?? + queryClient.getQueryData( + mothershipChatKeys.detail(requestChatId) + )?.effort) + : effortStore.newChatEffort const writeQueuedSendHandoff = (chatId?: string) => { if (!queuedSendHandoff) return if (!chatId && !queuedSendHandoff.supersededStreamId) return @@ -3530,6 +3550,16 @@ export function useChat( } const rollbackOptimisticSend = () => { + // A withdrawn first send hands its pick back to the new-chat composer for the retry, + // only while that surface is still open on the new chat. + if ( + !requestChatId && + effortChoice && + surfaceMountedRef.current && + !chatIdRef.current && + !selectedChatIdRef.current + ) + useMothershipEffortStore.getState().setNewChatEffort(effortChoice) if (requestChatId) { upsertChatHistory(requestChatId, (current) => ({ ...current, @@ -3707,10 +3737,11 @@ export function useChat( userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, ...(options?.requestMode !== 'assistant' ? { - ...resolveMothershipModelSettings( + modelSelection: resolveMothershipModelSettings( useMothershipEffortStore.getState(), modelSelectorEnabled - ), + ).modelSelection, + ...(effortChoice ? { effort: effortChoice } : {}), } : {}), }), @@ -3727,6 +3758,8 @@ export function useChat( return consumedByTranscript } if (admittedChatId && !requestChatId) { + if (effortChoice) + useMothershipEffortStore.getState().adoptNewChatEffort(admittedChatId, effortChoice) requestChatId = admittedChatId streamTargetChatId = admittedChatId adoptResolvedChatId(admittedChatId, { replaceHomeHistory: true, invalidateList: true }) @@ -3775,6 +3808,9 @@ export function useChat( const conflictChatId = typeof errorData.chatId === 'string' ? errorData.chatId : undefined if (conflictChatId && !streamTargetChatId) { + // The retry carries the same pick the first attempt stored on that chat. + if (effortChoice) + useMothershipEffortStore.getState().adoptNewChatEffort(conflictChatId, effortChoice) adoptResolvedChatId(conflictChatId, { replaceHomeHistory: true, invalidateList: true, diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 7f745e07fe1..b15d1a93c84 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,5 +1,9 @@ import { Blimp } from '@sim/emcn/icons' -import { MOTHERSHIP_EFFORT_OPTIONS, MOTHERSHIP_MODEL_OPTIONS } from '@/lib/mothership/model-options' +import { + DEFAULT_MOTHERSHIP_EFFORT, + MOTHERSHIP_EFFORT_OPTIONS, + MOTHERSHIP_MODEL_OPTIONS, +} from '@/lib/mothership/model-options' import type { BlockConfig } from '@/blocks/types' export const MothershipBlock: BlockConfig = { @@ -45,7 +49,7 @@ export const MothershipBlock: BlockConfig = { title: 'Reasoning Effort', type: 'dropdown', options: MOTHERSHIP_EFFORT_OPTIONS.map(({ value, label }) => ({ id: value, label })), - value: () => 'high', + value: () => DEFAULT_MOTHERSHIP_EFFORT, }, { id: 'fastMode', diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index ec27a4848a0..2e0a5c8ecac 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -29,6 +29,7 @@ import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from import { resolveMcpToolBinding } from '@/lib/mcp/tool-binding' import { resolveMothershipConversation } from '@/lib/mothership/conversation-id' import { ChatPayloadSchema, ModelSelectionSchema } from '@/lib/mothership/generated/protocol' +import { DEFAULT_MOTHERSHIP_EFFORT } from '@/lib/mothership/model-options' import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import { areModelSafeWorkspaceFileKeys, @@ -971,7 +972,7 @@ export class MothershipBlockHandler implements BlockHandler { model: inputs.model ?? 'gpt-6-astra', fastMode: inputs.model === 'claude-opus-5' ? false : (inputs.fastMode ?? false), }) - const effort = ChatPayloadSchema.shape.effort.parse(inputs.effort ?? 'high') + const effort = ChatPayloadSchema.shape.effort.parse(inputs.effort ?? DEFAULT_MOTHERSHIP_EFFORT) const body: Record = { messages, useConversationHistory: true, diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 48ea0783916..314489d4b5c 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -20,18 +20,22 @@ import { type MothershipChatScope, markMothershipChatReadContract, restoreMothershipChatContract, + type SetMothershipChatEffortBody, + setMothershipChatEffortContract, updateMothershipChatContract, } from '@/lib/api/contracts/mothership-chats' import { mothershipResourceSchema } from '@/lib/api/contracts/mothership-resources' import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope' import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { type FilePreviewSession, isFilePreviewSession, } from '@/lib/mothership/request/session/file-preview-session-contract' import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/mothership/request/session/types' import type { MothershipResource } from '@/lib/mothership/resources/types' +import { useMothershipEffortStore } from '@/stores/mothership-effort/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' export interface MothershipChatMetadata { @@ -53,6 +57,8 @@ export interface MothershipChatHistory { messages: PersistedMessage[] activeStreamId: string | null resources: MothershipResource[] + /** The effort the user picked for this chat; null or absent while it follows the default. */ + effort?: MothershipEffort | null streamSnapshot?: { events: StreamBatchEvent[] previewSessions: FilePreviewSession[] @@ -200,6 +206,7 @@ function parseChatHistory(value: unknown): MothershipChatHistory { messages: normalizeMessages(chat.messages), activeStreamId: chat.activeStreamId, resources: parseResources(chat.resources, `${chatContext}.resources`), + effort: getMothershipChatResponseSchema.shape.chat.shape.effort.parse(chat.effort ?? null), streamSnapshot: parseStrictStreamSnapshot(chat.streamSnapshot, `${chatContext}.streamSnapshot`), } } @@ -581,6 +588,43 @@ export function useSetMothershipChatPinned(owner?: MothershipChatOwner) { }) } +async function setChatEffort({ + chatId, + effort, +}: SetMothershipChatEffortBody & { chatId: string }): Promise { + await requestJson(setMothershipChatEffortContract, { + params: { chatId }, + body: { effort }, + }) +} + +/** + * Records the effort the user picked for a chat. The pick shows and sends at once from the + * session's pick map; saves for one chat run one at a time so the last pick is the one stored. + */ +export function useSetMothershipChatEffort(chatId: string | undefined) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (effort: MothershipEffort) => { + if (!chatId) throw new Error('A chat effort needs a chat') + return setChatEffort({ chatId, effort }) + }, + scope: { id: `mothership-chat-effort:${chatId ?? ''}` }, + onMutate: (effort) => { + if (chatId) useMothershipEffortStore.getState().setChatEffort(chatId, effort) + }, + onError: (_error, effort) => { + if (chatId) useMothershipEffortStore.getState().dropChatEffort(chatId, effort) + }, + onSuccess: (_data, effort) => { + queryClient.setQueryData( + mothershipChatKeys.detail(chatId), + (current) => current && { ...current, effort } + ) + }, + }) +} + async function forkChat(params: { chatId: string upToMessageId: string diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 0370e50d74a..11b7670a2cd 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -39,6 +39,14 @@ export const mothershipChatParamsSchema = z.object({ chatId: z.string().min(1), }) +/** The effort the user picked for a chat; null while the chat follows the default. */ +const mothershipChatEffortChoiceSchema = ChatPayloadSchema.shape.effort.unwrap().nullable() + +const setMothershipChatEffortBodySchema = z.object({ + effort: ChatPayloadSchema.shape.effort.unwrap(), +}) +export type SetMothershipChatEffortBody = z.input + export const updateMothershipChatBodySchema = z .object({ title: z.string().trim().min(1).max(200).optional(), @@ -309,6 +317,19 @@ export const updateMothershipChatContract = defineRouteContract({ }, }) +export const setMothershipChatEffortContract = defineRouteContract({ + method: 'PUT', + path: '/api/mothership/chats/[chatId]/effort', + params: mothershipChatParamsSchema, + body: setMothershipChatEffortBodySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + }), + }, +}) + export const deleteMothershipChatContract = defineRouteContract({ method: 'DELETE', path: '/api/mothership/chats/[chatId]', @@ -399,6 +420,7 @@ export const getMothershipChatResponseSchema = z.object({ messages: z.array(z.unknown()), activeStreamId: z.string().nullable(), resources: z.array(z.unknown()), + effort: mothershipChatEffortChoiceSchema, createdAt: z.union([z.string(), z.date()]).nullable().optional(), updatedAt: z.union([z.string(), z.date()]).nullable().optional(), streamSnapshot: mothershipChatStreamSnapshotSchema.optional(), diff --git a/apps/sim/lib/mothership/chat/application/admit-turn.test.ts b/apps/sim/lib/mothership/chat/application/admit-turn.test.ts index 9bdb2ddbd64..f2f8a45c923 100644 --- a/apps/sim/lib/mothership/chat/application/admit-turn.test.ts +++ b/apps/sim/lib/mothership/chat/application/admit-turn.test.ts @@ -151,6 +151,24 @@ describe('organization turn admission through current private-chat authorization ) } ) + it.each([ + [undefined, false], + ['low', true], + ] as const)( + 'records the send effort %s as the chat choice in the admission write: %s', + async (effortChoice, recorded) => { + queueTableRows(copilotChats, [chat]) + queueTableRows(member, [{ role: 'member' }]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ model: null }]) + .mockResolvedValueOnce([{ id: 'run-1' }]) + .mockResolvedValueOnce([{ key: 'claim' }]) + await admitChatTurn.execute({ principal, input: { ...input(), effortChoice } }) + const config = dbChainMockFns.set.mock.calls[0][0].config.toSQL() + expect(config.sql.includes("jsonb_build_object('effort'")).toBe(recorded) + expect(config.params.includes('low')).toBe(recorded) + } + ) it.each(['agent', 'plan'] as const)( 'denies switching to %s without current workspace-create permission before any mutation', async (mode) => { diff --git a/apps/sim/lib/mothership/chat/application/admit-turn.ts b/apps/sim/lib/mothership/chat/application/admit-turn.ts index 27271d1dc7c..e1b4a4ce516 100644 --- a/apps/sim/lib/mothership/chat/application/admit-turn.ts +++ b/apps/sim/lib/mothership/chat/application/admit-turn.ts @@ -11,6 +11,7 @@ import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/avail import { insertRunSegment, withRunAdmissionLock } from '@/lib/mothership/async-runs/repository' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { withChatEffortChoice } from '@/lib/mothership/chat/intent' import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats' import { @@ -18,6 +19,7 @@ import { type UserMessageParams, } from '@/lib/mothership/chat/persisted-message' import { publishChatStatusChanged } from '@/lib/mothership/chat-status' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { StreamRecoveryConfigSchema } from '@/lib/mothership/request/lifecycle/recovery-config' import { assertChatStreamLease, @@ -34,6 +36,8 @@ interface AdmitTurnInput { lease: ChatStreamLease sendClaim: { normalizedKey: string; claimToken: string } notifyWorkspaceStatus: boolean + /** The effort this send picked, kept as the chat's choice for later turns. */ + effortChoice?: MothershipEffort } /** The accepted message, its start intent and retry destination commit together. */ @@ -79,13 +83,16 @@ export const admitChatTurn = defineAuthorizedChatUseCase({ else await requireOrganizationSearchAvailable(organizationId) } await assertChatStreamLease(input.lease) + const turnConfig = sql`COALESCE(${copilotChats.config}, '{}'::jsonb) || jsonb_build_object('conversationMode', ${request.mode ?? 'agent'}::text)` return withRunAdmissionLock(userId, request.messageId, async (tx) => { const [chat] = await tx .update(copilotChats) .set({ conversationId: request.messageId, updatedAt: new Date(), - config: sql`COALESCE(${copilotChats.config}, '{}'::jsonb) || jsonb_build_object('conversationMode', ${request.mode ?? 'agent'}::text)`, + config: input.effortChoice + ? withChatEffortChoice(turnConfig, input.effortChoice) + : turnConfig, }) .where( and( diff --git a/apps/sim/lib/mothership/chat/application/set-effort.integration.ts b/apps/sim/lib/mothership/chat/application/set-effort.integration.ts new file mode 100644 index 00000000000..29a3b33e596 --- /dev/null +++ b/apps/sim/lib/mothership/chat/application/set-effort.integration.ts @@ -0,0 +1,109 @@ +/** Exercises a chat's explicit effort choice against real PostgreSQL rows. */ +import { db } from '@sim/db' +import { copilotChats, permissions, user, workspace } from '@sim/db/schema' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { setChatEffort } from '@/lib/mothership/chat/application/set-effort' +import { getAccessibleCopilotChatWithMessages } from '@/lib/mothership/chat/lifecycle' +import { resolveMothershipModelSettings } from '@/lib/mothership/model-options' + +const ownerId = generateId() +const outsiderId = generateId() +const workspaceId = generateId() +const owner = createSessionPrincipal({ userId: ownerId, sessionId: generateId() }) +const outsider = createSessionPrincipal({ userId: outsiderId, sessionId: generateId() }) + +async function createChat(config: Record | null): Promise { + const [chat] = await db + .insert(copilotChats) + .values({ userId: ownerId, workspaceId, type: 'mothership', config }) + .returning({ id: copilotChats.id }) + return chat.id +} + +async function loadEffort(chatId: string) { + const chat = await getAccessibleCopilotChatWithMessages(chatId, ownerId) + return chat?.effort +} + +/** What the next turn of this chat runs at when the send names no effort. */ +async function nextTurnEffort(chatId: string) { + return resolveMothershipModelSettings({ effort: (await loadEffort(chatId)) ?? undefined }, false) + .effort +} + +beforeAll(async () => { + const now = new Date() + await db.insert(user).values( + [ownerId, outsiderId].map((id) => ({ + id, + name: 'Chat effort fixture', + email: `${id}@chat-effort.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(workspace).values({ + id: workspaceId, + name: 'Chat effort fixture', + ownerId, + billedAccountUserId: ownerId, + }) + await db.insert(permissions).values( + [ownerId, outsiderId].map((userId) => ({ + id: generateId(), + userId, + entityType: 'workspace' as const, + entityId: workspaceId, + permissionType: 'admin' as const, + })) + ) +}) + +afterAll(async () => { + await db.delete(copilotChats).where(eq(copilotChats.workspaceId, workspaceId)) + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(inArray(user.id, [ownerId, outsiderId])) + await db.$client.end() +}) + +describe('a chat keeps the effort its owner picked', () => { + it('runs a chat with no pick at the default and a picked chat at its pick', async () => { + const untouched = await createChat(null) + const picked = await createChat({ conversationMode: 'plan' }) + + await setChatEffort.execute({ principal: owner, input: { chatId: picked, effort: 'low' } }) + + expect(await loadEffort(untouched)).toBeNull() + expect(await nextTurnEffort(untouched)).toBe('medium') + expect(await nextTurnEffort(picked)).toBe('low') + const [row] = await db + .select({ config: copilotChats.config }) + .from(copilotChats) + .where(eq(copilotChats.id, picked)) + expect(row.config).toEqual({ conversationMode: 'plan', effort: 'low' }) + }) + + it('replaces an earlier pick with the latest one', async () => { + const chatId = await createChat({ effort: 'xhigh' }) + await setChatEffort.execute({ principal: owner, input: { chatId, effort: 'high' } }) + expect(await loadEffort(chatId)).toBe('high') + }) + + it('reads a stored value outside the effort range as no pick', async () => { + const chatId = await createChat({ effort: 'turbo' }) + expect(await loadEffort(chatId)).toBeNull() + expect(await nextTurnEffort(chatId)).toBe('medium') + }) + + it("refuses to change another user's chat in a shared workspace", async () => { + const chatId = await createChat(null) + await expect( + setChatEffort.execute({ principal: outsider, input: { chatId, effort: 'max' } }) + ).rejects.toThrow('Chat not found') + expect(await loadEffort(chatId)).toBeNull() + }) +}) diff --git a/apps/sim/lib/mothership/chat/application/set-effort.ts b/apps/sim/lib/mothership/chat/application/set-effort.ts new file mode 100644 index 00000000000..468e54620e8 --- /dev/null +++ b/apps/sim/lib/mothership/chat/application/set-effort.ts @@ -0,0 +1,59 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { and, eq, isNull, sql } from 'drizzle-orm' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' +import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { withChatEffortChoice } from '@/lib/mothership/chat/intent' +import type { MothershipEffort } from '@/lib/mothership/model-options' + +interface SetChatEffortInput { + chatId: string + effort: MothershipEffort +} + +/** Records the effort the owner picked for a chat, so its later turns keep it. */ +export const setChatEffort = defineAuthorizedChatUseCase({ + // permission-group-exempt: a chat's reasoning effort is owned chat metadata and starts no turn + operation: defineWorkspaceOperation({ + id: 'mothership.chats.set_effort', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + /** permission-group-exempt: a chat's reasoning effort is owned chat metadata and starts no turn. */ + organizationOperation: defineOrganizationOperation({ + id: 'mothership.chats.set_effort', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session'], + }), + resolveContext({ principal, input }: { principal: SessionPrincipal; input: SetChatEffortInput }) { + return resolveOwnedChatContext(principal, input.chatId) + }, + authorizationOptions: {}, + async execute({ context, input }) { + const [chat] = await db + .update(copilotChats) + .set({ + config: withChatEffortChoice( + sql`COALESCE(${copilotChats.config}, '{}'::jsonb)`, + input.effort + ), + }) + .where( + and( + eq(copilotChats.id, context.chatId), + eq(copilotChats.userId, context.userId), + isNull(copilotChats.deletedAt) + ) + ) + .returning({ id: copilotChats.id }) + if (!chat) throw new OrchestrationError('not_found', 'Chat not found') + return {} + }, +}) diff --git a/apps/sim/lib/mothership/chat/intent.ts b/apps/sim/lib/mothership/chat/intent.ts index 2813a7e0e78..0652feb8578 100644 --- a/apps/sim/lib/mothership/chat/intent.ts +++ b/apps/sim/lib/mothership/chat/intent.ts @@ -1,5 +1,7 @@ import { copilotChats } from '@sim/db/schema' -import { sql } from 'drizzle-orm' +import { type SQL, sql } from 'drizzle-orm' +import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol' +import type { MothershipEffort } from '@/lib/mothership/model-options' export type ConversationMode = 'agent' | 'assistant' | 'plan' @@ -9,3 +11,20 @@ export const conversationModeSelection = sql`CASE WHEN ${copilotChats.organizationId} IS NULL THEN 'agent' WHEN ${copilotChats.config}->>'conversationMode' = 'agent' THEN 'agent' ELSE 'assistant' END` + +const MOTHERSHIP_EFFORTS = ChatPayloadSchema.shape.effort.unwrap().options + +/** + * The effort the user explicitly picked for this chat, or null while it follows the default. + * A stored value outside the protocol's effort range reads as no choice. + */ +export const chatEffortSelection = sql`CASE + WHEN ${copilotChats.config}->>'effort' IN (${sql.join( + MOTHERSHIP_EFFORTS.map((effort) => sql`${effort}`), + sql`, ` + )}) THEN ${copilotChats.config}->>'effort' END` + +/** Merges an explicit effort choice into a chat config expression, keeping its other keys. */ +export function withChatEffortChoice(config: SQL, effort: MothershipEffort): SQL { + return sql`${config} || jsonb_build_object('effort', ${effort}::text)` +} diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index d58546c5b10..7d40c26c769 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -8,7 +8,11 @@ import { } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { type ConversationMode, conversationModeSelection } from '@/lib/mothership/chat/intent' +import { + type ConversationMode, + chatEffortSelection, + conversationModeSelection, +} from '@/lib/mothership/chat/intent' import { authorizeOrganizationChat, authorizeOrganizationChatCancellation, @@ -17,6 +21,7 @@ import { type PersistedMessage, stripToolResultOutput, } from '@/lib/mothership/chat/persisted-message' +import type { MothershipEffort } from '@/lib/mothership/model-options' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -59,6 +64,7 @@ const copilotChatDetailColumns = { title: copilotChats.title, conversationId: copilotChats.conversationId, resources: copilotChats.resources, + effort: chatEffortSelection, createdAt: copilotChats.createdAt, updatedAt: copilotChats.updatedAt, } as const @@ -163,7 +169,7 @@ export type CopilotChatDetail = Pick< | 'resources' | 'createdAt' | 'updatedAt' -> & { mode: ConversationMode } +> & { mode: ConversationMode; effort: MothershipEffort | null } export type CopilotChatDetailRow = CopilotChatDetail & { /** Transcript assembled from `copilot_messages` (no longer a chat-row column). */ diff --git a/apps/sim/lib/mothership/chat/post.test.ts b/apps/sim/lib/mothership/chat/post.test.ts index 6e28a4e67ae..7ef76f2eb9f 100644 --- a/apps/sim/lib/mothership/chat/post.test.ts +++ b/apps/sim/lib/mothership/chat/post.test.ts @@ -813,7 +813,7 @@ describe('handleUnifiedChatPost', () => { ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'xhigh'], - ['low', 'medium'], + ['low', 'low'], ['none', 'medium'], ])( 'enforces the default model and effort range on submitted %s effort', @@ -840,6 +840,40 @@ describe('handleUnifiedChatPost', () => { } ) + it.each([ + { stored: null, sent: undefined, runs: 'medium' }, + { stored: 'high', sent: undefined, runs: 'high' }, + { stored: 'low', sent: undefined, runs: 'low' }, + { stored: 'high', sent: 'low', runs: 'low' }, + { stored: 'high', sent: 'max', runs: 'xhigh' }, + ] as const)( + 'runs a chat whose stored effort choice is $stored at $runs when the send names $sent', + async ({ stored, sent, runs }) => { + flags.models.mockResolvedValue(false) + resolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1', effort: stored }, + isNew: false, + }) + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/mothership/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Continue', + workspaceId: 'ws-1', + chatId: 'chat-1', + ...(sent ? { effort: sent } : {}), + }), + }) + ) + expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ effort: runs }) + ) + expect(admitTurn.mock.calls[0][0].input.effortChoice).toBe(sent && runs) + } + ) + it('routes workflow-attached chat requests through the copilot backend path', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index e980d9be025..76cc073961a 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -983,12 +983,15 @@ export async function handleUnifiedChatPost(req: NextRequest) { const authenticatedUserEmail = session.user.email const body = ChatMessageSchema.parse(await req.json()) + // Admission records a send's own effort as the chat's explicit choice. + const effortChoice = body.mode === 'assistant' ? undefined : body.effort + let modelSelectorEnabled = false if (body.mode !== 'assistant') { - const [modelSelectorEnabled, planEnabled] = await Promise.all([ + const [selectorEnabled, planEnabled] = await Promise.all([ isMothershipModelSelectorEnabled(), body.mode === 'plan' ? isPlanModeEnabled() : false, ]) - Object.assign(body, resolveMothershipModelSettings(body, modelSelectorEnabled)) + modelSelectorEnabled = selectorEnabled if (body.mode === 'plan' && !planEnabled) return createBadRequestResponse('Plan mode is disabled') } @@ -1178,6 +1181,17 @@ export async function handleUnifiedChatPost(req: NextRequest) { return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) } } + if (body.mode !== 'assistant') + Object.assign( + body, + resolveMothershipModelSettings( + { + effort: effortChoice ?? currentChat?.effort ?? undefined, + modelSelection: body.modelSelection, + }, + modelSelectorEnabled + ) + ) let pendingStreamWaitMs = 0 if (actualChatId) { @@ -1466,6 +1480,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { requestMode: body.mode, }, notifyWorkspaceStatus: branch.notifyChatStatus, + // The effort this turn actually runs at, so the stored pick is always one it can use. + effortChoice: effortChoice && body.effort, }, }) // Admission committed. A failure to attach this HTTP sink must leave the turn recoverable. diff --git a/apps/sim/lib/mothership/model-options.ts b/apps/sim/lib/mothership/model-options.ts index c3f28dc3607..cc110f0a871 100644 --- a/apps/sim/lib/mothership/model-options.ts +++ b/apps/sim/lib/mothership/model-options.ts @@ -17,7 +17,15 @@ export const MOTHERSHIP_MODEL_OPTIONS = [ { value: 'claude-opus-5-5', label: 'Opus 5.5' }, ] satisfies Array<{ value: ModelSelection['model']; label: string }> -const SIMPLE_EFFORT_VALUES: ReadonlySet = new Set(['medium', 'high', 'xhigh']) +/** The effort a chat or Sim Chat block runs at until the user picks one. */ +export const DEFAULT_MOTHERSHIP_EFFORT: MothershipEffort = 'medium' + +const SIMPLE_EFFORT_VALUES: ReadonlySet = new Set([ + 'low', + 'medium', + 'high', + 'xhigh', +]) /** The efforts the simple picker offers, labeled with the effort each one sends. */ export const MOTHERSHIP_SIMPLE_EFFORT_OPTIONS = MOTHERSHIP_EFFORT_OPTIONS.filter((option) => @@ -35,15 +43,15 @@ export function resolveMothershipModelSettings( settings: { effort?: MothershipEffort; modelSelection?: ModelSelection }, advanced: boolean ): { effort: MothershipEffort; modelSelection: ModelSelection } { - let effort = settings.effort ?? 'high' + let effort = settings.effort ?? DEFAULT_MOTHERSHIP_EFFORT if (!advanced) { - if (effort === 'none' || effort === 'low') effort = 'medium' + if (effort === 'none') effort = DEFAULT_MOTHERSHIP_EFFORT if (effort === 'max') effort = 'xhigh' return { effort, modelSelection: { model: 'gpt-6-astra', fastMode: false } } } const stored = settings.modelSelection ?? { model: 'gpt-6-astra', fastMode: false } const model = stored.model === 'claude-opus-5' ? 'claude-opus-5-5' : stored.model - if (effort === 'none' && model !== 'gpt-6-sol') effort = 'medium' + if (effort === 'none' && model !== 'gpt-6-sol') effort = DEFAULT_MOTHERSHIP_EFFORT return { effort, modelSelection: { model, fastMode: model === 'claude-opus-5-5' ? false : stored.fastMode }, diff --git a/apps/sim/stores/mothership-effort/store.test.ts b/apps/sim/stores/mothership-effort/store.test.ts index 43ec1ea49e1..5f8d3108995 100644 --- a/apps/sim/stores/mothership-effort/store.test.ts +++ b/apps/sim/stores/mothership-effort/store.test.ts @@ -4,14 +4,11 @@ import { useMothershipEffortStore } from '@/stores/mothership-effort/store' beforeEach(() => { localStorage.clear() - useMothershipEffortStore.setState({ - effort: 'high', - modelSelection: { model: 'gpt-6-astra', fastMode: false }, - }) + useMothershipEffortStore.getState().reset() }) describe('Build reasoning preferences', () => { - it('updates a saved Opus selection to Opus 5.5 while preserving effort', async () => { + it('updates a saved Opus selection to Opus 5.5 and drops a saved global effort', async () => { localStorage.setItem( 'mothership-effort', JSON.stringify({ @@ -21,29 +18,35 @@ describe('Build reasoning preferences', () => { ) await useMothershipEffortStore.persist.rehydrate() expect(useMothershipEffortStore.getState()).toMatchObject({ - effort: 'xhigh', + newChatEffort: null, modelSelection: { model: 'claude-opus-5-5', fastMode: false }, }) useMothershipEffortStore.getState().setFastMode(true) expect(JSON.parse(localStorage.getItem('mothership-effort')!).state).toEqual({ - effort: 'xhigh', modelSelection: { model: 'claude-opus-5-5', fastMode: false }, }) }) - it('restores effort and Fast mode together without changing the Build model', async () => { - useMothershipEffortStore.getState().setEffort('max') + it('restores Fast mode but not a new chat effort pick across reloads', async () => { + useMothershipEffortStore.getState().setNewChatEffort('low') useMothershipEffortStore.getState().setFastMode(true) const saved = localStorage.getItem('mothership-effort')! - useMothershipEffortStore.setState({ - effort: 'high', - modelSelection: { model: 'gpt-6-astra', fastMode: false }, - }) + useMothershipEffortStore.getState().reset() localStorage.setItem('mothership-effort', saved) await useMothershipEffortStore.persist.rehydrate() expect(useMothershipEffortStore.getState()).toMatchObject({ - effort: 'max', + newChatEffort: null, modelSelection: { model: 'gpt-6-astra', fastMode: true }, }) }) + + it('keeps a newer chat pick when an older pick fails to save', () => { + const store = useMothershipEffortStore.getState() + store.setChatEffort('chat-1', 'low') + store.setChatEffort('chat-1', 'high') + store.dropChatEffort('chat-1', 'low') + expect(useMothershipEffortStore.getState().chatEfforts).toEqual({ 'chat-1': 'high' }) + store.dropChatEffort('chat-1', 'high') + expect(useMothershipEffortStore.getState().chatEfforts).toEqual({}) + }) }) diff --git a/apps/sim/stores/mothership-effort/store.ts b/apps/sim/stores/mothership-effort/store.ts index 7fdc6f94390..76849d6120a 100644 --- a/apps/sim/stores/mothership-effort/store.ts +++ b/apps/sim/stores/mothership-effort/store.ts @@ -1,9 +1,8 @@ -import { toRecord } from '@sim/utils/object' +import { omit, toRecord } from '@sim/utils/object' import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { type ModelSelection, ModelSelectionSchema } from '@/lib/mothership/generated/protocol' import { - MOTHERSHIP_EFFORT_OPTIONS, type MothershipEffort, resolveMothershipModelSettings, } from '@/lib/mothership/model-options' @@ -12,15 +11,39 @@ interface MothershipEffortState { modelSelection: ModelSelection setModel: (model: ModelSelection['model']) => void setFastMode: (fastMode: boolean) => void - effort: MothershipEffort - setEffort: (effort: MothershipEffort) => void + /** + * The effort picked in a composer whose chat does not exist yet. Its first send records + * it on the new chat; leaving that composer unsent drops it. + */ + newChatEffort: MothershipEffort | null + setNewChatEffort: (effort: MothershipEffort | null) => void + /** + * Picks made in existing chats this session, by chat id. They win over the chat's loaded + * value, so a detail refetch or a save still in flight never shows or sends an older one. + */ + chatEfforts: Record + setChatEffort: (chatId: string, effort: MothershipEffort) => void + /** Drops a pick whose save failed, unless a newer pick replaced it. */ + dropChatEffort: (chatId: string, effort: MothershipEffort) => void + /** Moves the new-chat pick onto the chat its first send created. */ + adoptNewChatEffort: (chatId: string, effort: MothershipEffort) => void reset: () => void } -const initialState = { - effort: 'high', +const initialState: Pick< + MothershipEffortState, + 'modelSelection' | 'newChatEffort' | 'chatEfforts' +> = { modelSelection: { model: 'gpt-6-astra', fastMode: false }, -} satisfies Pick + newChatEffort: null, + chatEfforts: {}, +} + +function withModelSelection( + modelSelection: ModelSelection +): Pick { + return { modelSelection: resolveMothershipModelSettings({ modelSelection }, true).modelSelection } +} export const useMothershipEffortStore = create()( devtools( @@ -28,42 +51,32 @@ export const useMothershipEffortStore = create()( (set) => ({ ...initialState, setFastMode: (fastMode) => - set((state) => - resolveMothershipModelSettings( - { ...state, modelSelection: { ...state.modelSelection, fastMode } }, - true - ) - ), + set((state) => withModelSelection({ ...state.modelSelection, fastMode })), setModel: (model) => - set((state) => - resolveMothershipModelSettings( - { ...state, modelSelection: { model, fastMode: state.modelSelection.fastMode } }, - true - ) - ), - setEffort: (effort) => set({ effort }), + set((state) => withModelSelection({ model, fastMode: state.modelSelection.fastMode })), + setNewChatEffort: (newChatEffort) => set({ newChatEffort }), + setChatEffort: (chatId, effort) => + set((state) => ({ chatEfforts: { ...state.chatEfforts, [chatId]: effort } })), + dropChatEffort: (chatId, effort) => + set((state) => { + if (state.chatEfforts[chatId] !== effort) return state + return { chatEfforts: omit(state.chatEfforts, [chatId]) } + }), + adoptNewChatEffort: (chatId, effort) => + set((state) => ({ + newChatEffort: null, + chatEfforts: { ...state.chatEfforts, [chatId]: effort }, + })), reset: () => set(initialState), }), { name: 'mothership-effort', - partialize: ({ effort, modelSelection }) => ({ effort, modelSelection }), + partialize: ({ modelSelection }) => ({ modelSelection }), merge: (persistedState, currentState) => { - const persisted = toRecord(persistedState) - const selection = ModelSelectionSchema.safeParse(persisted.modelSelection) - const effort = - persisted.effort === 'none' - ? 'none' - : (MOTHERSHIP_EFFORT_OPTIONS.find((option) => option.value === persisted.effort) - ?.value ?? currentState.effort) + const selection = ModelSelectionSchema.safeParse(toRecord(persistedState).modelSelection) return { ...currentState, - ...resolveMothershipModelSettings( - { - effort, - modelSelection: selection.success ? selection.data : currentState.modelSelection, - }, - true - ), + ...withModelSelection(selection.success ? selection.data : currentState.modelSelection), } }, }