From d546e4609224c8b26019a3746aecddae81e7eb58 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:57:36 +0000 Subject: [PATCH] fix: honor Fast automation session model overrides --- .changeset/fast-automation-session-model.md | 5 + apps/docs/automations.mdx | 5 +- ...fast-agent-conversation-repository.test.ts | 51 ++++++- .../__tests__/fast-agent-service.test.ts | 55 ++++++++ .../fast-agent-conversation-repository.ts | 11 ++ .../server/fast-agent/fast-agent-service.ts | 7 +- .../server/fast-agent/fast-agent-session.ts | 14 +- .../__tests__/custom-automations.test.ts | 21 +-- .../server/automations/custom-automations.ts | 10 +- .../lib/fast-agent-parent-event.test.ts | 131 ++++++++++-------- .../src/server/lib/fast-agent-parent-event.ts | 23 +-- 11 files changed, 232 insertions(+), 101 deletions(-) create mode 100644 .changeset/fast-automation-session-model.md diff --git a/.changeset/fast-automation-session-model.md b/.changeset/fast-automation-session-model.md new file mode 100644 index 0000000000..985dcff3de --- /dev/null +++ b/.changeset/fast-automation-session-model.md @@ -0,0 +1,5 @@ +--- +'roomote': patch +--- + +Honor custom automation model and reasoning overrides for the Fast session across initial and resumed turns, without applying them to delegated coding tasks. diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 7aab070219..3439d94f59 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -118,8 +118,9 @@ Create arbitrary scheduled agent runs with: - an optional **preferred environment**: a named environment or **All repositories**, offered to the run as a hint for delegated work; leave it as **Let Roomote decide** to route normally -- an optional **model** override for the runs; the default follows the - deployment task model +- an optional **model** override for the automation's Fast session, including + later turns; without an override, it uses the deployment orchestration default. + Delegated coding tasks use their own model selection or deployment coding default - an optional **effort** override (`low`, `medium`, `high`, `extra high`, or `max`) when the selected model supports configurable reasoning - an optional **report destination**: a direct message to the automation owner, diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts index 6b1e26c4a0..b577687723 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts @@ -28,7 +28,10 @@ import { renewFastSessionRespondingLease, } from '../fast-agent-conversation-repository'; import { FAST_AGENT_REACTION_INPUT_TYPE } from '../fast-agent-conversation'; -import { hasFastAgentSession } from '../fast-agent-session'; +import { + getOrCreateFastAgentSession, + hasFastAgentSession, +} from '../fast-agent-session'; const createdUserIds: string[] = []; @@ -55,6 +58,52 @@ afterEach(async () => { }); describe('Fast conversation repository', () => { + it.each([true, false])( + 'seeds model settings only on insert (initial overrides: %s)', + async (withOverrides) => { + const user = await createUser(); + const conversation = { + surface: 'automation' as const, + workspaceId: 'model-settings-test', + conversationId: crypto.randomUUID(), + }; + const expected = { + model: withOverrides ? 'openai/gpt-5.6' : null, + reasoningEffort: withOverrides ? 'high' : null, + }; + const created = await getOrCreateFastAgentSession({ + userId: user.id, + conversation, + ...(withOverrides + ? { + initialModel: 'openai/gpt-5.6', + initialReasoningEffort: 'high' as const, + } + : {}), + }); + expect(created).toMatchObject({ created: true, ...expected }); + + const reused = await getOrCreateFastAgentSession({ + userId: user.id, + conversation, + initialModel: 'anthropic/claude-sonnet-5', + initialReasoningEffort: 'low', + }); + expect(reused).toMatchObject({ + id: created.id, + created: false, + ...expected, + }); + await expect( + fastAgentConversationRepository.findById({ id: created.id }), + ).resolves.toMatchObject(expected); + const row = await db.query.fastAgentConversations.findFirst({ + where: eq(fastAgentConversations.id, created.id), + }); + expect(row).toMatchObject(expected); + }, + ); + it('persists a channel-less automation conversation', async () => { const user = await createUser(); const conversation = { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index 04a61ef9e8..526a8413a5 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -5757,6 +5757,61 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { await expect(answer).rejects.toBe(shutdown); }); + it.each([ + { selection: {}, expectedModel: 'openai/gpt-5.6', expectedEffort: 'high' }, + { + selection: { + model: 'anthropic/claude-sonnet-5', + reasoningEffort: 'low' as const, + }, + expectedModel: 'anthropic/claude-sonnet-5', + expectedEffort: 'low', + }, + { + selection: { model: null, reasoningEffort: null }, + expectedModel: undefined, + expectedEffort: undefined, + }, + { + selection: { model: null }, + expectedModel: undefined, + expectedEffort: 'high', + }, + { + selection: { reasoningEffort: null }, + expectedModel: 'openai/gpt-5.6', + expectedEffort: undefined, + }, + ])( + 'applies persisted inference settings and explicit precedence on initial and resumed turns: $selection', + async ({ selection, expectedModel, expectedEffort }) => { + for (const resumed of [false, true]) { + mocks.getSession.mockResolvedValueOnce({ + id: 'conversation-1', + compatibilityMessages: [], + openCodeSessionId: resumed ? 'opencode-session-1' : null, + model: 'openai/gpt-5.6', + reasoningEffort: 'high', + created: !resumed, + }); + await answerFastAgentQuestion({ + ...baseParams, + ...selection, + images: ['data:image/png;base64,aGVsbG8='], + resumedAfterInterruption: resumed, + adapter: callbacks(), + }); + expect(mocks.generateText).toHaveBeenCalledTimes(resumed ? 2 : 1); + for (const mock of [mocks.generateText, mocks.resolveImageDelivery]) { + const options = mock.mock.lastCall?.[0]; + expect(options).toBeDefined(); + expect(options.model).toBe(expectedModel); + expect(options.reasoningEffort).toBe(expectedEffort); + } + } + }, + ); + it('passes image data URLs to the Fast model as file input when it can view images', async () => { await answerFastAgentQuestion({ ...baseParams, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts index 8ba78aefe1..b6d7b2f06a 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts @@ -28,6 +28,7 @@ import { ACP_ENVELOPE_EVENT_TYPES, fastAgentConversationSchema, type FastAgentConversationOwner, + type ReasoningEffort, } from '@roomote/types'; import { FAST_RESPONDING_LEASE_MS } from './fast-agent-constants'; @@ -41,6 +42,8 @@ export type FastAgentConversationRecord = { userId: string | null; owner: FastAgentConversationOwner; title: string | null; + model: string | null; + reasoningEffort: ReasoningEffort | null; conversation: FastAgentConversation; /** * Durable visible history for cold starts and provider retries. OpenCode, @@ -776,6 +779,8 @@ export interface FastAgentConversationRepository { sessionId?: string; /** Title to seed only when this call creates the conversation. */ initialTitle?: string; + initialModel?: string; + initialReasoningEffort?: ReasoningEffort; }): Promise; findById(input: { id: string; @@ -916,6 +921,8 @@ async function loadConversationRecord( userId: record.userId, owner, title: record.title, + model: record.model, + reasoningEffort: record.reasoningEffort, conversation, compatibilityMessages: record.compatibilityMessages as ModelMessage[], openCodeSessionId: record.openCodeSessionId, @@ -930,6 +937,8 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = conversation, sessionId, initialTitle, + initialModel, + initialReasoningEffort, }) { const resolvedOwner = owner ?? (userId ? { kind: 'user' as const, userId } : null); @@ -987,6 +996,8 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = ? resolvedOwner.automationKey : null, title: initialTitle?.trim() || null, + model: initialModel, + reasoningEffort: initialReasoningEffort, surface: conversation.surface, workspaceId: conversation.workspaceId, conversationId: conversation.conversationId, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index de2b8f9b94..014fd444eb 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -1618,8 +1618,8 @@ export async function answerFastAgentQuestion({ activeTasks?: FastAgentActiveTask[]; adapter: FastAgentTurnAdapter; signal?: AbortSignal; - /** Explicit model override for this turn; defaults to the deployment's - * orchestration model. */ + /** Explicit turn overrides; undefined uses stored session settings, + * while null uses deployment defaults. */ model?: string | null; reasoningEffort?: ReasoningEffort | null; turnSource?: FastAgentTurnSource; @@ -2860,6 +2860,9 @@ export async function answerFastAgentQuestion({ return false; }), ]); + if (model === undefined) model = session.model; + if (reasoningEffort === undefined) + reasoningEffort = session.reasoningEffort; const availableIntegrations = selectFastRoomoteChannelTools({ integrations: discoveredIntegrations, conversation, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts index e1a89bbef7..6c9a06d24b 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts @@ -10,7 +10,11 @@ import { taskRuns, tasks, } from '@roomote/db/server'; -import type { FastAgentConversationOwner, RunStatus } from '@roomote/types'; +import type { + FastAgentConversationOwner, + ReasoningEffort, + RunStatus, +} from '@roomote/types'; import type { FastAgentConversation } from './fast-agent-conversation'; import { fastAgentConversationRepository } from './fast-agent-conversation-repository'; import type { @@ -23,6 +27,8 @@ type FastAgentSessionRecord = { userId: string | null; owner: FastAgentConversationOwner; title: string | null; + model: string | null; + reasoningEffort: ReasoningEffort | null; conversation: FastAgentConversation; compatibilityMessages: ModelMessage[]; openCodeSessionId: string | null; @@ -41,6 +47,8 @@ export async function getOrCreateFastAgentSession({ conversation, sessionId, initialTitle, + initialModel, + initialReasoningEffort, }: { owner?: FastAgentConversationOwner; userId?: string; @@ -49,6 +57,8 @@ export async function getOrCreateFastAgentSession({ sessionId?: string; /** Title to seed only when this call creates the conversation. */ initialTitle?: string; + initialModel?: string; + initialReasoningEffort?: ReasoningEffort; }): Promise { return fastAgentConversationRepository.getOrCreate({ ...(owner ? { owner } : {}), @@ -56,6 +66,8 @@ export async function getOrCreateFastAgentSession({ conversation, ...(sessionId ? { sessionId } : {}), ...(initialTitle ? { initialTitle } : {}), + ...(initialModel !== undefined ? { initialModel } : {}), + ...(initialReasoningEffort !== undefined ? { initialReasoningEffort } : {}), }); } diff --git a/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts b/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts index 6a843dd9ff..f54f202085 100644 --- a/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts +++ b/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts @@ -416,7 +416,7 @@ describe('customAutomationsJob', () => { expect(enqueued.event).not.toHaveProperty('preferredEnvironmentId'); }); - it('passes the model and effort as delegated-task defaults', async () => { + it('persists the model and effort for the scheduled Fast session, not its children', async () => { vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ { ...automation, @@ -427,14 +427,15 @@ describe('customAutomationsJob', () => { await customAutomationsJob(); - expect(fastMocks.enqueueParentEvent).toHaveBeenCalledWith( + expect(fastMocks.getSession).toHaveBeenCalledWith( expect.objectContaining({ - event: expect.objectContaining({ - defaultTaskModel: 'anthropic/claude-sonnet-5', - defaultTaskReasoningEffort: 'high', - }), + initialModel: 'anthropic/claude-sonnet-5', + initialReasoningEffort: 'high', }), ); + const { event } = fastMocks.enqueueParentEvent.mock.calls[0]![0]; + expect(event).not.toHaveProperty('defaultTaskModel'); + expect(event).not.toHaveProperty('defaultTaskReasoningEffort'); }); it('keeps the claim fenced when the failed outcome cannot be persisted', async () => { @@ -1235,6 +1236,12 @@ describe('runCustomAutomationNow', () => { const result = await runCustomAutomationNow(automation.id); expect(result).toEqual({ outcome: 'queued' }); + expect(fastMocks.getSession).toHaveBeenCalledWith( + expect.objectContaining({ + initialModel: 'anthropic/claude-sonnet-5', + initialReasoningEffort: 'xhigh', + }), + ); expect(fastMocks.enqueueParentEvent).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ @@ -1242,8 +1249,6 @@ describe('runCustomAutomationNow', () => { automationId: automation.id, launchClaimedAt: expect.any(String), trigger: 'manual', - defaultTaskModel: 'anthropic/claude-sonnet-5', - defaultTaskReasoningEffort: 'xhigh', }), }), ); diff --git a/packages/sdk/src/server/automations/custom-automations.ts b/packages/sdk/src/server/automations/custom-automations.ts index b1da29dc50..062466c217 100644 --- a/packages/sdk/src/server/automations/custom-automations.ts +++ b/packages/sdk/src/server/automations/custom-automations.ts @@ -344,6 +344,8 @@ async function runFastCustomAutomation(params: { const session = await getOrCreateFastAgentSession({ userId: params.automation.createdByUserId, conversation, + initialModel: params.automation.model ?? undefined, + initialReasoningEffort: params.automation.reasoningEffort ?? undefined, }); if (rootMessageId) { await recordFastAgentConversationMessage({ @@ -360,14 +362,6 @@ async function runFastCustomAutomation(params: { launchClaimedAt: params.launchClaimedAt.toISOString(), prompt: params.automation.prompt, trigger: params.trigger, - ...(params.automation.model - ? { defaultTaskModel: params.automation.model } - : {}), - ...(params.automation.reasoningEffort - ? { - defaultTaskReasoningEffort: params.automation.reasoningEffort, - } - : {}), ...(params.preferredEnvironmentId ? { preferredEnvironmentId: params.preferredEnvironmentId } : {}), diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index a18756bb11..2af0427c8a 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -1426,66 +1426,83 @@ describe('deliverFastAgentParentEvent', () => { expect(mocks.postMessage).not.toHaveBeenCalled(); }); - it('delegates a task with the stored automation conversation as its Fast parent', async () => { - const automationParent = { - sessionId: parent.sessionId, - conversation: { - surface: 'automation' as const, - workspaceId: 'automation-1', - conversationId: 'occurrence-1', - }, - }; - mocks.answerQuestion.mockImplementationOnce( - async ({ - adapter, - }: { - adapter: { - launchTask: typeof mocks.launchTask; - resolveMcpServerConfigs: () => Promise; - }; - }) => { - await adapter.resolveMcpServerConfigs(); - return adapter.launchTask({ - prompt: 'Inspect the repository.', - environmentId: null, - parentSessionId: automationParent.sessionId, - postKickoff: vi.fn(), - }); - }, - ); + it.each([undefined, 'anthropic/claude-sonnet-5'])( + 'keeps child model selection independent of the automation session: %s', + async (childModel) => { + const automationParent = { + sessionId: parent.sessionId, + conversation: { + surface: 'automation' as const, + workspaceId: 'automation-1', + conversationId: 'occurrence-1', + }, + }; + mocks.findSession.mockResolvedValueOnce({ + id: automationParent.sessionId, + userId: 'u1', + conversation: automationParent.conversation, + model: 'openai/gpt-5.6-luna', + reasoningEffort: 'high', + }); + mocks.answerQuestion.mockImplementationOnce( + async ({ + adapter, + }: { + adapter: { + launchTask: typeof mocks.launchTask; + resolveMcpServerConfigs: () => Promise; + }; + }) => { + await adapter.resolveMcpServerConfigs(); + return adapter.launchTask({ + prompt: 'Inspect the repository.', + environmentId: null, + ...(childModel + ? { model: childModel, reasoningEffort: 'low' } + : {}), + parentSessionId: automationParent.sessionId, + postKickoff: vi.fn(), + }); + }, + ); - await deliverFastAgentParentEvent({ - parent: automationParent, - event: { - type: 'automation_triggered', - eventId: 'occurrence-1', - automationId: 'automation-1', - automationName: 'Weekly scan', - prompt: 'Find actionable regressions.', - trigger: 'schedule', - defaultTaskModel: 'openai/gpt-5.6-luna', - defaultTaskReasoningEffort: 'high', - }, - }); + await deliverFastAgentParentEvent({ + parent: automationParent, + event: { + type: 'automation_triggered', + eventId: 'occurrence-1', + automationId: 'automation-1', + automationName: 'Weekly scan', + prompt: 'Find actionable regressions.', + trigger: 'schedule', + }, + }); - expect(mocks.resolveUserMcpServerConfigs).toHaveBeenCalledWith({ - userId: 'u1', - apiBaseUrl: 'https://roomote.example.com', - includeRoomoteMemberTools: true, - }); - expect(mocks.enqueueTask).toHaveBeenCalledWith({ - task: expect.objectContaining({ - payload: expect.objectContaining({ - fastAgentSessionId: automationParent.sessionId, - fastAgentParent: automationParent, - harnessModelOverrides: { - 'opencode-server': 'openai/gpt-5.6-luna', - }, - reasoningEffort: 'high', + expect(mocks.resolveUserMcpServerConfigs).toHaveBeenCalledWith({ + userId: 'u1', + apiBaseUrl: 'https://roomote.example.com', + includeRoomoteMemberTools: true, + }); + expect(mocks.enqueueTask).toHaveBeenCalledWith({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + fastAgentSessionId: automationParent.sessionId, + fastAgentParent: automationParent, + }), }), - }), - }); - }); + }); + const payload = mocks.enqueueTask.mock.calls[0]![0].task.payload; + if (childModel) { + expect(payload.harnessModelOverrides).toEqual({ + 'opencode-server': childModel, + }); + expect(payload.reasoningEffort).toBe('low'); + } else { + expect(payload).not.toHaveProperty('harnessModelOverrides'); + expect(payload).not.toHaveProperty('reasoningEffort'); + } + }, + ); it('uses a stable delivery key when the same child update is retried', async () => { const childEvent = { diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index 926ed05b2f..e1c7ccd2f8 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -57,7 +57,6 @@ import { type FastAgentSourceControlReplyTarget, type FastAgentParent, type PullRequestStatus, - type ReasoningEffort, type RunStatus, type TaskRunErrorCode, type SourceControlProvider, @@ -176,8 +175,6 @@ export type FastAgentParentEvent = launchClaimedAt?: string; prompt: string; trigger: 'schedule' | 'manual'; - defaultTaskModel?: string; - defaultTaskReasoningEffort?: ReasoningEffort; /** Environment the automation was configured for; `all` for every repository. */ preferredEnvironmentId?: string; rootMessageId?: string; @@ -2216,24 +2213,6 @@ export async function deliverFastAgentParentEventWithLock( }); return 'delivered'; } - const defaultTaskModel = - params.event.type === 'automation_triggered' - ? params.event.defaultTaskModel - : undefined; - const defaultTaskReasoningEffort = - params.event.type === 'automation_triggered' - ? params.event.defaultTaskReasoningEffort - : undefined; - const launchTask = - defaultTaskModel || defaultTaskReasoningEffort - ? (input: Parameters[0]) => - parentTurn.adapter.launchTask({ - ...input, - model: input.model ?? defaultTaskModel, - reasoningEffort: - input.reasoningEffort ?? defaultTaskReasoningEffort, - }) - : parentTurn.adapter.launchTask; // The same base URL must reach both the config resolver and the broker: // the broker only injects its auth header on deployment-proxy URLs whose // origin matches its own apiBaseUrl, so a mismatched pair silently drops @@ -2329,7 +2308,7 @@ export async function deliverFastAgentParentEventWithLock( adapter: { createArtifact: buildFastAgentArtifactCreator(params.parent.sessionId), ...parentTurn.adapter, - launchTask, + launchTask: parentTurn.adapter.launchTask, resolveMcpServerConfigs: () => resolveUserMcpServerConfigs({ userId: parentTurn.userId,