From 0fa5883d0e1f503c440a35ff913025bb6f96f59c Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:50:08 +0000 Subject: [PATCH 1/3] feat: add free-text routing guidance --- apps/docs/environments/definition.mdx | 18 +- ...EnvironmentRoutingOverview.client.test.tsx | 68 +++-- .../EnvironmentRoutingOverview.tsx | 251 +++++------------- .../src/trpc/commands/environments/index.ts | 33 ++- .../__tests__/fast-agent-prompt.test.ts | 43 +-- .../__tests__/fast-agent-service.test.ts | 32 ++- .../server/fast-agent/fast-agent-prompt.ts | 46 +--- .../server/fast-agent/fast-agent-service.ts | 14 +- .../src/__tests__/command-schema.test.ts | 54 ++-- packages/types/src/workspace-routing.ts | 35 ++- 10 files changed, 275 insertions(+), 319 deletions(-) diff --git a/apps/docs/environments/definition.mdx b/apps/docs/environments/definition.mdx index a8b40594c4..86ac26fa65 100644 --- a/apps/docs/environments/definition.mdx +++ b/apps/docs/environments/definition.mdx @@ -378,14 +378,16 @@ repository has a known trap, state it plainly. ## Routing rules -Admins can configure natural-language routing rules from **Settings → -Environments**. Each rule maps a description, such as “Messages sent in the -hospital-bugs Slack channel,” to an environment or the broad **All repositories** -workspace. - -Rules are guidance rather than exact string matchers. An environment explicitly -named in a request takes precedence, and a specific rule takes precedence over a -catch-all default. +Admins can configure free-text routing guidance from **Settings → Environments**. +The same guidance can describe environment choices, model preferences, or both. +For example: “Use the Web environment for frontend work” or “Prefer Claude +Sonnet for code reviews.” Model-only guidance does not need to name an +environment. + +Routing guidance is supplemental rather than an exact string matcher. An +environment or model explicitly named in a request takes precedence. Guidance +cannot select disabled models, unavailable environments, or override Roomote's +permissions and system policies. ## MCP servers diff --git a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx index 1392a1350b..5cdf2fd1ad 100644 --- a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx @@ -1,24 +1,14 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import { EnvironmentRoutingOverview } from './EnvironmentRoutingOverview'; const mutateAsync = vi.fn(); -const environments = [{ id: 'env-1', name: 'Hospital app' }]; const routingSettings = { - rules: [ - { - description: 'Messages from hospital-bugs belong here.', - target: 'env-1', - }, - ], + guidance: 'Use Hospital app for messages from hospital-bugs.', }; vi.mock('@/hooks/environments', () => ({ - useEnvironments: () => ({ - isPending: false, - data: environments, - }), useWorkspaceRoutingSettings: () => ({ isPending: false, data: routingSettings, @@ -30,37 +20,45 @@ vi.mock('@/hooks/environments', () => ({ })); describe('EnvironmentRoutingOverview', () => { - it('does not persist a deletion when edit starts', async () => { + beforeEach(() => { + mutateAsync.mockReset(); + }); + + it('loads and saves free-text environment and model guidance', async () => { + mutateAsync.mockResolvedValue({ + guidance: 'Use Hospital app for frontend work. Prefer GPT-5.6.', + }); render(); - fireEvent.click( - screen.getByRole('button', { - name: 'Edit Messages from hospital-bugs belong here.', - }), + const textarea = screen.getByLabelText('Routing guidance'); + expect(textarea).toHaveValue( + 'Use Hospital app for messages from hospital-bugs.', ); + fireEvent.change(textarea, { + target: { value: 'Use Hospital app for frontend work. Prefer GPT-5.6.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); - expect(mutateAsync).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Rule description')).toHaveValue( - 'Messages from hospital-bugs belong here.', - ); - expect(screen.getByRole('button', { name: 'Save Rule' })).toBeEnabled(); + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + guidance: 'Use Hospital app for frontend work. Prefer GPT-5.6.', + }); + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + }); }); - it('cancels an edit without persisting changes', () => { + it('clears saved guidance', async () => { + mutateAsync.mockResolvedValue({ guidance: '' }); render(); - fireEvent.click( - screen.getByRole('button', { - name: 'Edit Messages from hospital-bugs belong here.', - }), - ); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.change(screen.getByLabelText('Routing guidance'), { + target: { value: '' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); - expect(mutateAsync).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Rule description')).toHaveValue(''); - expect(screen.getByRole('button', { name: 'Add Rule' })).toBeDisabled(); - expect( - screen.queryByRole('button', { name: 'Cancel' }), - ).not.toBeInTheDocument(); + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ guidance: '' }); + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + }); }); }); diff --git a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx index a66d58d0c8..762e51cb69 100644 --- a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx @@ -2,220 +2,95 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { - ALL_REPOSITORIES, - type WorkspaceRoutingSettings, -} from '@roomote/types'; -import { GitBranch, Pencil, Trash2 } from '@/components/system'; +import { MAX_WORKSPACE_ROUTING_GUIDANCE_LENGTH } from '@roomote/types'; import { Button, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + GitBranch, + Label, Skeleton, + Textarea, } from '@/components/system'; import { Section } from '@/components/settings'; import { - useEnvironments, useUpdateWorkspaceRoutingSettings, useWorkspaceRoutingSettings, } from '@/hooks/environments'; -const EMPTY_RULE = { description: '', target: '' }; - export function EnvironmentRoutingOverview() { - const environments = useEnvironments(); const settings = useWorkspaceRoutingSettings(); const updateSettings = useUpdateWorkspaceRoutingSettings(); - const [rules, setRules] = useState([]); - const [draftRule, setDraftRule] = useState(EMPTY_RULE); - const [editingIndex, setEditingIndex] = useState(null); + const [guidance, setGuidance] = useState(''); + const [savedGuidance, setSavedGuidance] = useState(''); useEffect(() => { - setRules(settings.data?.rules ?? []); + const nextGuidance = settings.data?.guidance ?? ''; + setGuidance(nextGuidance); + setSavedGuidance(nextGuidance); }, [settings.data]); - if (environments.isPending || settings.isPending) { + if (settings.isPending) { return (
- +
); } - const saveRules = async (nextRules: WorkspaceRoutingSettings['rules']) => { - await updateSettings.mutateAsync({ rules: nextRules }); - setRules(nextRules); - toast.success('Routing rules updated'); - }; + const isDirty = guidance !== savedGuidance; + const footer = isDirty ? ( + <> + + + + ) : undefined; return ( -
-

- Routing rules help Roomote agents pick the right environment or broad - workspace. Describe when Roomote should choose a specific environment; - the router prioritizes matching rules unless the user explicitly - overrides them. -

- -
-
- - setDraftRule((current) => ({ - ...current, - description: event.target.value.slice(0, 500), - })) - } - /> - - setDraftRule((current) => ({ ...current, target })) - } - /> -
- {editingIndex !== null && ( - - )} - -
-
- -
- Description - Target - -
- - {rules.length === 0 ? ( -

- No routing rules configured. -

- ) : ( - rules.map((rule, index) => ( -
- {rule.description} - - {rule.target === ALL_REPOSITORIES - ? 'All repositories' - : environments.data?.find( - (environment) => environment.id === rule.target, - )?.name || rule.target} - -
- - -
-
- )) - )} +
+
+

+ Describe how Roomote should choose environments and models. For + example: “Use the Web environment for frontend work” or “Prefer Claude + Sonnet for code reviews.” Explicit choices in a request always take + priority. +

+ +