diff --git a/AGENTS.md b/AGENTS.md index 1af04b4f10..4ab8d4c84f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ This repository is open source. Treat GitHub and other public surfaces as fully - Keep equivalent functionality in sync across supported source-control, communication, and sandbox providers whenever applicable. Do not intentionally make provider-specific exceptions unless the user explicitly requests one. - When adding or changing a built-in automation, explicitly evaluate whether it needs text-based Additional rules for repository scope, per-repository communication-output routing, and residual workflow/report guidance. Opt in when its output goes to a communication provider and can contain material relevant to different people or teams; exclude inherently global, triage-oriented (except CI Failure Triage), and non-communication-output automations. Keep the eligibility metadata, settings UI, save-time compilation and validation, persisted automation settings, runtime scope/routing enforcement, and prompt propagation aligned. - **Schema N-1 rollback guarantee:** Roomote must always be able to roll application code back one release against the current database. Do not drop tables or columns that the previous release still reads or writes in the same release that removes the feature. Stop using the columns in app code first, keep them in `packages/db` with an explicit N-1 comment, and drop them only after the next release is the supported rollback target. See `packages/db/AGENTS.md` for the package-local rules. +- **Model roles:** `TASK_MODEL_ROLE_DESCRIPTORS` in `packages/types/src/model-provider-config.ts` is the single source of truth for model roles. When adding a role, define its fallback model and reasoning env vars and its fallback runtimes; wire those runtimes through the shared fallback classifier and application path; map subagents in `MODEL_FALLBACK_AGENT_ROLES`; and update the role fallback contract test and `apps/docs/models.mdx`. Do not hard-code role env lists or confuse provider-error fallback with `modelFallback`, which means **Same as coding model** inheritance. ## Slack message formatting diff --git a/CHANGELOG.md b/CHANGELOG.md index 7725a2e0fb..5fcda06737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.15.1 (2026-10-02) + +Roomote 1.15.1 adds resilient model fallbacks, reduces Auto approval prompts, and fixes approval, sessions board, and task-detail edge cases. + +### Highlights + +- Configure per-role model fallbacks that keep work moving through eligible provider failures and make each switch visible to users. +- Fewer unnecessary Auto approval prompts. +- Review the correct arguments when an agent calls the same integration tool several times at once. +- Keep manually active sessions and human command-style prompts visible in the views where users expect them. + +### Patch changes + +- Fix tool approvals showing and checking the wrong arguments when an agent calls the same tool several times at once. +- Add configurable per-role model fallbacks with sticky runtime switching, provider-error retry classification, user-visible notices, notifications, and anonymous telemetry. +- Keep manually active sessions visible in the sessions board's Active lane. +- Keep human prompts that begin with commands visible in task details while continuing to hide generated setup and automation prompts. +- Reduce unnecessary Auto approval prompts for harmless actions and for steps a session's owner already approved. +- Apply model fallbacks to subagents that use the "Same as coding" model. + ## 1.15.0 (2026-09-30) Roomote 1.15 makes sessions and automation runs easier to follow while adding opt-in Judgement repository rules, prompt history, and more reliable approval and resume flows. diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts index b86de3f057..3b5e6ba2d6 100644 --- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts @@ -572,6 +572,7 @@ describe('Discord component callbacks', () => { expect(mocks.startNewTask).toHaveBeenCalledWith( expect.objectContaining({ workspace: target.expectedWorkspace, + visibleInTranscript: false, fastAgentParent: expect.objectContaining({ sessionId: 'fast-1' }), }), ); diff --git a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts index 3349d38e16..410c1e5c3b 100644 --- a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts +++ b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts @@ -350,6 +350,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { agentContext: 'Treat each message as a bug report.', senderUserId: 'roomote-user-1', directedAtRoomote: true, + visibleInTranscript: true, }), ); expect(mocks.addReaction).not.toHaveBeenCalled(); @@ -446,6 +447,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { displayName: payload.author.username, }, }, + visibleInTranscript: false, }), ); }, @@ -475,6 +477,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { key: 'slack_channel_auto_start', actor: { externalId: 'alert-bot', displayName: 'alerts' }, }, + visibleInTranscript: false, }), ); expect(mocks.processFast).not.toHaveBeenCalled(); diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts index b2967e3ef3..5e2dc0f5c8 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -246,6 +246,7 @@ describe('processDiscordFastAgentMessage', () => { interaction: { id: 'new-interaction', token: 'token' } as never, interactionDeferred: true, }, + visibleInTranscript: false, onAccepted, }), ).resolves.toBe(true); @@ -305,6 +306,7 @@ describe('processDiscordFastAgentMessage', () => { threadTs: 'report-thread', userId: 'acting-user', }), + visibleInTranscript: false, fastAgentParent: { sessionId: 'fast-session-1', conversation }, }), ); diff --git a/apps/api/src/handlers/discord/__tests__/task-launch.test.ts b/apps/api/src/handlers/discord/__tests__/task-launch.test.ts index 228cf240ff..6d3cce179a 100644 --- a/apps/api/src/handlers/discord/__tests__/task-launch.test.ts +++ b/apps/api/src/handlers/discord/__tests__/task-launch.test.ts @@ -124,6 +124,7 @@ describe('launchDiscordTask', () => { userId: 'user-1', ts: 'message-1', }, + visibleInTranscript: true, metadata: { communicationProvider: 'discord', communicationGuildId: 'guild-1', @@ -183,6 +184,7 @@ describe('launchDiscordTask', () => { communicationThreadId: 'thread-41', communicationMessageId: 'thread-message-1', communicationSourceEventId: 'message-1', + visibleInTranscript: true, discordTaskThread: true, }), }, diff --git a/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts b/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts index 6452759161..fc51cb763b 100644 --- a/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts +++ b/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts @@ -66,6 +66,7 @@ describe('startNewDiscordTask', () => { applicationId: 'application-1', requesterDiscordUserId: 'discord-user-1', launchOwnerUserId: 'user-1', + visibleInTranscript: true, queuedMessage: { provider: 'discord', text: 'Fix checkout', @@ -94,7 +95,10 @@ describe('startNewDiscordTask', () => { expect(result.status).toBe('started'); expect(mocks.launchTask).toHaveBeenCalledWith( - expect.objectContaining({ beforeEnqueueKickoff }), + expect.objectContaining({ + beforeEnqueueKickoff, + visibleInTranscript: true, + }), ); }); diff --git a/apps/api/src/handlers/discord/callback-actions.ts b/apps/api/src/handlers/discord/callback-actions.ts index d2f4084eaa..b2c2401022 100644 --- a/apps/api/src/handlers/discord/callback-actions.ts +++ b/apps/api/src/handlers/discord/callback-actions.ts @@ -377,6 +377,7 @@ async function launchClaimedDiscordSuggestion(input: { input.triggerId, ), createAnchoredThread: false, + visibleInTranscript: false, }); return fastStart.accepted ? { @@ -468,6 +469,7 @@ async function launchClaimedDiscordSuggestion(input: { applicationId: input.applicationId, requesterDiscordUserId: input.sender.id, launchOwnerUserId: input.senderUserId, + visibleInTranscript: false, queuedMessage, metadata, channel: launchChannel, diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index 4f9a0467df..8f7a5ab49e 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -393,6 +393,7 @@ export async function maybeHandleDiscordChannelAutoStart(input: { await processDiscordFastAgentMessage({ ...fastEntry, senderUserId: launchOwnerUserId!, + visibleInTranscript: true, }); return; } @@ -417,6 +418,7 @@ export async function maybeHandleDiscordChannelAutoStart(input: { ...fastEntry, senderUserId: automationLaunchUserId, delegatedTaskInitiator: initiator, + visibleInTranscript: false, }); if (fastStart.accepted) { apiLogger.info( diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index 0f38a1b074..1fa9de77ed 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -142,6 +142,8 @@ export async function processDiscordFastAgentMessage( * turns pass their automation initiator so delegated work keeps automation * provenance instead of appearing installer-initiated. */ delegatedTaskInitiator?: TaskInitiator; + /** Visibility comes from the entry surface, not the delegated task initiator. */ + visibleInTranscript?: boolean; onAccepted?: (abort: () => Promise) => void; onRejected?: () => void; } & DiscordFastAgentSource, @@ -505,6 +507,9 @@ export async function processDiscordFastAgentMessage( applicationId: input.applicationId, requesterDiscordUserId: input.sender.id, launchOwnerUserId: input.senderUserId, + ...(input.visibleInTranscript !== undefined + ? { visibleInTranscript: input.visibleInTranscript } + : {}), ...(input.delegatedTaskInitiator ? { initiator: input.delegatedTaskInitiator } : {}), diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index b39300d710..7a556e132b 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -1074,6 +1074,7 @@ async function processDiscordGatewayEvent( createAnchoredThread: false, ...(fastInteraction ? { interaction: fastInteraction } : {}), directedAtRoomote: true, + visibleInTranscript: true, }); return { ok: true, fastAnswered: true, fastStartedNew: true }; } @@ -1107,6 +1108,7 @@ async function processDiscordGatewayEvent( isDiscordBotMentioned(message, resolved.botUserId), addressedToRoomote: unmentionedReplyAddressedToRoomote, peerConversationsEnabled, + visibleInTranscript: true, }); return { ok: true, fastAnswered: true, fastContinued: true }; } @@ -1172,6 +1174,7 @@ async function processDiscordGatewayEvent( ...(reactionTarget ? { anchorMessageId: reactionTarget.messageId } : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], directedAtRoomote: true, + visibleInTranscript: true, }); return { ok: true, fastAnswered: true, fastDefaulted: true }; } diff --git a/apps/api/src/handlers/discord/task-launch.ts b/apps/api/src/handlers/discord/task-launch.ts index 45b243d63a..2e6b4a6b5c 100644 --- a/apps/api/src/handlers/discord/task-launch.ts +++ b/apps/api/src/handlers/discord/task-launch.ts @@ -348,6 +348,8 @@ export async function launchDiscordTask(input: { * automation-owned launches for bot-authored messages. */ initiator?: TaskInitiator; + /** Explicit visibility from the task-launch origin, independent of initiator. */ + visibleInTranscript?: boolean; /** * Agent-facing prompt override (e.g. auto-respond channel instructions * prepended to the message); the queued message text stays the @@ -473,6 +475,9 @@ export async function launchDiscordTask(input: { : {}), } : {}), + ...(input.visibleInTranscript !== undefined + ? { visibleInTranscript: input.visibleInTranscript } + : {}), }, }; diff --git a/apps/api/src/handlers/discord/task-orchestration.ts b/apps/api/src/handlers/discord/task-orchestration.ts index 7b4718a214..2067567014 100644 --- a/apps/api/src/handlers/discord/task-orchestration.ts +++ b/apps/api/src/handlers/discord/task-orchestration.ts @@ -77,6 +77,8 @@ export async function startNewDiscordTask(input: { launchOwnerUserId?: string; /** Attribution override; takes precedence over the default user initiator. */ initiator?: TaskInitiator; + /** Explicit visibility from the task-launch origin, independent of initiator. */ + visibleInTranscript?: boolean; queuedMessage: QueuedCommunicationMessage; metadata: DiscordEventCommunicationMetadata; channel: DiscordChannelContext; @@ -245,6 +247,9 @@ export async function startNewDiscordTask(input: { provider: input.provider, launchOwnerUserId: input.launchOwnerUserId, ...(input.initiator ? { initiator: input.initiator } : {}), + ...(input.visibleInTranscript !== undefined + ? { visibleInTranscript: input.visibleInTranscript } + : {}), ...(agentPromptText ? { agentPromptText } : {}), queuedMessage: { ...input.queuedMessage, diff --git a/apps/api/src/handlers/mcp/__tests__/tool-approval-enforcement.test.ts b/apps/api/src/handlers/mcp/__tests__/tool-approval-enforcement.test.ts index a113023a24..0ba430c9c3 100644 --- a/apps/api/src/handlers/mcp/__tests__/tool-approval-enforcement.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/tool-approval-enforcement.test.ts @@ -3,6 +3,7 @@ const { mockDeployment, mockUser, mockSessionForTask, + mockSessionForFastConversation, mockOverrides, mockClaim, mockAutoState, @@ -10,13 +11,19 @@ const { mockLatestUserRequest, mockLatestFastUserRequest, } = vi.hoisted(() => ({ + mockSessionForFastConversation: vi.fn( + async () => null as { id: string } | null, + ), mockExperiment: vi.fn(async () => true), mockDeployment: vi.fn(async () => [] as unknown[]), mockUser: vi.fn(async () => [] as unknown[]), mockSessionForTask: vi.fn(async () => null as { id: string } | null), mockOverrides: vi.fn(async () => [] as unknown[]), mockClaim: vi.fn(async () => true), - mockAutoState: vi.fn(async () => ({ mode: 'off' }) as unknown), + mockAutoState: vi.fn( + async (_scope?: { sessionId?: string | null }) => + ({ mode: 'off' }) as unknown, + ), mockShadow: vi.fn(), mockLatestUserRequest: vi.fn(async () => 'Look up the open invoices.'), mockLatestFastUserRequest: vi.fn(async () => 'What is open for ENG?'), @@ -28,6 +35,7 @@ vi.mock('@roomote/db/server', () => ({ listIntegrationToolPolicies: mockDeployment, listIntegrationToolUserPolicies: mockUser, getSessionForTask: mockSessionForTask, + getSessionForFastConversation: mockSessionForFastConversation, listIntegrationToolSessionOverrides: mockOverrides, claimTaskIntegrationToolCall: mockClaim, fingerprintIntegrationToolCall: (input: unknown) => JSON.stringify(input), @@ -70,7 +78,9 @@ describe('resolveProxyToolApprovalBlocks', () => { ]); mockUser.mockResolvedValue([]); mockSessionForTask.mockResolvedValue(null); + mockSessionForFastConversation.mockResolvedValue(null); mockOverrides.mockResolvedValue([]); + mockAutoState.mockReset(); mockAutoState.mockResolvedValue({ mode: 'off' }); }); @@ -201,6 +211,8 @@ describe('resolveProxyToolApprovalBlocks', () => { resolveTaskId: async () => 'task-1', }); expect(task.defaultBlock).toBe('needs_approval'); + // Auto is read for the task's own session, whose owner turned it on. + expect(mockAutoState).toHaveBeenCalledWith({ sessionId: 'session-1' }); expect(blocksOf(task)).toEqual({ get_issue: 'allow', delete_issue: 'reject', @@ -220,6 +232,77 @@ describe('resolveProxyToolApprovalBlocks', () => { expect(session.defaultBlock).toBeUndefined(); }); + it('follows Auto for the session each call belongs to', async () => { + // Auto is on for one session only; everything else is shadowed. + mockAutoState.mockImplementation(async (scope) => ({ + mode: scope?.sessionId === 'session-on' ? 'on' : 'shadow', + })); + mockDeployment.mockResolvedValue([]); + const conversationId = '11111111-1111-4111-8111-111111111111'; + const requestHeaders = new Headers({ + 'x-roomote-fast-conversation-id': conversationId, + }); + + // A task in a session with Auto off runs its default tools ungated. + mockSessionForTask.mockResolvedValue({ id: 'session-off' }); + const taskOff = await resolveProxyToolApprovalBlocks({ + integrationId: 'linear', + tokenType: 'run', + resolveActingUserId: async () => 'user-1', + resolveTaskId: async () => 'task-1', + }); + expect(taskOff.defaultBlock).toBeUndefined(); + expect(taskOff.shadowDefaultTools).toBe(true); + + // A task with no session has no owner to have turned Auto on. + mockSessionForTask.mockResolvedValue(null); + const orphan = await resolveProxyToolApprovalBlocks({ + integrationId: 'linear', + tokenType: 'run', + resolveActingUserId: async () => 'user-1', + resolveTaskId: async () => 'task-2', + }); + expect(orphan.defaultBlock).toBeUndefined(); + expect(mockAutoState).toHaveBeenLastCalledWith({ sessionId: undefined }); + + // A session's own call names its conversation: with Auto on there it was + // already assessed natively, so the proxy does not shadow it again. + mockSessionForFastConversation.mockResolvedValue({ id: 'session-on' }); + const sessionOn = await resolveProxyToolApprovalBlocks({ + integrationId: 'linear', + tokenType: 'auth', + resolveActingUserId: async () => 'user-1', + requestHeaders, + }); + expect(mockSessionForFastConversation).toHaveBeenCalledWith( + expect.anything(), + conversationId, + ); + expect(sessionOn.shadowDefaultTools).toBe(false); + expect(sessionOn.defaultBlock).toBeUndefined(); + + // The shadow assessment is recorded against the same session. + mockSessionForFastConversation.mockResolvedValue({ id: 'session-off' }); + const sessionOff = await resolveProxyToolApprovalBlocks({ + integrationId: 'linear', + tokenType: 'auth', + resolveActingUserId: async () => 'user-1', + requestHeaders, + }); + expect(sessionOff.shadowDefaultTools).toBe(true); + shadowProxyToolCall(sessionOff, { + integrationId: 'linear', + toolName: 'list_issues', + args: {}, + userId: 'user-1', + taskId: null, + fastConversationId: conversationId, + }); + expect(mockShadow).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-off' }), + ); + }); + it('shadow-assesses default tool calls only while shadowing', async () => { mockAutoState.mockResolvedValue({ mode: 'shadow' }); const approvals = await resolveProxyToolApprovalBlocks({ diff --git a/apps/api/src/handlers/mcp/native-tool-approvals.ts b/apps/api/src/handlers/mcp/native-tool-approvals.ts index bc42769e1e..010db99ad5 100644 --- a/apps/api/src/handlers/mcp/native-tool-approvals.ts +++ b/apps/api/src/handlers/mcp/native-tool-approvals.ts @@ -60,6 +60,7 @@ export async function resolveNativeToolApprovalGuard(input: { tokenType: input.auth.tokenType, resolveActingUserId: () => resolveTaskOrSessionUserIdOrNull(input.auth), resolveTaskId: () => resolveRunTokenTaskId(input.auth), + requestHeaders: input.requestHeaders, }); const inert = diff --git a/apps/api/src/handlers/mcp/proxy-utils.ts b/apps/api/src/handlers/mcp/proxy-utils.ts index 6f27344922..91f82c37bc 100644 --- a/apps/api/src/handlers/mcp/proxy-utils.ts +++ b/apps/api/src/handlers/mcp/proxy-utils.ts @@ -1002,6 +1002,7 @@ export function createMcpProxy(config: McpProxyConfig) { tokenType: auth.tokenType, resolveActingUserId: () => resolveTaskOrSessionUserIdOrNull(auth), resolveTaskId: () => resolveRunTokenTaskId(auth), + requestHeaders: c.req.raw.headers, }); } catch (error) { // Fail closed: an unreadable policy must not let a gated tool run. diff --git a/apps/api/src/handlers/mcp/tool-approval-enforcement.ts b/apps/api/src/handlers/mcp/tool-approval-enforcement.ts index 71baaa9ac6..d5acd84095 100644 --- a/apps/api/src/handlers/mcp/tool-approval-enforcement.ts +++ b/apps/api/src/handlers/mcp/tool-approval-enforcement.ts @@ -4,6 +4,7 @@ import { findLatestFastConversationUserRequest, findLatestTaskUserRequest, fingerprintIntegrationToolCall, + getSessionForFastConversation, getSessionForTask, listIntegrationToolPolicies, listIntegrationToolSessionOverrides, @@ -34,6 +35,8 @@ export type ProxyToolApprovals = { defaultBlock?: 'needs_approval'; /** Whether a call to a default tool should be shadow-assessed. */ shadowDefaultTools: boolean; + /** The session the call belongs to, whose owner turns Auto on. */ + sessionId?: string; }; /** @@ -67,10 +70,28 @@ export async function resolveProxyToolApprovalBlocks(input: { resolveActingUserId: () => Promise; /** The run token's task, for its Session's overrides. */ resolveTaskId?: () => Promise; + /** The request's headers, for the Fast conversation a session's call names. */ + requestHeaders?: Headers; }): Promise { const blocks = new Map(); const result: ProxyToolApprovals = { blocks, shadowDefaultTools: false }; - const autoState = await resolveIntegrationToolAutoState(); + // Auto is the session owner's choice for that session, so find the call's + // session first: a task's, or the one behind the conversation it names. + const taskId = + input.tokenType === 'run' ? await input.resolveTaskId?.() : null; + const fastConversationId = + input.tokenType === 'auth' && input.requestHeaders + ? readFastConversationIdHeader(input.requestHeaders) + : null; + const session = taskId + ? await getSessionForTask(db, taskId) + : fastConversationId + ? await getSessionForFastConversation(db, fastConversationId) + : null; + if (session) result.sessionId = session.id; + const autoState = await resolveIntegrationToolAutoState({ + sessionId: session?.id, + }); result.shadowDefaultTools = autoState.mode === 'shadow'; if (autoState.mode === 'on' && input.tokenType === 'run') { result.defaultBlock = 'needs_approval'; @@ -98,8 +119,6 @@ export async function resolveProxyToolApprovalBlocks(input: { } const overrideModes = new Map(); if (input.tokenType === 'run') { - const taskId = await input.resolveTaskId?.(); - const session = taskId ? await getSessionForTask(db, taskId) : null; const overrides = session ? await listIntegrationToolSessionOverrides(session.id) : []; @@ -184,6 +203,7 @@ export function shadowProxyToolCall( : undefined; recordIntegrationToolShadowEvaluationInBackground({ ...call, + sessionId: approvals.sessionId, ...(resolveUserRequest ? { resolveUserRequest } : {}), }); } diff --git a/apps/api/src/handlers/slack/events/auth-resume.test.ts b/apps/api/src/handlers/slack/events/auth-resume.test.ts index 3116f27d69..6cb0354030 100644 --- a/apps/api/src/handlers/slack/events/auth-resume.test.ts +++ b/apps/api/src/handlers/slack/events/auth-resume.test.ts @@ -118,6 +118,7 @@ describe('resumePendingSlackAuthRequest', () => { thread_ts: '111.000', }, directedAtRoomote: true, + visibleInTranscript: true, userId: 'user-1', }), ); diff --git a/apps/api/src/handlers/slack/events/auth-resume.ts b/apps/api/src/handlers/slack/events/auth-resume.ts index 32474ddf84..10576bc432 100644 --- a/apps/api/src/handlers/slack/events/auth-resume.ts +++ b/apps/api/src/handlers/slack/events/auth-resume.ts @@ -95,6 +95,7 @@ export async function resumePendingSlackAuthRequest( userId: activeMapping.userId, teamId: authToken.slackTeamId, directedAtRoomote: true, + visibleInTranscript: true, errorLogPrefix: `Failed to resume pending Slack request in thread ${authToken.threadTs}:`, }); diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts index 6bf527e83e..f8954238c3 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts @@ -208,6 +208,7 @@ async function processFastAgentReaction(params: { channelId: event.item.channel, threadTs, messageId: event.item.ts, + visibleInTranscript: false, }), postReply: async ({ message, diff --git a/apps/api/src/handlers/slack/events/function-executed.test.ts b/apps/api/src/handlers/slack/events/function-executed.test.ts index f1dc22838b..2f20679b3f 100644 --- a/apps/api/src/handlers/slack/events/function-executed.test.ts +++ b/apps/api/src/handlers/slack/events/function-executed.test.ts @@ -142,6 +142,7 @@ describe('function-executed', () => { userId: 'user_author', teamId: 'T123', directedAtRoomote: true, + visibleInTranscript: false, event: expect.objectContaining({ type: 'app_mention', channel: 'C123', @@ -199,6 +200,7 @@ describe('function-executed', () => { expect.objectContaining({ userId: 'user_installer', delegatedTaskInitiator: { kind: 'automation', key: 'slack_workflow' }, + visibleInTranscript: false, event: expect.objectContaining({ user: 'U_INSTALLER', ts: '222.000', diff --git a/apps/api/src/handlers/slack/events/function-executed.ts b/apps/api/src/handlers/slack/events/function-executed.ts index c85ab16bb2..5216edb7b2 100644 --- a/apps/api/src/handlers/slack/events/function-executed.ts +++ b/apps/api/src/handlers/slack/events/function-executed.ts @@ -308,6 +308,7 @@ export async function processSlackWorkflowFunctionExecuted(params: { userId: fastUserId, teamId: context.teamId, directedAtRoomote: true, + visibleInTranscript: false, ...(initiator.kind === 'automation' ? { delegatedTaskInitiator: initiator } : {}), diff --git a/apps/api/src/handlers/slack/events/message-entry-automated-mention.test.ts b/apps/api/src/handlers/slack/events/message-entry-automated-mention.test.ts index 742659a83f..b9875b70f4 100644 --- a/apps/api/src/handlers/slack/events/message-entry-automated-mention.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-automated-mention.test.ts @@ -142,6 +142,7 @@ describe('automated Slack message mentions', () => { key: 'slack_channel_auto_start', actor: { externalId: 'U_WORKFLOW' }, }, + visibleInTranscript: false, }), ); expect(mocks.startTask).not.toHaveBeenCalled(); diff --git a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts index aaf7928ae4..09103bdd29 100644 --- a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts @@ -14,6 +14,7 @@ const { lookupSlackUserMappingMock, recordInboundSlackConversationMessageMock, processFastAgentMessageMock, + createFastAgentSlackLiveTaskLauncherMock, findSessionAttentionNotificationReplyMock, } = vi.hoisted(() => ({ fetchThreadMessagesMock: vi.fn(), @@ -29,6 +30,7 @@ const { lookupSlackUserMappingMock: vi.fn(), recordInboundSlackConversationMessageMock: vi.fn(), processFastAgentMessageMock: vi.fn(), + createFastAgentSlackLiveTaskLauncherMock: vi.fn(() => vi.fn()), findSessionAttentionNotificationReplyMock: vi.fn(), })); @@ -58,7 +60,8 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('@roomote/slack', async (importOriginal) => ({ ...(await importOriginal()), acquireSlackFastRootBindingLock: acquireRootBindingLockMock, - createFastAgentSlackLiveTaskLauncher: vi.fn(() => vi.fn()), + createFastAgentSlackLiveTaskLauncher: + createFastAgentSlackLiveTaskLauncherMock, hasPendingRoutingConfirmation: hasPendingRoutingConfirmationMock, markSlackThreadExplicitMentionRequired: markSlackThreadExplicitMentionRequiredMock, @@ -305,6 +308,9 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { event: expect.objectContaining({ text: '<@UBOT> please continue' }), }), ); + expect(createFastAgentSlackLiveTaskLauncherMock).toHaveBeenCalledWith( + expect.objectContaining({ visibleInTranscript: true }), + ); expect(evaluateTypeSafeJudgmentsMock).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 64af4539fb..33e204272e 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -654,6 +654,7 @@ export async function processSlackChannelAutoStartTask(params: { slack, userId: launchIdentity.launchUserId, teamId, + visibleInTranscript: !isBotAuthored, directedAtRoomote: !isBotAuthored || mentionsSlackBot(event, slackInstallation.botUserId), @@ -1024,6 +1025,7 @@ async function processAutomatedAppMentionTask(params: { ? { actor: { externalId: event.user } } : {}), }, + visibleInTranscript: false, resolveActiveTasks: () => resolveFastAgentReplyTasks({ slack, @@ -1115,10 +1117,17 @@ export function startFastAgentResponse(params: { * turns pass their automation initiator so delegated work keeps automation * provenance instead of appearing installer-initiated. */ delegatedTaskInitiator?: TaskInitiator; + /** Prompt visibility from the launch origin, not inferred from initiator. */ + visibleInTranscript?: boolean; originSessionId?: string; errorLogPrefix: string; }): Promise { - const { errorLogPrefix, delegatedTaskInitiator, ...fastAgentParams } = params; + const { + errorLogPrefix, + delegatedTaskInitiator, + visibleInTranscript, + ...fastAgentParams + } = params; return startAcceptedFastAgentTurn({ run: ({ onAccepted, onRejected }) => processFastAgentMessage({ @@ -1134,6 +1143,7 @@ export function startFastAgentResponse(params: { ...(delegatedTaskInitiator ? { initiator: delegatedTaskInitiator } : {}), + ...(visibleInTranscript !== undefined ? { visibleInTranscript } : {}), ...(params.slackInstallation.teamDomain ? { teamDomain: params.slackInstallation.teamDomain } : {}), @@ -1311,6 +1321,7 @@ async function handleSlackEntryEvent(params: { slack, userId: userMapping.userId, teamId, + visibleInTranscript: true, resolveActiveTasks: () => resolveFastAgentReplyTasks({ slack, diff --git a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.db.test.ts b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.db.test.ts index c6268ca746..e2d3447c75 100644 --- a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.db.test.ts +++ b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.db.test.ts @@ -206,6 +206,7 @@ describe('Slack suggestion reaction execution-root persistence', () => { }); expect(mocks.startFastAgentResponse).toHaveBeenCalledWith( expect.objectContaining({ + visibleInTranscript: false, event: expect.objectContaining({ ts: 'successful-execution-root', thread_ts: 'successful-execution-root', diff --git a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts index 997647359b..8ca385a692 100644 --- a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts +++ b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts @@ -502,6 +502,7 @@ describe('chat reply suggestion reactions', () => { if (launchKind === 'router') { expect(mocks.startFastAgentResponse).toHaveBeenCalledWith( expect.objectContaining({ + visibleInTranscript: false, event: expect.objectContaining({ channel: 'C1', ts: 'execution-thread-ts', diff --git a/apps/api/src/handlers/slack/events/reactions.ts b/apps/api/src/handlers/slack/events/reactions.ts index 6b86387bf3..5f90b6f23f 100644 --- a/apps/api/src/handlers/slack/events/reactions.ts +++ b/apps/api/src/handlers/slack/events/reactions.ts @@ -775,6 +775,7 @@ async function launchTaskSuggestionTaskFromReaction({ slack, userId: activeUserMapping.userId, teamId, + visibleInTranscript: false, errorLogPrefix: `Failed to start Fast suggestion response for work item ${workItemId}:`, }); return fastStart.accepted @@ -830,6 +831,7 @@ async function launchTaskSuggestionTaskFromReaction({ channelId: announceChannelId, threadTs: launchThreadTs, messageId: announceMessageTs, + visibleInTranscript: false, initiator, repoForPayload: launchTarget.kind === 'all_repositories' diff --git a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts index 0769f0b141..3e5afdc63e 100644 --- a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts @@ -161,6 +161,10 @@ describe('launchTask', () => { // Guard against the regression: the raw row fields must not leak through. expect(json.id).toBeUndefined(); expect(json.error).toBeUndefined(); + const launchedTask = mockLaunchPinned.mock.calls[0]?.[0] as { + task: { payload: { visibleInTranscript?: boolean } }; + }; + expect(launchedTask.task.payload.visibleInTranscript).toBe(true); }); it('maps read-only launch rejection to a stable 409 error', async () => { diff --git a/apps/api/src/handlers/tasks/launchTask.ts b/apps/api/src/handlers/tasks/launchTask.ts index f14fabd1f1..8b6be3c898 100644 --- a/apps/api/src/handlers/tasks/launchTask.ts +++ b/apps/api/src/handlers/tasks/launchTask.ts @@ -275,7 +275,7 @@ export async function launchTask( ? body.visibleInTranscript : body.hidden === true ? false - : taskTypePayload.visibleInTranscript; + : (taskTypePayload.visibleInTranscript ?? true); const workspacePayload = body.repo === NO_REPOSITORIES diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index 1f9e896f48..65f0dd550f 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -754,6 +754,7 @@ describe('Teams webhook handler', () => { payload: expect.objectContaining({ repo: 'acme/app', description: 'Fix the flaky test', + visibleInTranscript: false, reportConsumer: 'orchestrator', fastAgentSessionId: 'fast-1', }), diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 0cbfcf4528..ada6092c46 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -1504,6 +1504,7 @@ async function launchTeamsTask(input: { queuedMessage: QueuedTeamsCommunicationMessage; metadata: TeamsActivityCommunicationMetadata; workspace: TeamsWorkspaceSelection; + visibleInTranscript?: boolean; /** The session that owns this task; its transcript gets the kickoff. */ fastAgentParent?: FastAgentParent; /** Runs inside the launch gate before the child becomes runnable. */ @@ -1527,6 +1528,9 @@ async function launchTeamsTask(input: { ...(input.fastAgentParent ? buildFastAgentChildTaskMetadata(input.fastAgentParent) : {}), + ...(input.visibleInTranscript !== undefined + ? { visibleInTranscript: input.visibleInTranscript } + : {}), }, }; const launchResult = await enqueueTask( @@ -1609,6 +1613,7 @@ async function launchPinnedTeamsSuggestionTask(input: { queuedMessage: input.queuedMessage, metadata: input.metadata, workspace: input.workspace, + visibleInTranscript: false, fastAgentParent: parent, beforeEnqueue: async () => { await postKickoff(); diff --git a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts index 660bbc84ec..257d84218c 100644 --- a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts @@ -290,6 +290,7 @@ describe('handleTelegramCallbackQuery suggestion launch lifecycle', () => { expect(launchTelegramTaskMock).toHaveBeenCalledWith( expect.objectContaining({ launchOwnerUserId: 'user-1', + visibleInTranscript: false, queuedMessage: expect.objectContaining({ text: 'Fix the flaky test\n\nThe retry loop never terminates.', user: 'Matt', diff --git a/apps/api/src/handlers/telegram/__tests__/task-launch.test.ts b/apps/api/src/handlers/telegram/__tests__/task-launch.test.ts index 6c07c56b9b..9ba4d9b8b8 100644 --- a/apps/api/src/handlers/telegram/__tests__/task-launch.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/task-launch.test.ts @@ -99,6 +99,7 @@ describe('Telegram task topic launch', () => { replyTarget: { channelId: '555' }, }, }, + visibleInTranscript: false, beforeEnqueue, }); @@ -109,6 +110,7 @@ describe('Telegram task topic launch', () => { payload: expect.objectContaining({ reportConsumer: 'orchestrator', fastAgentSessionId: '66666666-6666-4666-8666-666666666666', + visibleInTranscript: false, fastAgentParent: expect.objectContaining({ sessionId: '66666666-6666-4666-8666-666666666666', }), diff --git a/apps/api/src/handlers/telegram/callback-actions.ts b/apps/api/src/handlers/telegram/callback-actions.ts index 2f5c101a24..18094a60e0 100644 --- a/apps/api/src/handlers/telegram/callback-actions.ts +++ b/apps/api/src/handlers/telegram/callback-actions.ts @@ -410,6 +410,7 @@ async function handleSuggestionLaunchCallback(params: { const launched = await launchTelegramTask({ launchOwnerUserId: senderUserId, queuedMessage, + visibleInTranscript: false, metadata: { communicationProvider: 'telegram', communicationChannelId: chatId, diff --git a/apps/api/src/handlers/telegram/task-launch.ts b/apps/api/src/handlers/telegram/task-launch.ts index e53692497b..bcabb4fc00 100644 --- a/apps/api/src/handlers/telegram/task-launch.ts +++ b/apps/api/src/handlers/telegram/task-launch.ts @@ -91,6 +91,7 @@ export async function launchTelegramTask(input: { queuedMessage: QueuedTelegramCommunicationMessage; metadata: TelegramUpdateCommunicationMetadata; workspace: TelegramWorkspaceSelection; + visibleInTranscript?: boolean; createTopicForTask?: boolean; /** The session that owns this task; its transcript gets the kickoff. */ fastAgentParent?: FastAgentParent; @@ -140,6 +141,9 @@ export async function launchTelegramTask(input: { ...(input.fastAgentParent ? buildFastAgentChildTaskMetadata(input.fastAgentParent) : {}), + ...(input.visibleInTranscript !== undefined + ? { visibleInTranscript: input.visibleInTranscript } + : {}), }, }; const launchResult = await enqueueTask( diff --git a/apps/docs/communications.mdx b/apps/docs/communications.mdx index 208ba4d8ed..6bb1dc016e 100644 --- a/apps/docs/communications.mdx +++ b/apps/docs/communications.mdx @@ -108,6 +108,10 @@ Task ownership follows the person who asked for the work. When identities are linked, Roomote can attribute a task to the right user, respect access rules, and continue the right thread when someone replies later. +When a task or session switches to a configured model fallback, Roomote posts one +notice to the originating Slack, Teams, Telegram, Discord, or email thread. The +switch also remains visible in the dashboard transcript. + If someone asks Roomote to do work from chat before their account is linked, Roomote prompts them to link the account. Slack resumes the original request through the session after linking; on other providers, they may need to send the diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 02e3024e3a..5954460d59 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -309,6 +309,11 @@ work. Session turns also survive API or worker interruptions. Roomote durably admits the turn before acknowledging it, records every action the turn takes as it happens, and resumes unfinished work on another process. The resumed run receives the transcript of its earlier attempt, up to the point it was cut off, so it continues from there instead of repeating actions or asking you to send the request again. If the cut lands on the final reply itself, the resumed run finishes from the transcript: a reply that was recorded is not posted again, and a reply the process died while posting goes out once more without another model request. This covers turns started by a typed message, by an emoji reaction, and by platform events such as a setup kickoff. Provider-side retry waits are honored the same way: short waits keep the turn in place, and longer waits park it durably until the scheduled time. +When an admin configured an orchestration fallback, an eligible provider failure +switches the session to that model before a long retry is parked. The fallback +remains selected for later turns and resumed work until someone changes the +session model manually. + ### Code Mode Integrations Sessions search and call each connected integration's tools through OpenCode's diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 4477ef6938..715dacfe7e 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -284,7 +284,8 @@ must stay enabled, because Roomote uses it when a task does not request a specific model. Model settings affect new task starts. Running tasks and resumed snapshots keep -the model that was selected when they started. +the model that was selected when they started, except when a configured fallback +switches a running task or session after a provider failure. ## Model roles @@ -307,6 +308,35 @@ You do not need a separate model for every role. Set a specialized role to strong default model, then split out a faster orchestration or helper model, or a stronger review model, after they can see real usage. +## Fallback models + +Admins can turn on **Fallback config** in **Settings > Models** and choose one +enabled fallback model and reasoning level for each role. A fallback must differ +from that role's effective default. Mapping presets change only the **Default** +rows; they do not replace saved fallbacks. + +Roomote switches immediately when the active provider runs out of credits or +quota, rejects credentials with a 401 or 403 response, or reports the model as +unavailable with a 404 response. Other retryable provider failures, including +rate limits and transient server errors, switch after three Roomote-level +attempts. Safety refusals, context-length failures, output-length failures, and +user aborts do not trigger a fallback. An HTML gateway or WAF 403 is treated as +a transient provider failure rather than a credential rejection. + +After a switch, the task or session stays on the fallback for the rest of its +life, including snapshot resumes. New tasks and sessions start on their defaults. +Choosing another model manually takes precedence and re-arms fallback protection +for that model. The transcript shows an expandable fallback row, and work started +from a communications provider also receives one notice in its originating +thread. + +Fallbacks cover task coding and review models, visual, explore, and advisor +subagents, session orchestration, and Roomote's helper calls for titles, +summaries, and routing. OpenCode's private helper calls inside a task sandbox, +such as OpenCode-generated session titles, are not observable to Roomote and +cannot trigger fallback. This error fallback is separate from **Same as coding +model**, which only makes a role inherit the coding model before a request starts. + ### Coding model routing rules Admins can add natural-language routing rules below **Coding model** in @@ -466,6 +496,8 @@ Changes apply from the next message: a turn that is already running finishes on the old settings, and the next turn (including its sub-agents) uses the new ones. Overrides persist for the life of the task, including snapshot resumes, and **Reset to defaults** returns every role to the deployment configuration. +When Roomote switches after a provider failure, the model chip and session +composer show the fallback immediately; a manual selection overrides it. You can also just ask the agent — for example "switch to Fable 5 with max reasoning for the rest of this task". The agent applies the same change @@ -587,9 +619,12 @@ deployment. It helps admins compare models without relying on stale defaults. for the vision role. - **A model works from one provider but not another.** Check provider-specific credentials, rate limits, model availability, and model ID prefix. +- **A task switched to a fallback model.** Check the failed provider's credits, + credentials, and model availability. The current task stays on the fallback; + a new task starts on the configured default. - **Work stops with "You seem to have run out of credits".** The provider reported that the account has no remaining credits or quota, or that a - ChatGPT subscription reached its usage limit. Roomote does not retry these - automatically, because retrying cannot succeed until the account changes. - Add credits or wait for the subscription limit to reset, or switch to a model - from another provider. Temporary rate limits are still retried automatically. + ChatGPT subscription reached its usage limit. If that role has an enabled + fallback, Roomote switches immediately; otherwise add credits, wait for the + subscription limit to reset, or switch to another provider. Temporary rate + limits are retried before fallback. diff --git a/apps/docs/privacy.mdx b/apps/docs/privacy.mdx index 56dccecc1c..35f019f3c6 100644 --- a/apps/docs/privacy.mdx +++ b/apps/docs/privacy.mdx @@ -47,7 +47,8 @@ When anonymous telemetry is enabled, your deployment sends: the provider type and, where Roomote can determine it from deployment state, whether it was configured before the wizard. Other events include non-identifying facts like the harness, model, source surface, and sandbox - provider used. Session events include bounded outcome and retry counts; + provider used. Model fallback events include only the previous and replacement + provider and model names. Session events include bounded outcome and retry counts; setup and response phase durations; aggregate model-request and token-usage counts; prompt-size, context-size, environment, integration, and active-task counts; and whether a turn is the session's first human message. They do not diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index 1bbcee70d8..de7a2b2385 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -21,6 +21,7 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ let currentEnvironmentsPending = false; let currentBrainConfigured = false; let currentPrivateSessionsExperimentEnabled = false; +let currentAutoToolApprovalsOffer = { shown: false, available: false }; let currentHomeSuggestions: string[] = []; let currentHomeSuggestionsHasData = true; let currentHomeSuggestionsPending = false; @@ -149,6 +150,10 @@ vi.mock('@/hooks/usePrivateSessionsExperiment', () => ({ }), })); +vi.mock('@/hooks/useSessionAutoToolApprovals', () => ({ + useNewSessionAutoToolApprovals: () => currentAutoToolApprovalsOffer, +})); + vi.mock('@/hooks/useLiveVoice', () => ({ useLiveVoice: ({ onUtterance }: { onUtterance: (text: string) => void }) => { voiceState.onUtterance = onUtterance; @@ -338,6 +343,7 @@ describe('Home', () => { currentEnvironmentsPending = false; currentBrainConfigured = false; currentPrivateSessionsExperimentEnabled = false; + currentAutoToolApprovalsOffer = { shown: false, available: false }; currentHomeSuggestions = []; currentHomeSuggestionsHasData = true; currentHomeSuggestionsPending = false; @@ -441,6 +447,53 @@ describe('Home', () => { ); }); + it('offers the tool approvals mode in the composer, starting in Run', async () => { + const { unmount } = render(); + // Not offered outside the experiment. + expect( + screen.queryByRole('button', { name: /^Tool approvals/ }), + ).not.toBeInTheDocument(); + unmount(); + + currentAutoToolApprovalsOffer = { shown: true, available: true }; + render(); + expect( + screen.getByRole('button', { name: /^Tool approvals/ }), + ).toHaveTextContent('Run'); + + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + await waitFor(() => expect(mockStartFastSession).toHaveBeenCalled()); + expect(mockStartFastSession.mock.calls[0]![0]).not.toHaveProperty( + 'autoToolApprovals', + ); + }); + + it('starts the session with Auto on when Auto is chosen', async () => { + currentAutoToolApprovalsOffer = { shown: true, available: true }; + render(); + fireEvent.click(screen.getByRole('button', { name: /^Tool approvals/ })); + fireEvent.click(screen.getByRole('option', { name: /^Auto/ })); + expect( + screen.getByRole('button', { name: /^Tool approvals/ }), + ).toHaveTextContent('Auto'); + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + await waitFor(() => { + expect(mockStartFastSession).toHaveBeenCalledWith( + expect.objectContaining({ autoToolApprovals: true }), + ); + }); + }); + + it('cannot choose Auto while nothing can assess calls', () => { + currentAutoToolApprovalsOffer = { shown: true, available: false }; + render(); + fireEvent.click(screen.getByRole('button', { name: /^Tool approvals/ })); + expect(screen.getByRole('option', { name: /^Auto/ })).toBeDisabled(); + expect(screen.getByRole('option', { name: /^Auto/ })).toHaveTextContent( + 'Not available yet.', + ); + }); + it('retains the private Session selection and sends it with creation', async () => { currentPrivateSessionsExperimentEnabled = true; const { rerender } = render(); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index fb73051304..3f399108a4 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -48,6 +48,24 @@ vi.mock('@/hooks/useSessionIntegrationToolApprovals', () => ({ }), })); +const autoToolApprovals = vi.hoisted(() => ({ + state: undefined as + | { available: boolean; enabled: boolean; suspended: boolean } + | undefined, + setEnabled: vi.fn(), + sessionIds: [] as string[], +})); +vi.mock('@/hooks/useSessionAutoToolApprovals', () => ({ + useSessionAutoToolApprovals: (sessionId: string) => { + autoToolApprovals.sessionIds.push(sessionId); + return { + state: autoToolApprovals.state, + isSaving: false, + setEnabled: autoToolApprovals.setEnabled, + }; + }, +})); + vi.mock('./CapabilityOfferCard', () => ({ CapabilityOfferCard: ({ offer }: { offer: { capability: string } }) => (
Capability offer: {offer.capability}
@@ -580,6 +598,45 @@ describe('FastSessionTranscript', () => { createdAt: new Date(ts), }); + it('offers the tool approvals mode in the composer to the session owner only', () => { + autoToolApprovals.state = { + available: true, + enabled: false, + suspended: false, + }; + autoToolApprovals.sessionIds.length = 0; + autoToolApprovals.setEnabled.mockClear(); + const { unmount } = render( + , + ); + const chip = screen.getByRole('button', { name: /^Tool approvals/ }); + expect(chip).toHaveTextContent('Run'); + // Approvals are keyed on the unified session, not the Fast conversation. + expect(autoToolApprovals.sessionIds).toContain('canonical-session'); + fireEvent.click(chip); + fireEvent.click(screen.getByRole('option', { name: /^Auto/ })); + expect(autoToolApprovals.setEnabled).toHaveBeenCalledWith(true); + unmount(); + + // Someone else's session: nothing to choose. + render( + , + ); + expect( + screen.queryByRole('button', { name: /^Tool approvals/ }), + ).not.toBeInTheDocument(); + autoToolApprovals.state = undefined; + }); + it('shows a pending-key card for the owner and opens the key dialog from it', async () => { integrationApprovalsState.pending = [ { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 7ac308905d..08abfb5d7b 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -2381,6 +2381,7 @@ export function FastSessionTranscript({ onModelSelectionChange={(selection) => { modelSelectionRef.current = selection; }} + toolApprovalsSessionId={secretSessionId} /> {replyError ? ( void; + /** The unified session, when the viewer owns it and may pick its tool + * approvals mode. */ + toolApprovalsSessionId?: string; }) { const trpc = useTRPC(); const trpcClient = useTRPCClient(); @@ -557,6 +562,12 @@ export function SessionPromptInput({ defaultReasoningEffort={defaultReasoningEffort} disabled={controlsDisabled} /> + {toolApprovalsSessionId ? ( + + ) : null}
{voice?.enabled ? ( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx index 0c3e1ca5e6..5dba83e5db 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx @@ -817,6 +817,33 @@ describe('Messages', () => { ); }); + it('renders a visible command-style initial prompt in the task transcript', () => { + render( + , + ); + + expect( + screen.getByText('$review-code Check this change'), + ).toBeInTheDocument(); + expect(mockBuildAcpRenderBlocks).toHaveBeenCalledWith( + [], + expect.objectContaining({ + shouldHideFirstMessage: true, + }), + ); + }); + it('hides the first ACP user prompt when the session prompt is rendered above', () => { render( { }); describe('getTaskRunVisiblePrompt', () => { + it('keeps an explicitly visible human shortcut prompt in task detail', () => { + expect( + getTaskRunVisiblePrompt( + buildTaskRun({ + repo: 'Roomote/example-app', + description: '$review-code Check this change', + visibleInTranscript: true, + } satisfies TaskPayload), + ), + ).toEqual({ + text: '$review-code Check this change', + visibleInTranscript: true, + }); + }); + + it('keeps command-like messages visible after chat wrappers are removed', () => { + expect( + getTaskRunVisiblePrompt( + buildTaskRun({ + text: '$review-code Check this change', + channel: 'C123', + user: 'U123', + ts: '123.000', + repo: 'Roomote/example-app', + visibleInTranscript: true, + } satisfies TaskPayload), + ), + ).toEqual({ + text: '$review-code Check this change', + visibleInTranscript: true, + }); + }); + + it('keeps explicitly hidden execution prompts hidden for human-created tasks', () => { + expect( + getTaskRunVisiblePrompt( + buildTaskRun({ + repo: 'Roomote/example-app', + description: '$environment-setup Set up the environment', + visibleInTranscript: false, + } satisfies TaskPayload), + ), + ).toMatchObject({ visibleInTranscript: false }); + }); + + it('keeps Roomote execution wrappers hidden', () => { + expect( + getTaskRunVisiblePrompt( + buildTaskRun({ + repo: 'Roomote/example-app', + description: + 'Internal execution instructions\n/request', + } satisfies TaskPayload), + ), + ).toMatchObject({ visibleInTranscript: false }); + }); + + it('keeps unflagged legacy command envelopes hidden', () => { + expect( + getTaskRunVisiblePrompt( + buildTaskRun({ + repo: 'Roomote/example-app', + description: '$environment-setup Generated setup instructions', + } satisfies TaskPayload), + ), + ).toMatchObject({ visibleInTranscript: false }); + }); + it('strips Slack thread context and reply targets from the visible prompt text', () => { expect( getTaskRunVisiblePrompt( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx index d9da99a56e..ceb515b533 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx @@ -4,6 +4,7 @@ import { ACP_ENVELOPE_EVENT_TYPES, type AcpRequestUserInputPayload, getProviderRetryNoticeFromMessageData, + getModelFallbackNoticeFromMessageData, getTerminalProviderErrorFromMessageData, parseLinkedReviewResults, parsePrReviewActionOffer, @@ -43,6 +44,7 @@ import { messageAnchorId } from '../message-anchor'; import type { AcpUiMessage } from './types'; import { ProviderRetryNoticeMessage } from './ProviderRetryNoticeMessage'; import { TerminalProviderErrorMessage } from './TerminalProviderErrorMessage'; +import { ModelFallbackNoticeMessage } from './ModelFallbackNoticeMessage'; import { AcpDataVisualizations } from './AcpDataVisualizations'; import { PrReviewActionOffer } from '@/components/ai-elements/pr-review-action-offer'; import { useMessageUiOptions } from '@/components/ai-elements/message-ui-options'; @@ -222,6 +224,12 @@ export function AcpTextMessage({ msg }: AcpTextMessageProps) { msg.data as Record, ) : null; + const modelFallbackNotice = + !isUser && msg.kind === 'text' + ? getModelFallbackNoticeFromMessageData( + msg.data as Record, + ) + : null; const requestUserInputResponse = isUser && msg.updateType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse @@ -243,6 +251,7 @@ export function AcpTextMessage({ msg }: AcpTextMessageProps) { : msg.partial !== true && msg.isTurnCompletion === true && !providerRetryNotice && + !modelFallbackNotice && !terminalProviderError; const messageContentClassName = cn( 'min-w-0 flex-1', @@ -388,6 +397,10 @@ export function AcpTextMessage({ msg }: AcpTextMessageProps) { data={msg.data as Record} text={content} /> + ) : modelFallbackNotice ? ( + } + /> ) : terminalProviderError ? ( } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/ModelFallbackNoticeMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/ModelFallbackNoticeMessage.tsx new file mode 100644 index 0000000000..c9570d986c --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/ModelFallbackNoticeMessage.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { getModelFallbackNoticeFromMessageData } from '@roomote/types'; + +import { ArrowRightLeft } from '@/components/system'; +import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements'; + +export function ModelFallbackNoticeMessage({ + data, +}: { + data: Record; +}) { + const notice = getModelFallbackNoticeFromMessageData(data); + if (!notice) return null; + + return ( + + + +

Failed provider: {notice.fromProvider}

+

Error: {notice.errorSummary}

+

+ Switched to: {notice.toModelId} + {notice.toReasoningEffort ? ` (${notice.toReasoningEffort})` : ''} +

+
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts index a10ec28969..f627bbf1fd 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts @@ -1,4 +1,7 @@ -import { getProviderRetryNoticeFromMessageData } from '@roomote/types'; +import { + getModelFallbackNoticeFromMessageData, + getProviderRetryNoticeFromMessageData, +} from '@roomote/types'; import type { TaskArtifact } from '@/types'; @@ -66,6 +69,9 @@ function isProviderRetryNoticeMessage(msg: AcpUiMessage): boolean { return ( getProviderRetryNoticeFromMessageData( msg.data as Record, + ) !== null || + getModelFallbackNoticeFromMessageData( + msg.data as Record, ) !== null ); } diff --git a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx index 6f1c5afd05..78a986e9c6 100644 --- a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx +++ b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.client.test.tsx @@ -99,6 +99,51 @@ describe('PendingIntegrationToolApprovals', () => { ).toBeInTheDocument(); }); + it('asks about a batch of parallel calls on one card and lists each call', () => { + render( + + + , + ); + + expect( + screen.getByText('Let Mock Slack run these 2 calls?'), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Details' })); + expect( + screen.getByText(/"channel": "C1"[\s\S]*"channel": "C2"/), + ).toBeInTheDocument(); + expect(screen.queryByText(/parallelCalls/)).not.toBeInTheDocument(); + }); + + it('shows a tool argument named like a batch as one call', () => { + render( + + + , + ); + + expect( + screen.getByText('Let Mock Slack use this tool?'), + ).toBeInTheDocument(); + }); + it('disables every decision while the request is being submitted', async () => { let resolveFetch: ((response: Response) => void) | undefined; fetchMock.mockImplementationOnce( diff --git a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx index 097683f89f..3270b46972 100644 --- a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx +++ b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx @@ -33,8 +33,23 @@ function integrationDisplayName(integrationId: string): string { return integrationNames.get(integrationId) ?? formatIdentifier(integrationId); } +/** + * The calls of a batch card: several calls to one tool that ran together. + * The server records them as a list, which a single call's arguments (always + * an object) never are. + */ +function parallelCalls(argsSummary: unknown): unknown[] | null { + return Array.isArray(argsSummary) && argsSummary.length > 1 + ? argsSummary + : null; +} + function approvalPrompt(item: IntegrationToolApprovalMetadata): string { const name = integrationDisplayName(item.integrationId) || 'this integration'; + const batch = parallelCalls(item.argsSummary); + if (batch) { + return `Let ${name} run these ${batch.length} calls?`; + } const normalizedToolName = item.toolName.toLowerCase(); const argumentKeys = item.argsSummary && @@ -81,6 +96,12 @@ function describeAutoEvaluation( } function summarizeArgs(argsSummary: unknown): string | null { + const batch = parallelCalls(argsSummary); + if (batch) { + return batch + .map((call) => JSON.stringify(call ?? {}, null, 2) ?? '{}') + .join('\n\n'); + } if ( argsSummary === null || argsSummary === undefined || diff --git a/apps/web/src/components/sessions/SessionToolApprovalsPicker.client.test.tsx b/apps/web/src/components/sessions/SessionToolApprovalsPicker.client.test.tsx new file mode 100644 index 0000000000..2dc60467b7 --- /dev/null +++ b/apps/web/src/components/sessions/SessionToolApprovalsPicker.client.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +const auto = vi.hoisted(() => ({ + state: undefined as + | { available: boolean; enabled: boolean; suspended: boolean } + | undefined, + isSaving: false, + setEnabled: vi.fn(), +})); + +vi.mock('@/hooks/useSessionAutoToolApprovals', () => ({ + useSessionAutoToolApprovals: () => auto, +})); + +import { + SessionToolApprovalsPicker, + ToolApprovalsPicker, +} from './SessionToolApprovalsPicker'; + +const chip = () => + screen.getByRole('button', { name: /^Tool approvals for this session/ }); +const openMenu = () => fireEvent.click(chip()); +const option = (name: RegExp) => screen.getByRole('option', { name }); + +describe('ToolApprovalsPicker', () => { + it('names the current mode on the chip and lists both modes with what they do', () => { + render(); + expect(chip()).toHaveTextContent('Run'); + openMenu(); + expect(screen.getByText('Tool approvals')).toBeInTheDocument(); + expect(option(/^Run/)).toHaveAttribute('aria-selected', 'true'); + expect(option(/^Auto/)).toHaveAttribute('aria-selected', 'false'); + // Run is honest about the per-tool choices that still apply. + expect( + screen.getByText( + 'Tools run without a check, unless set to Always ask or Disable.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Roomote checks each tool call and asks before risky ones.', + ), + ).toBeInTheDocument(); + }); + + it('changes the mode from the list and closes it', () => { + const onModeChange = vi.fn(); + const { rerender } = render( + , + ); + openMenu(); + fireEvent.click(option(/^Auto/)); + expect(onModeChange).toHaveBeenLastCalledWith('auto'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + + rerender(); + expect(chip()).toHaveTextContent('Auto'); + openMenu(); + // Picking the mode already in effect changes nothing. + fireEvent.click(option(/^Auto/)); + expect(onModeChange).toHaveBeenCalledTimes(1); + openMenu(); + fireEvent.click(option(/^Run/)); + expect(onModeChange).toHaveBeenLastCalledWith('run'); + }); + + it('keeps explaining Auto while it cannot be chosen, and can always leave it', () => { + const onModeChange = vi.fn(); + const { rerender } = render( + , + ); + openMenu(); + expect(option(/^Auto/)).toBeDisabled(); + expect(option(/^Auto/)).toHaveTextContent( + 'Roomote checks each tool call and asks before risky ones. Not available yet.', + ); + fireEvent.click(option(/^Run/)); + + rerender( + , + ); + openMenu(); + expect(option(/^Run/)).toBeEnabled(); + fireEvent.click(option(/^Run/)); + expect(onModeChange).toHaveBeenLastCalledWith('run'); + }); + + it('says when Auto paused itself and offers to resume it', () => { + const onResume = vi.fn(); + const { rerender } = render( + , + ); + expect(chip()).toHaveTextContent('Auto paused'); + openMenu(); + expect( + screen.getByText( + 'Auto couldn’t check tool calls, so tools will ask before running.', + ), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Resume Auto' })); + expect(onResume).toHaveBeenCalledOnce(); + + // Nothing to resume with while calls still cannot be checked. + rerender( + , + ); + openMenu(); + expect( + screen.queryByRole('button', { name: 'Resume Auto' }), + ).not.toBeInTheDocument(); + }); +}); + +describe('SessionToolApprovalsPicker', () => { + beforeEach(() => { + auto.state = { available: true, enabled: false, suspended: false }; + auto.isSaving = false; + auto.setEnabled.mockClear(); + }); + + it('renders nothing until the mode is offered for the session', () => { + auto.state = undefined; + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('starts in Run and turns Auto on and off for this session', () => { + const { rerender } = render( + , + ); + expect(chip()).toHaveTextContent('Run'); + openMenu(); + fireEvent.click(option(/^Auto/)); + expect(auto.setEnabled).toHaveBeenLastCalledWith(true); + + auto.state = { available: true, enabled: true, suspended: true }; + rerender(); + expect(chip()).toHaveTextContent('Auto paused'); + openMenu(); + fireEvent.click(screen.getByRole('button', { name: 'Resume Auto' })); + expect(auto.setEnabled).toHaveBeenLastCalledWith(true); + openMenu(); + fireEvent.click(option(/^Run/)); + expect(auto.setEnabled).toHaveBeenLastCalledWith(false); + }); + + it('holds the chip while a change is saving', () => { + auto.isSaving = true; + render(); + expect(chip()).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/sessions/SessionToolApprovalsPicker.tsx b/apps/web/src/components/sessions/SessionToolApprovalsPicker.tsx new file mode 100644 index 0000000000..18334a4fcb --- /dev/null +++ b/apps/web/src/components/sessions/SessionToolApprovalsPicker.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { useState } from 'react'; + +import { cn } from '@/lib/utils'; +import { useSessionAutoToolApprovals } from '@/hooks/useSessionAutoToolApprovals'; + +import { + BasicTooltip, + Button, + buttonVariants, + Check, + ChevronDown, + Popover, + PopoverContent, + PopoverTrigger, + Scale, +} from '@/components/system'; + +type ToolApprovalsMode = 'run' | 'auto'; + +const MODES: { + mode: ToolApprovalsMode; + label: string; + description: string; +}[] = [ + { + mode: 'run', + label: 'Run', + description: + 'Tools run without a check, unless set to Always ask or Disable.', + }, + { + mode: 'auto', + label: 'Auto', + description: 'Roomote checks each tool call and asks before risky ones.', + }, +]; + +const COPY = { + title: 'Tool approvals', + tooltip: 'Tool approvals for this session', + unavailable: 'Not available yet.', + pausedLabel: 'Auto paused', + paused: 'Auto couldn’t check tool calls, so tools will ask before running.', + resume: 'Resume Auto', +}; + +/** + * The composer's tool approvals chip: what happens, in this session, to the + * tools nobody has made a choice about. It opens a short list of modes. The + * mode is the session owner's choice and every session starts in Run. + */ +export function ToolApprovalsPicker({ + mode, + available = true, + paused = false, + disabled, + size = 'compact', + onModeChange, + onResume, +}: { + mode: ToolApprovalsMode; + /** Whether Auto can be chosen; leaving it always works. */ + available?: boolean; + /** Auto stopped because calls could not be checked. */ + paused?: boolean; + disabled?: boolean; + size?: 'compact' | 'base'; + onModeChange: (mode: ToolApprovalsMode) => void; + onResume?: () => void; +}) { + const [open, setOpen] = useState(false); + const autoPaused = mode === 'auto' && paused; + const label = autoPaused + ? COPY.pausedLabel + : MODES.find((entry) => entry.mode === mode)!.label; + + return ( + + + + + + + +

+ {COPY.title} +

+
+ {MODES.map((entry) => { + const selected = entry.mode === mode; + const locked = entry.mode === 'auto' && !available && !selected; + return ( + + ); + })} +
+ {autoPaused ? ( +
+

{COPY.paused}

+ {available && onResume ? ( + + ) : null} +
+ ) : null} +
+
+ ); +} + +/** The tool approvals chip for one running session, shown to its owner. */ +export function SessionToolApprovalsPicker({ + sessionId, + disabled, +}: { + /** The unified session, which approvals are keyed on. */ + sessionId: string; + disabled?: boolean; +}) { + const { state, isSaving, setEnabled } = + useSessionAutoToolApprovals(sessionId); + if (!state) return null; + + return ( + setEnabled(mode === 'auto')} + onResume={() => setEnabled(true)} + /> + ); +} diff --git a/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx b/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx index 6dc76fdba8..5de555aa9f 100644 --- a/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx +++ b/apps/web/src/components/settings/IntegrationToolApprovalControls.tsx @@ -18,18 +18,17 @@ import { ChevronDown, ChevronRight, MessageCircleQuestionMark, - Scale, ToggleButton, type LucideIcon, } from '@/components/system'; /** * The stored choices a tool can be given. A tool nobody has made a choice - * about has no row. With Auto tool approvals on (experimental), that default - * is Auto: a row shows it as nothing pressed, pressing the selected choice - * again returns to it, and the group row also offers Auto so a whole group - * can be returned to it in one step. With Auto off, the default simply runs, - * so it shows as Always allow. + * about has no row. With Auto tool approvals available (experimental), that + * default follows each session's tool approvals mode: it shows as nothing + * pressed, and pressing the selected choice again returns to it, on a tool's + * row and on a group's. Without Auto, the default simply runs, so it shows + * as Always allow. */ type ApprovalModeOption = { mode: IntegrationToolPolicyMode; @@ -52,10 +51,6 @@ const STORED_MODES: ApprovalModeOption[] = [ }, { mode: 'reject', label: 'Disable', tooltip: 'Disable', icon: Ban }, ]; -const BULK_MODES: ApprovalModeOption[] = [ - { mode: 'allow', label: 'Auto', tooltip: 'Auto', icon: Scale }, - ...STORED_MODES, -]; /** * Per-tool approval mode: one click per tool, with the current mode visible without opening anything. @@ -66,13 +61,11 @@ function IntegrationToolApprovalModeControl({ toolName, value, disabled, - options = STORED_MODES, onChange, }: { toolName: string; value?: IntegrationToolPolicyMode; disabled?: boolean; - options?: ApprovalModeOption[]; onChange: (mode: IntegrationToolPolicyMode) => void; }) { return ( @@ -81,7 +74,7 @@ function IntegrationToolApprovalModeControl({ aria-label={`Approval mode for ${toolName}`} className="flex shrink-0 items-center" > - {options.map(({ mode, label, tooltip, icon: Icon }) => { + {STORED_MODES.map(({ mode, label, tooltip, icon: Icon }) => { const checked = mode === value; return ( @@ -93,9 +86,8 @@ function IntegrationToolApprovalModeControl({ aria-label={label} disabled={disabled} onPressedChange={() => { - // Pressing the selected choice again returns to Auto. - if (!checked) onChange(mode); - else if (mode !== 'allow') onChange('allow'); + // Pressing the selected choice again returns to the default. + onChange(checked ? 'allow' : mode); }} >
diff --git a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx index 6ceb896c15..172e208a4c 100644 --- a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx +++ b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx @@ -45,36 +45,22 @@ describe('IntegrationToolAutoModeSetting', () => { state.setAuto.mockClear(); }); - it('shows the approval switch and a link to integration tool controls', () => { + it('has no deployment-wide switch: Auto is turned on per session', () => { render(); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); expect( - screen.getByRole('switch', { name: 'Enable auto-approval' }), - ).not.toBeChecked(); - expect( - screen.getByText( - 'Let Roomote decide when something is worth interrupting for approval.', - ), + screen.getByText(/choose Auto from the tool approvals menu/), ).toBeInTheDocument(); expect( screen.getByRole('link', { name: 'Integrations page' }), ).toHaveAttribute('href', '/integrations'); - expect( - screen.queryByLabelText('Additional instructions'), - ).not.toBeInTheDocument(); }); - it('shows guidance when enabled and saves changes using the current mode', async () => { - state.settings = { mode: 'on', policy: '', model: { kind: 'judgment' } }; - render(); - expect( - screen.getByRole('switch', { name: 'Enable auto-approval' }), - ).toBeChecked(); - expect( - screen.getByLabelText('Additional instructions'), - ).toBeInTheDocument(); + it('always shows the guidance and saves it without changing the stored mode', async () => { + const { rerender } = render(); expect( screen.getByPlaceholderText( - 'Include any specific guidance for how to decide auto-approval here', + 'For example: Anything that moves money needs a person to approve it.', ), ).toBeInTheDocument(); const guidance = screen.getByLabelText('Additional instructions'); @@ -83,85 +69,49 @@ describe('IntegrationToolAutoModeSetting', () => { fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(state.setAuto).toHaveBeenLastCalledWith({ - mode: 'on', + mode: 'off', policy: 'Reads only.', }), ); - fireEvent.click( - screen.getByRole('switch', { name: 'Enable auto-approval' }), - ); - expect(state.setAuto).toHaveBeenLastCalledWith({ - mode: 'off', - policy: 'Reads only.', - }); - }); - - it('enables auto-approval through the same policy mutation', () => { - render(); - fireEvent.click( - screen.getByRole('switch', { name: 'Enable auto-approval' }), - ); - expect(state.setAuto).toHaveBeenCalledWith({ mode: 'on', policy: '' }); - }); - - it('keeps the switch disabled with no availability message when off and no hosted model exists', () => { - state.settings = { - mode: 'off', - policy: '', - model: { kind: 'helper', model: 'openai/gpt-5.6-mini' }, - }; - render(); - expect( - screen.getByRole('switch', { name: 'Enable auto-approval' }), - ).toBeDisabled(); - expect( - screen.queryByText('Auto mode isn’t available yet.'), - ).not.toBeInTheDocument(); - expect( - screen.queryByLabelText('Additional instructions'), - ).not.toBeInTheDocument(); - }); - it('keeps guidance visible and allows disabling if the hosted model becomes unavailable', () => { + // A mode saved by an earlier release is kept as it was. state.settings = { mode: 'on', policy: 'Existing guidance', model: { kind: 'judgment' }, }; - const { rerender } = render(); - state.settings = { ...state.settings, model: null }; rerender(); - const toggle = screen.getByRole('switch', { name: 'Enable auto-approval' }); - expect(toggle).toBeEnabled(); - expect(toggle).toBeChecked(); - expect( - screen.getByText('Auto mode isn’t available yet.'), - ).toBeInTheDocument(); - expect(screen.getByLabelText('Additional instructions')).toHaveValue( - 'Existing guidance', + await waitFor(() => + expect(screen.getByLabelText('Additional instructions')).toHaveValue( + 'Existing guidance', + ), ); - fireEvent.click(toggle); - expect(state.setAuto).toHaveBeenCalledWith({ - mode: 'off', - policy: 'Existing guidance', + fireEvent.change(screen.getByLabelText('Additional instructions'), { + target: { value: 'New guidance' }, }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => + expect(state.setAuto).toHaveBeenLastCalledWith({ + mode: 'on', + policy: 'New guidance', + }), + ); }); - it('only allows Auto-on when the resolved model is Jev', () => { + it('says when Auto has no hosted model to assess with', () => { + render(); + expect( + screen.queryByText('Auto mode isn’t available yet.'), + ).not.toBeInTheDocument(); + state.settings = { mode: 'off', - policy: '', + policy: 'Existing guidance', model: { kind: 'helper', model: 'openai/gpt-test' }, }; - const { rerender } = render(); - expect( - screen.getByRole('switch', { name: 'Enable auto-approval' }), - ).toBeDisabled(); - - state.settings = { mode: 'off', policy: '', model: { kind: 'judgment' } }; - rerender(); + render(); expect( - screen.getByRole('switch', { name: 'Enable auto-approval' }), - ).toBeEnabled(); + screen.getByText('Auto mode isn’t available yet.'), + ).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.tsx b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.tsx index c2c539d55e..a7110d70af 100644 --- a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.tsx +++ b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.tsx @@ -9,26 +9,24 @@ import { INTEGRATION_TOOL_AUTO_POLICY_MAX_LENGTH } from '@roomote/types'; import { useTRPC } from '@/trpc/client'; -import { Button, Label, Switch, Textarea } from '@/components/system'; +import { Button, Label, Textarea } from '@/components/system'; const COPY = { description: - 'Roomote can use a judgement model to decide whether a specific action requires approval. You can configure granular approval for each tool in each integration in the ', - switchHelp: - 'Let Roomote decide when something is worth interrupting for approval.', + 'Roomote can use a judgement model to decide whether a specific action requires approval. Every session starts in Run mode; choose Auto from the tool approvals menu in its message box. You can configure granular approval for each tool in each integration in the ', guidanceLabel: 'Additional instructions', guidanceHelp: 'Describe what your deployment considers routine or risky. Optional.', guidancePlaceholder: - 'Include any specific guidance for how to decide auto-approval here', + 'For example: Anything that moves money needs a person to approve it.', save: 'Save changes', unavailable: 'Auto mode isn’t available yet.', }; /** - * Deployment-wide Auto mode for tool approvals. Rendered on the - * Agent guidance page, only while the experiment is on. Reject is never - * affected, and Auto only runs routine calls; it asks about anything risky. + * Deployment-wide guidance for Auto tool approvals. Rendered on the Agent + * guidance page, only while the experiment is on. Auto itself is turned on + * per session, under the composer, by the session's owner. */ export function IntegrationToolAutoModeSetting() { const trpc = useTRPC(); @@ -54,9 +52,11 @@ export function IntegrationToolAutoModeSetting() { ); if (!settings.data) return null; + // The stored mode does not turn Auto on. It is saved back unchanged for + // N-1 rollback compatibility. const mode = settings.data.mode; - // On needs a hosted judgment model; the helper-model fallback would be an - // LLM call per tool call, so it is never used. + // Auto needs a hosted judgment model; the helper-model fallback would be + // an LLM call per tool call, so it is never used. const available = settings.data.model?.kind === 'judgment'; const policyDirty = policy.trim() !== settings.data.policy; @@ -69,62 +69,44 @@ export function IntegrationToolAutoModeSetting() { .

-
- - save.mutate({ mode: checked ? 'on' : 'off', policy }) - } +
+ {!available ? ( +

{COPY.unavailable}

+ ) : null} + +

{COPY.guidanceHelp}

+