diff --git a/apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts b/apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts new file mode 100644 index 0000000000..f554c50a2e --- /dev/null +++ b/apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts @@ -0,0 +1,158 @@ +import { TaskPayloadKind } from '@roomote/types'; +import { Hono } from 'hono'; + +import type { Variables } from '../../../types'; +import type { McpAuth } from '../../mcp/middleware'; + +const mocks = vi.hoisted(() => ({ + enqueueUpdate: vi.fn(), + limit: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + enqueueUserPersonalizationUpdate: mocks.enqueueUpdate, +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn(() => ({ limit: mocks.limit })), + })), + })), + })), + }, + eq: vi.fn(), + taskRuns: { + actingUserId: 'actingUserId', + id: 'id', + payloadKind: 'payloadKind', + taskId: 'taskId', + }, + tasks: { id: 'id', initiatorKind: 'initiatorKind' }, +})); + +import { + canLearnPersonalizationForRun, + updatePersonalization, +} from '../updatePersonalization'; + +function createApp() { + const app = new Hono<{ Variables: Variables & { mcpAuth: McpAuth } }>(); + app.use('*', async (c, next) => { + c.set('mcpAuth', { + userId: undefined, + authContext: { runId: 42 } as never, + }); + await next(); + }); + app.post('/tasks/runs/:runId/personalization', updatePersonalization); + return app; +} + +function postUpdate(app: ReturnType) { + return app.request('/tasks/runs/42/personalization', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + preference: 'Be concise.', + confidence: 'explicit', + }), + }); +} + +describe('personalization learning run authorization', () => { + it.each([ + TaskPayloadKind.StandardTask, + TaskPayloadKind.SlackAppMention, + TaskPayloadKind.LinearAgentSession, + TaskPayloadKind.SnapshotResume, + TaskPayloadKind.GithubPrReview, + ])('allows a trusted user actor on %s', (payloadKind) => { + expect( + canLearnPersonalizationForRun({ + actingUserId: 'current-user', + initiatorKind: 'user', + payloadKind, + }), + ).toBe(true); + }); + + it.each([ + { + actingUserId: 'service-user', + initiatorKind: 'automation', + payloadKind: TaskPayloadKind.StandardTask, + }, + { + actingUserId: 'user-1', + initiatorKind: 'user', + payloadKind: TaskPayloadKind.Scan, + }, + { + actingUserId: null, + initiatorKind: 'user', + payloadKind: TaskPayloadKind.SnapshotResume, + }, + ])('denies automation, maintenance, and actorless runs', (run) => { + expect(canLearnPersonalizationForRun(run)).toBe(false); + }); +}); + +describe('updatePersonalization', () => { + beforeEach(() => { + mocks.enqueueUpdate.mockReset(); + mocks.limit.mockReset(); + mocks.limit.mockResolvedValue([ + { + actingUserId: 'user-1', + initiatorKind: 'user', + payloadKind: TaskPayloadKind.StandardTask, + }, + ]); + }); + + it('returns success only after persistence succeeds', async () => { + mocks.enqueueUpdate.mockResolvedValue({ saved: true }); + + const response = await postUpdate(createApp()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ saved: true }); + expect(mocks.enqueueUpdate).toHaveBeenCalledWith({ + userId: 'user-1', + preference: 'Be concise.', + confidence: 'explicit', + taskId: '42', + }); + }); + + it('returns the opt-out result without confirming a save', async () => { + mocks.enqueueUpdate.mockResolvedValue({ + saved: false, + reason: 'disabled', + }); + + const response = await postUpdate(createApp()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + saved: false, + reason: 'disabled', + }); + }); + + it('returns an error when persistence fails', async () => { + mocks.enqueueUpdate.mockRejectedValue(new Error('db unavailable')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await postUpdate(createApp()); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: 'Failed to update personalization', + }); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/api/src/handlers/tasks/index.ts b/apps/api/src/handlers/tasks/index.ts index 603ec42d7a..66fd71e500 100644 --- a/apps/api/src/handlers/tasks/index.ts +++ b/apps/api/src/handlers/tasks/index.ts @@ -20,6 +20,7 @@ import { updateTaskModelSelection } from './updateModelSelection'; import { listTaskModels } from './listModels'; import { getGoal, manageGoal } from './manageGoal'; import { saveTaskMemory } from './saveTaskMemory'; +import { updatePersonalization } from './updatePersonalization'; export const tasksRouter = new Hono<{ Variables: Variables }>(); @@ -43,3 +44,4 @@ tasksRouter.post('/:taskId/task_suggestions', submitTaskSuggestions); tasksRouter.post('/:taskId/mcp_recommendations', submitMcpRecommendations); tasksRouter.post('/runs/:runId/goal', manageGoal); tasksRouter.post('/runs/:runId/memory', saveTaskMemory); +tasksRouter.post('/runs/:runId/personalization', updatePersonalization); diff --git a/apps/api/src/handlers/tasks/updatePersonalization.ts b/apps/api/src/handlers/tasks/updatePersonalization.ts new file mode 100644 index 0000000000..564d4ea17f --- /dev/null +++ b/apps/api/src/handlers/tasks/updatePersonalization.ts @@ -0,0 +1,85 @@ +import type { Context } from 'hono'; +import { z } from 'zod'; + +import { db, eq, taskRuns, tasks } from '@roomote/db/server'; +import { enqueueUserPersonalizationUpdate } from '@roomote/cloud-agents/server'; +import { TaskPayloadKind } from '@roomote/types'; + +import type { Variables } from '../../types'; +import type { McpAuth } from '../mcp/middleware'; +import { isRunTokenContext } from '../mcp/proxy-utils'; +import { logHandlerError } from '../utils'; + +const inputSchema = z.object({ + preference: z.string().trim().min(1).max(500), + confidence: z.enum(['explicit', 'inferred']), +}); + +const EXCLUDED_PERSONALIZATION_PAYLOADS = new Set([ + TaskPayloadKind.Scan, + TaskPayloadKind.McpRecommendations, + TaskPayloadKind.SnapshotEnvironment, +]); + +export function canLearnPersonalizationForRun(run: { + actingUserId: string | null; + initiatorKind: string; + payloadKind: TaskPayloadKind; +}): run is typeof run & { actingUserId: string } { + return ( + Boolean(run.actingUserId) && + run.initiatorKind === 'user' && + !EXCLUDED_PERSONALIZATION_PAYLOADS.has(run.payloadKind) + ); +} + +export async function updatePersonalization( + c: Context<{ Variables: Variables & { mcpAuth: McpAuth } }>, +): Promise { + const auth = c.get('mcpAuth').authContext; + if (!isRunTokenContext(auth)) { + return c.json( + { error: 'Personalization updates require a task run token' }, + 403, + ); + } + + const runId = Number(c.req.param('runId')); + if (!Number.isInteger(runId) || runId <= 0 || auth.runId !== runId) { + return c.json( + { error: 'Task run token does not match requested task run' }, + 403, + ); + } + + const parsed = inputSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) + return c.json({ error: 'Invalid personalization update' }, 400); + + try { + const [run] = await db + .select({ + actingUserId: taskRuns.actingUserId, + payloadKind: taskRuns.payloadKind, + initiatorKind: tasks.initiatorKind, + }) + .from(taskRuns) + .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) + .where(eq(taskRuns.id, runId)) + .limit(1); + + if (!run || !canLearnPersonalizationForRun(run)) { + return c.json({ saved: false, reason: 'not_human_initiated' }, 200); + } + + const result = await enqueueUserPersonalizationUpdate({ + userId: run.actingUserId, + ...parsed.data, + taskId: String(runId), + }); + return c.json(result, 200); + } catch (error) { + logHandlerError('updatePersonalization', error); + return c.json({ error: 'Failed to update personalization' }, 500); + } +} diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index 9f882f649f..0a35a179aa 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -65,6 +65,8 @@ Personal Settings also include app preferences such as: you can still collapse or expand individual thought messages - **Narration Mode** for a more streamlined task conversation view - **Therapist Mode** to explain which remembered fact informed a Session or task +- **How to work with me** for private personal instructions and conversational + learning Most teammates only need profile, linked accounts, and theme settings. @@ -76,6 +78,33 @@ that remembered fact in human terms and explains how it was used. It does not narrate unrelated retrievals or expose internal memory identifiers, storage paths, metadata, or provenance fields. +## Personalize how Roomote works with you + +Use **How to work with me** for durable preferences such as your preferred +name, language, formality, answer length, use of examples, directness, update +frequency, or accessibility and presentation preferences you choose to share. +These instructions apply to conversations with you; they do not silently +rewrite code or customer-facing deliverables. + +**Learn from conversations** is on by default. Roomote may save a preference +you state explicitly and may form modest, revisable style preferences from +repeated behavior. Turning learning off stops new automatic updates but keeps +the instructions already shown in the text box active. **Reset** clears both +edited and learned instructions. A reset does not rebuild them from older +conversation history. + +Personalization follows the trusted account associated with the current +speaker on supported chat surfaces and with the person who requested a task. +In a shared thread, Roomote adapts its conversation to the current speaker +while preserving the original requester's task requirements when they conflict. + +Personalization is encrypted at rest and is not available to other members or +admins through Roomote's normal UI or API. It is excluded from shared task +transcripts, summaries, memory, and ordinary tool output. It remains subject to +the deployment operator's infrastructure access, backups, and retention +policies. Roomote does not use public-web or LinkedIn enrichment for this +feature. + ## Common issues - **A linked account is missing.** Ask an admin to enable the integration from diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts index f7d13ac108..d5a8c7efca 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts @@ -69,6 +69,7 @@ describe('tool presentation resolver', () => { ['manage_source_control', 'pull-request'], ['manage_environments', 'environment'], ['save_task_memory', 'memory'], + ['update_personalization', 'book-heart'], ['request_environment_variables', 'terminal'], ['report_platform_issue', 'alert'], ['submit_automation_work_items', 'task'], @@ -702,6 +703,28 @@ describe('tool presentation policy', () => { } }); + it('renders personalization updates as a standalone non-expandable receipt', () => { + expect( + resolveToolPresentation( + toolData({ toolName: 'update_personalization', status: 'completed' }), + ), + ).toMatchObject({ + verb: 'Personalization', + object: 'updated', + iconKey: 'book-heart', + }); + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'update_personalization' }), + ), + ).toMatchObject({ + rowVisibility: 'visible', + detailMode: 'none', + activityMode: 'keep-visible', + groupingMode: 'standalone', + }); + }); + it.each([ ['in_progress', 'Sending'], ['completed', 'Sent'], diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts index bef887d840..05ba3e1709 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts @@ -3,6 +3,7 @@ import type { LucideProps } from 'lucide-react'; import { type LucideIcon, + BookHeart, BookOpenText, BrandIcon, Bot, @@ -40,6 +41,7 @@ export function toolIconForKey(key: ToolIconKey): LucideIcon { if (key === 'task') return Zap; if (key === 'message') return MessageSquareText; if (key === 'memory') return BookOpenText; + if (key === 'book-heart') return BookHeart; if (key === 'artifact') return HardDriveUpload; if (key === 'widget') return GalleryVerticalEnd; if (key === 'roomote') return RoomoteR; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts index e47242b2a8..f640e5a15e 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts @@ -68,6 +68,8 @@ export function resolveToolPresentationPolicy( const consequentialReceipt = presentation.identity.toolName !== null && CONSEQUENTIAL_RECEIPTS.has(presentation.identity.toolName); + const isPersonalizationReceipt = + presentation.identity.toolName === 'update_personalization'; const keepConsequentialReceiptVisible = consequentialReceipt && (presentation.identity.toolName !== 'send_chat_reply' || @@ -94,8 +96,9 @@ export function resolveToolPresentationPolicy( rowVisibility = 'hidden'; } - const detailMode: ResolvedToolPolicy['detailMode'] = - isSubagentToolMessage(msg) && hasSubagentSummary(msg) + const detailMode: ResolvedToolPolicy['detailMode'] = isPersonalizationReceipt + ? 'none' + : isSubagentToolMessage(msg) && hasSubagentSummary(msg) ? 'expandable' : hasPreview ? 'preview' @@ -113,6 +116,7 @@ export function resolveToolPresentationPolicy( detailMode, activityMode: isRunning || + isPersonalizationReceipt || hasPreview || isArtifact || renderAs === 'delegated-task-card' || @@ -121,6 +125,7 @@ export function resolveToolPresentationPolicy( : 'collapsible', renderAs, groupingMode: + isPersonalizationReceipt || hasPreview || isArtifact || renderAs === 'delegated-task-card' || diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts index 050e4ab0af..0f2c1dbabc 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -32,6 +32,7 @@ export type ToolIconKey = | 'task' | 'message' | 'memory' + | 'book-heart' | 'artifact' | 'widget' | 'roomote' @@ -117,6 +118,7 @@ const TOOL_ICON_OVERRIDES: Readonly>> = { manage_source_control: 'pull-request', manage_environments: 'environment', save_task_memory: 'memory', + update_personalization: 'book-heart', request_environment_variables: 'terminal', report_platform_issue: 'alert', submit_automation_work_items: 'task', @@ -356,6 +358,11 @@ function resolveReceiptLanguage( verb: byPhase('Sending', 'Sent', 'Failed to Send'), object: 'chat reply', }; + if (toolName === 'update_personalization') + return { + verb: byPhase('Updating', 'Personalization', 'Failed to update'), + object: byPhase('personalization', 'updated', 'personalization'), + }; if (toolName === 'request_user_input') return { verb: byPhase('Asking for', 'Asked for', 'Failed to Ask for'), diff --git a/apps/web/src/components/layout/WorkspaceHeader.tsx b/apps/web/src/components/layout/WorkspaceHeader.tsx index 93fd3950d8..0d47d1904c 100644 --- a/apps/web/src/components/layout/WorkspaceHeader.tsx +++ b/apps/web/src/components/layout/WorkspaceHeader.tsx @@ -1,3 +1,5 @@ +import { Children, cloneElement, isValidElement } from 'react'; + import { cn } from '@/lib/utils'; type WorkspaceHeaderProps = React.ComponentProps<'header'> & { @@ -12,6 +14,14 @@ export function WorkspaceHeader({ actions, ...props }: WorkspaceHeaderProps) { + const headerChildren = Children.toArray(children) + .concat(Children.toArray(actions)) + .map((child, index) => + isValidElement(child) + ? cloneElement(child, { key: `workspace-header-child-${index}` }) + : child, + ); + return (
- {children} - {actions} + {headerChildren}
); diff --git a/apps/web/src/components/layout/WorkspaceSurface.test.tsx b/apps/web/src/components/layout/WorkspaceSurface.test.tsx index 343d5fbd66..526eae89e2 100644 --- a/apps/web/src/components/layout/WorkspaceSurface.test.tsx +++ b/apps/web/src/components/layout/WorkspaceSurface.test.tsx @@ -17,7 +17,7 @@ describe('Workspace detail layout', () => { it('renders arbitrary task or session header content in the shared header', () => { render( - + Actions}>

Conversation title

Context
, @@ -27,5 +27,6 @@ describe('Workspace detail layout', () => { screen.getByRole('heading', { name: 'Conversation title' }), ).toBeInTheDocument(); expect(screen.getByText('Context')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/settings/PersonalizationSection.test.tsx b/apps/web/src/components/settings/PersonalizationSection.test.tsx new file mode 100644 index 0000000000..c6548537a8 --- /dev/null +++ b/apps/web/src/components/settings/PersonalizationSection.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +const mocks = vi.hoisted(() => ({ + mutate: vi.fn(), + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + settings: { + instructions: 'Be concise.', + learnFromConversations: true, + version: 3, + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(() => ({ + data: mocks.settings, + isPending: false, + })), + useMutation: vi.fn(() => ({ mutate: mocks.mutate, isPending: false })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: mocks.invalidateQueries, + setQueryData: mocks.setQueryData, + })), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + preferences: { + getPersonalization: { + queryKey: () => ['personalization'], + queryOptions: () => ({}), + }, + updatePersonalization: { + mutationOptions: (options: unknown) => options, + }, + }, + }), +})); + +import { PersonalizationSection } from './PersonalizationSection'; + +describe('PersonalizationSection', () => { + beforeEach(() => mocks.mutate.mockClear()); + + it('saves an edited blob with its concurrency version', () => { + render(); + + fireEvent.change( + screen.getByLabelText( + 'Things Roomote should always know about you to be more useful. Not shared with others.', + ), + { + target: { value: 'Lead with a recommendation.' }, + }, + ); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(mocks.mutate).toHaveBeenCalledWith( + { + expectedVersion: 3, + instructions: 'Lead with a recommendation.', + }, + expect.any(Object), + ); + }); + + it('resets every saved preference through the versioned mutation', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + + expect(mocks.mutate).toHaveBeenCalledWith( + { expectedVersion: 3, reset: true }, + expect.any(Object), + ); + }); + + it('updates learning separately and leaves the blob untouched', () => { + render(); + + fireEvent.click( + screen.getByRole('switch', { name: 'Learn from conversations' }), + ); + + expect(mocks.mutate).toHaveBeenCalledWith({ + expectedVersion: 3, + learnFromConversations: false, + }); + }); +}); diff --git a/apps/web/src/components/settings/PersonalizationSection.tsx b/apps/web/src/components/settings/PersonalizationSection.tsx new file mode 100644 index 0000000000..29d0cecb78 --- /dev/null +++ b/apps/web/src/components/settings/PersonalizationSection.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { + Button, + Check, + Label, + RotateCcw, + Sparkles, + Switch, + Textarea, +} from '@/components/system'; +import { useTRPC } from '@/trpc/client'; +import type { UserPersonalizationSettings } from '@/types/preferences'; + +import { Section } from './Section'; + +const MAX_INSTRUCTIONS_LENGTH = 2_000; + +export function PersonalizationSection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const queryKey = trpc.preferences.getPersonalization.queryKey(); + const personalization = useQuery( + trpc.preferences.getPersonalization.queryOptions(), + ); + const [instructions, setInstructions] = useState(''); + + useEffect(() => { + if (personalization.data) { + setInstructions(personalization.data.instructions); + } + }, [personalization.data]); + + const update = useMutation( + trpc.preferences.updatePersonalization.mutationOptions({ + onSuccess: (settings) => { + queryClient.setQueryData( + queryKey, + settings, + ); + setInstructions(settings.instructions); + }, + onError: (error) => { + toast.error(error.message); + void queryClient.invalidateQueries({ queryKey }); + }, + }), + ); + + const settings = personalization.data; + const isBusy = personalization.isPending || update.isPending; + const hasChanges = settings?.instructions !== instructions; + + return ( +
+
+
+ +