Skip to content

Commit d32d4c9

Browse files
authored
feat(mothership): keep each chat's reasoning effort, default to medium, restore Low (#8634)
* feat(mothership): keep each chat's reasoning effort, default to medium, restore Low The simple picker offers Low / Medium / High / Extra High again, each sending exactly that effort. New chats and chats never changed run at medium instead of high. An effort the user picks is stored on the chat (copilot_chats.config) through a new PUT /api/mothership/chats/[chatId]/effort and at turn admission, and later turns of that chat keep it. The global last-used effort is no longer persisted. The Sim Chat block defaults to medium. * fix(mothership): keep the latest effort pick through refetches, failed saves and abandoned new chats * fix(mothership): leave a deduplicated send's chat on the pick its first attempt stored * fix(mothership): show a recovered chat's pick while its details load * fix(mothership): hand a withdrawn first send's effort pick back to the new-chat composer * fix(mothership): hand a withdrawn send's pick back only while its new-chat surface is open
1 parent 7050250 commit d32d4c9

21 files changed

Lines changed: 519 additions & 72 deletions

File tree

‎apps/sim/app/api/copilot/chat/queries.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
readLiveTurnSnapshot,
1313
} from '@/lib/mothership/chat/live-turn-snapshot'
1414
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
15+
import type { MothershipEffort } from '@/lib/mothership/model-options'
1516
import {
1617
authenticateCopilotRequestSessionOnly,
1718
createBadRequestResponse,
@@ -32,6 +33,7 @@ function transformChat(chat: {
3233
model: string | null
3334
messages: unknown
3435
config?: unknown
36+
effort?: MothershipEffort | null
3537
conversationId?: string | null
3638
resources?: unknown
3739
createdAt: Date | null
@@ -44,6 +46,7 @@ function transformChat(chat: {
4446
messages: Array.isArray(chat.messages) ? chat.messages : [],
4547
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
4648
config: chat.config || null,
49+
effort: chat.effort ?? null,
4750
...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}),
4851
...('resources' in chat
4952
? { resources: Array.isArray(chat.resources) ? chat.resources : [] }
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { setMothershipChatEffortContract } from '@/lib/api/contracts/mothership-chats'
2+
import {
3+
defineInternalJsonRoute,
4+
internalJsonPresenters,
5+
internalOrchestrationErrorPolicy,
6+
internalRateLimits,
7+
internalSessionAuth,
8+
} from '@/lib/api/server/routes'
9+
import { setChatEffort } from '@/lib/mothership/chat/application/set-effort'
10+
11+
/** Records the reasoning effort the owner picked for one of their chats. */
12+
export const PUT = defineInternalJsonRoute({
13+
contract: setMothershipChatEffortContract,
14+
operation: setChatEffort.operation,
15+
auth: internalSessionAuth,
16+
rateLimit: internalRateLimits.none({
17+
reason: 'Personal chat settings updates have no separate rate bucket.',
18+
}),
19+
errorPolicy: internalOrchestrationErrorPolicy,
20+
mapInput: ({ params, body }) => ({ chatId: params.chatId, effort: body.effort }),
21+
useCase: setChatEffort,
22+
present: internalJsonPresenters.withSuccess,
23+
})

‎apps/sim/app/api/mothership/chats/[chatId]/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export const GET = withRouteHandler(
9494
messages: effectiveMessages,
9595
activeStreamId: liveStreamId,
9696
resources: Array.isArray(chat.resources) ? chat.resources : [],
97+
effort: chat.effort,
9798
createdAt: chat.createdAt,
9899
updatedAt: chat.updatedAt,
99100
// Events stay out of the payload (the resume endpoint replays them),

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,7 @@ let queryClient: QueryClient
102102

103103
beforeEach(() => {
104104
mocks.advanced = false
105-
useMothershipEffortStore.setState({
106-
effort: 'high',
107-
modelSelection: { model: 'gpt-6-astra', fastMode: false },
108-
})
105+
useMothershipEffortStore.getState().reset()
109106
mocks.plan = false
110107
vi.clearAllMocks()
111108
mocks.workspaces = [

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22

3+
import { useEffect } from 'react'
34
import {
45
DropdownMenu,
56
DropdownMenuContent,
@@ -11,12 +12,18 @@ import { Brain, Check, Sparkles } from '@sim/emcn/icons'
1112
import {
1213
MOTHERSHIP_MODEL_OPTIONS,
1314
MOTHERSHIP_SIMPLE_EFFORT_OPTIONS,
15+
type MothershipEffort,
1416
mothershipEffortOptions,
1517
resolveMothershipModelSettings,
1618
} from '@/lib/mothership/model-options'
19+
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
1720
import { FastModeToggle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/fast-mode-toggle'
1821
import { ModelSettingTrigger } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-setting-trigger'
1922
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
23+
import {
24+
useMothershipChatHistory,
25+
useSetMothershipChatEffort,
26+
} from '@/hooks/queries/mothership-chats'
2027
import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
2128

2229
/** Model, reasoning effort, and Fast mode for Build chat composers. */
@@ -25,15 +32,32 @@ export function ModelSelector() {
2532
const selection = useMothershipEffortStore((state) => state.modelSelection)
2633
const setModel = useMothershipEffortStore((state) => state.setModel)
2734
const setFastMode = useMothershipEffortStore((state) => state.setFastMode)
28-
const storedEffort = useMothershipEffortStore((state) => state.effort)
35+
const { chatId } = useChatSurface()
36+
const { data: chatHistory } = useMothershipChatHistory(chatId)
37+
const chatPick = useMothershipEffortStore((state) =>
38+
chatId ? state.chatEfforts[chatId] : undefined
39+
)
40+
const newChatEffort = useMothershipEffortStore((state) => state.newChatEffort)
41+
const setNewChatEffort = useMothershipEffortStore((state) => state.setNewChatEffort)
42+
const { mutate: saveChatEffort } = useSetMothershipChatEffort(chatId)
43+
const effortChoice = chatId ? (chatPick ?? chatHistory?.effort) : newChatEffort
2944
const { effort, modelSelection } = resolveMothershipModelSettings(
30-
{ effort: storedEffort, modelSelection: selection },
45+
{ effort: effortChoice ?? undefined, modelSelection: selection },
3146
advanced
3247
)
3348
const options = advanced
3449
? mothershipEffortOptions(modelSelection.model)
3550
: MOTHERSHIP_SIMPLE_EFFORT_OPTIONS
36-
const setEffort = useMothershipEffortStore((state) => state.setEffort)
51+
const setEffort = (choice: MothershipEffort) => {
52+
if (chatId) saveChatEffort(choice)
53+
else setNewChatEffort(choice)
54+
}
55+
56+
useEffect(() => {
57+
if (chatId) return
58+
return () => useMothershipEffortStore.getState().setNewChatEffort(null)
59+
}, [chatId])
60+
3761
const effortLabel = options.find((option) => option.value === effort)?.label ?? effort
3862
const modelLabel =
3963
MOTHERSHIP_MODEL_OPTIONS.find((option) => option.value === modelSelection.model)?.label ??

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,14 @@ export function useChat(
926926
new Set())
927927
const streamReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null)
928928
const chatIdRef = useRef<string | undefined>(initialChatId)
929+
/** Cleared on unmount, so a late rollback cannot hand a pick to a surface the user left. */
930+
const surfaceMountedRef = useRef(true)
931+
useEffect(() => {
932+
surfaceMountedRef.current = true
933+
return () => {
934+
surfaceMountedRef.current = false
935+
}
936+
}, [])
929937
const tableViewContextsRef = useRef({
930938
scopeId: desktopScopeId,
931939
views: new Map<string, MothershipTableViewContext>(),
@@ -3396,6 +3404,18 @@ export function useChat(
33963404

33973405
let requestChatId =
33983406
queuedSendHandoff?.chatId ?? selectedChatIdRef.current ?? chatIdRef.current
3407+
// Read before the composer can unmount. Sent only when picked; otherwise the server
3408+
// uses the chat's stored pick or the default.
3409+
const effortStore = useMothershipEffortStore.getState()
3410+
const effortChoice =
3411+
options?.requestMode === 'assistant'
3412+
? undefined
3413+
: requestChatId
3414+
? (effortStore.chatEfforts[requestChatId] ??
3415+
queryClient.getQueryData<MothershipChatHistory>(
3416+
mothershipChatKeys.detail(requestChatId)
3417+
)?.effort)
3418+
: effortStore.newChatEffort
33993419
const writeQueuedSendHandoff = (chatId?: string) => {
34003420
if (!queuedSendHandoff) return
34013421
if (!chatId && !queuedSendHandoff.supersededStreamId) return
@@ -3530,6 +3550,16 @@ export function useChat(
35303550
}
35313551

35323552
const rollbackOptimisticSend = () => {
3553+
// A withdrawn first send hands its pick back to the new-chat composer for the retry,
3554+
// only while that surface is still open on the new chat.
3555+
if (
3556+
!requestChatId &&
3557+
effortChoice &&
3558+
surfaceMountedRef.current &&
3559+
!chatIdRef.current &&
3560+
!selectedChatIdRef.current
3561+
)
3562+
useMothershipEffortStore.getState().setNewChatEffort(effortChoice)
35333563
if (requestChatId) {
35343564
upsertChatHistory(requestChatId, (current) => ({
35353565
...current,
@@ -3707,10 +3737,11 @@ export function useChat(
37073737
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
37083738
...(options?.requestMode !== 'assistant'
37093739
? {
3710-
...resolveMothershipModelSettings(
3740+
modelSelection: resolveMothershipModelSettings(
37113741
useMothershipEffortStore.getState(),
37123742
modelSelectorEnabled
3713-
),
3743+
).modelSelection,
3744+
...(effortChoice ? { effort: effortChoice } : {}),
37143745
}
37153746
: {}),
37163747
}),
@@ -3727,6 +3758,8 @@ export function useChat(
37273758
return consumedByTranscript
37283759
}
37293760
if (admittedChatId && !requestChatId) {
3761+
if (effortChoice)
3762+
useMothershipEffortStore.getState().adoptNewChatEffort(admittedChatId, effortChoice)
37303763
requestChatId = admittedChatId
37313764
streamTargetChatId = admittedChatId
37323765
adoptResolvedChatId(admittedChatId, { replaceHomeHistory: true, invalidateList: true })
@@ -3775,6 +3808,9 @@ export function useChat(
37753808
const conflictChatId =
37763809
typeof errorData.chatId === 'string' ? errorData.chatId : undefined
37773810
if (conflictChatId && !streamTargetChatId) {
3811+
// The retry carries the same pick the first attempt stored on that chat.
3812+
if (effortChoice)
3813+
useMothershipEffortStore.getState().adoptNewChatEffort(conflictChatId, effortChoice)
37783814
adoptResolvedChatId(conflictChatId, {
37793815
replaceHomeHistory: true,
37803816
invalidateList: true,

‎apps/sim/blocks/blocks/mothership.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Blimp } from '@sim/emcn/icons'
2-
import { MOTHERSHIP_EFFORT_OPTIONS, MOTHERSHIP_MODEL_OPTIONS } from '@/lib/mothership/model-options'
2+
import {
3+
DEFAULT_MOTHERSHIP_EFFORT,
4+
MOTHERSHIP_EFFORT_OPTIONS,
5+
MOTHERSHIP_MODEL_OPTIONS,
6+
} from '@/lib/mothership/model-options'
37
import type { BlockConfig } from '@/blocks/types'
48

59
export const MothershipBlock: BlockConfig = {
@@ -45,7 +49,7 @@ export const MothershipBlock: BlockConfig = {
4549
title: 'Reasoning Effort',
4650
type: 'dropdown',
4751
options: MOTHERSHIP_EFFORT_OPTIONS.map(({ value, label }) => ({ id: value, label })),
48-
value: () => 'high',
52+
value: () => DEFAULT_MOTHERSHIP_EFFORT,
4953
},
5054
{
5155
id: 'fastMode',

‎apps/sim/executor/handlers/mothership/mothership-handler.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from
2929
import { resolveMcpToolBinding } from '@/lib/mcp/tool-binding'
3030
import { resolveMothershipConversation } from '@/lib/mothership/conversation-id'
3131
import { ChatPayloadSchema, ModelSelectionSchema } from '@/lib/mothership/generated/protocol'
32+
import { DEFAULT_MOTHERSHIP_EFFORT } from '@/lib/mothership/model-options'
3233
import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy'
3334
import {
3435
areModelSafeWorkspaceFileKeys,
@@ -971,7 +972,7 @@ export class MothershipBlockHandler implements BlockHandler {
971972
model: inputs.model ?? 'gpt-6-astra',
972973
fastMode: inputs.model === 'claude-opus-5' ? false : (inputs.fastMode ?? false),
973974
})
974-
const effort = ChatPayloadSchema.shape.effort.parse(inputs.effort ?? 'high')
975+
const effort = ChatPayloadSchema.shape.effort.parse(inputs.effort ?? DEFAULT_MOTHERSHIP_EFFORT)
975976
const body: Record<string, unknown> = {
976977
messages,
977978
useConversationHistory: true,

‎apps/sim/hooks/queries/mothership-chats.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,22 @@ import {
2020
type MothershipChatScope,
2121
markMothershipChatReadContract,
2222
restoreMothershipChatContract,
23+
type SetMothershipChatEffortBody,
24+
setMothershipChatEffortContract,
2325
updateMothershipChatContract,
2426
} from '@/lib/api/contracts/mothership-chats'
2527
import { mothershipResourceSchema } from '@/lib/api/contracts/mothership-resources'
2628
import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope'
2729
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
2830
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
31+
import type { MothershipEffort } from '@/lib/mothership/model-options'
2932
import {
3033
type FilePreviewSession,
3134
isFilePreviewSession,
3235
} from '@/lib/mothership/request/session/file-preview-session-contract'
3336
import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/mothership/request/session/types'
3437
import type { MothershipResource } from '@/lib/mothership/resources/types'
38+
import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
3539
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
3640

3741
export interface MothershipChatMetadata {
@@ -53,6 +57,8 @@ export interface MothershipChatHistory {
5357
messages: PersistedMessage[]
5458
activeStreamId: string | null
5559
resources: MothershipResource[]
60+
/** The effort the user picked for this chat; null or absent while it follows the default. */
61+
effort?: MothershipEffort | null
5662
streamSnapshot?: {
5763
events: StreamBatchEvent[]
5864
previewSessions: FilePreviewSession[]
@@ -200,6 +206,7 @@ function parseChatHistory(value: unknown): MothershipChatHistory {
200206
messages: normalizeMessages(chat.messages),
201207
activeStreamId: chat.activeStreamId,
202208
resources: parseResources(chat.resources, `${chatContext}.resources`),
209+
effort: getMothershipChatResponseSchema.shape.chat.shape.effort.parse(chat.effort ?? null),
203210
streamSnapshot: parseStrictStreamSnapshot(chat.streamSnapshot, `${chatContext}.streamSnapshot`),
204211
}
205212
}
@@ -581,6 +588,43 @@ export function useSetMothershipChatPinned(owner?: MothershipChatOwner) {
581588
})
582589
}
583590

591+
async function setChatEffort({
592+
chatId,
593+
effort,
594+
}: SetMothershipChatEffortBody & { chatId: string }): Promise<void> {
595+
await requestJson(setMothershipChatEffortContract, {
596+
params: { chatId },
597+
body: { effort },
598+
})
599+
}
600+
601+
/**
602+
* Records the effort the user picked for a chat. The pick shows and sends at once from the
603+
* session's pick map; saves for one chat run one at a time so the last pick is the one stored.
604+
*/
605+
export function useSetMothershipChatEffort(chatId: string | undefined) {
606+
const queryClient = useQueryClient()
607+
return useMutation({
608+
mutationFn: (effort: MothershipEffort) => {
609+
if (!chatId) throw new Error('A chat effort needs a chat')
610+
return setChatEffort({ chatId, effort })
611+
},
612+
scope: { id: `mothership-chat-effort:${chatId ?? ''}` },
613+
onMutate: (effort) => {
614+
if (chatId) useMothershipEffortStore.getState().setChatEffort(chatId, effort)
615+
},
616+
onError: (_error, effort) => {
617+
if (chatId) useMothershipEffortStore.getState().dropChatEffort(chatId, effort)
618+
},
619+
onSuccess: (_data, effort) => {
620+
queryClient.setQueryData<MothershipChatHistory>(
621+
mothershipChatKeys.detail(chatId),
622+
(current) => current && { ...current, effort }
623+
)
624+
},
625+
})
626+
}
627+
584628
async function forkChat(params: {
585629
chatId: string
586630
upToMessageId: string

‎apps/sim/lib/api/contracts/mothership-chats.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ export const mothershipChatParamsSchema = z.object({
3939
chatId: z.string().min(1),
4040
})
4141

42+
/** The effort the user picked for a chat; null while the chat follows the default. */
43+
const mothershipChatEffortChoiceSchema = ChatPayloadSchema.shape.effort.unwrap().nullable()
44+
45+
const setMothershipChatEffortBodySchema = z.object({
46+
effort: ChatPayloadSchema.shape.effort.unwrap(),
47+
})
48+
export type SetMothershipChatEffortBody = z.input<typeof setMothershipChatEffortBodySchema>
49+
4250
export const updateMothershipChatBodySchema = z
4351
.object({
4452
title: z.string().trim().min(1).max(200).optional(),
@@ -309,6 +317,19 @@ export const updateMothershipChatContract = defineRouteContract({
309317
},
310318
})
311319

320+
export const setMothershipChatEffortContract = defineRouteContract({
321+
method: 'PUT',
322+
path: '/api/mothership/chats/[chatId]/effort',
323+
params: mothershipChatParamsSchema,
324+
body: setMothershipChatEffortBodySchema,
325+
response: {
326+
mode: 'json',
327+
schema: z.object({
328+
success: z.literal(true),
329+
}),
330+
},
331+
})
332+
312333
export const deleteMothershipChatContract = defineRouteContract({
313334
method: 'DELETE',
314335
path: '/api/mothership/chats/[chatId]',
@@ -399,6 +420,7 @@ export const getMothershipChatResponseSchema = z.object({
399420
messages: z.array(z.unknown()),
400421
activeStreamId: z.string().nullable(),
401422
resources: z.array(z.unknown()),
423+
effort: mothershipChatEffortChoiceSchema,
402424
createdAt: z.union([z.string(), z.date()]).nullable().optional(),
403425
updatedAt: z.union([z.string(), z.date()]).nullable().optional(),
404426
streamSnapshot: mothershipChatStreamSnapshotSchema.optional(),

0 commit comments

Comments
 (0)