diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 247da4a234..840e5a506b 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -23,6 +23,12 @@ When a task in a session opens a pull request, that pull request belongs to the session. A mention on it joins the session instead of starting a new one, and Roomote answers on the pull request as well as in the session's home surface. +The sidebar's **Recent sessions** keeps your personal sessions in reach and +excludes sessions created by automations, including custom automation runs. +Automation sessions remain available in the main sessions list and automation +history under the same access permissions. Opening or participating in an +automation session does not add it to the sidebar's recent sessions. + ## Start a Session from the dashboard On the home page, leave the workspace unselected to start a conversation. diff --git a/apps/web/src/components/layout/side-nav/RecentSessions.client.test.tsx b/apps/web/src/components/layout/side-nav/RecentSessions.client.test.tsx index dbdd855f2b..6ea2c7a008 100644 --- a/apps/web/src/components/layout/side-nav/RecentSessions.client.test.tsx +++ b/apps/web/src/components/layout/side-nav/RecentSessions.client.test.tsx @@ -111,7 +111,11 @@ describe('RecentSessions', () => { , ); - expect(listInput.value).toEqual({ ownedOnly: true, limit: 20 }); + expect(listInput.value).toEqual({ + ownedOnly: true, + excludeAutomations: true, + limit: 20, + }); expect(listOptions.value).toEqual( expect.objectContaining({ enabled: true }), ); diff --git a/apps/web/src/components/layout/side-nav/RecentSessions.tsx b/apps/web/src/components/layout/side-nav/RecentSessions.tsx index 88f0cd522d..29b262a066 100644 --- a/apps/web/src/components/layout/side-nav/RecentSessions.tsx +++ b/apps/web/src/components/layout/side-nav/RecentSessions.tsx @@ -23,7 +23,11 @@ export function RecentSessions({ enabled }: { enabled: boolean }) { const trpc = useTRPC(); const { data, isError, isFetching, refetch } = useQuery( trpc.sessions.list.queryOptions( - { ownedOnly: true, limit: RECENT_SESSIONS_LIMIT }, + { + ownedOnly: true, + excludeAutomations: true, + limit: RECENT_SESSIONS_LIMIT, + }, { enabled, placeholderData: keepPreviousData, diff --git a/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx index 3a0794701a..03617a11da 100644 --- a/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx +++ b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx @@ -353,7 +353,7 @@ describe('SideNav recent sessions', () => { expect.objectContaining({ enabled: true }), ); expect(sessionsQueryOptionsMock).toHaveBeenCalledWith( - { ownedOnly: true, limit: 20 }, + { ownedOnly: true, excludeAutomations: true, limit: 20 }, expect.objectContaining({ enabled: true }), ); }); @@ -442,7 +442,7 @@ describe('SideNav recent sessions', () => { expect.objectContaining({ enabled: false }), ); expect(sessionsQueryOptionsMock).toHaveBeenCalledWith( - { ownedOnly: true, limit: 20 }, + { ownedOnly: true, excludeAutomations: true, limit: 20 }, expect.objectContaining({ enabled: false }), ); }); diff --git a/apps/web/src/lib/server/sessions.test.ts b/apps/web/src/lib/server/sessions.test.ts index d10f685f2b..dad556c020 100644 --- a/apps/web/src/lib/server/sessions.test.ts +++ b/apps/web/src/lib/server/sessions.test.ts @@ -253,6 +253,15 @@ describe('unified Session queries', () => { }) ).sessions.map((row) => row.id), ).toEqual([session.id]); + expect( + ( + await getSessions(ownerAuth, { + ids: [session.id], + ownedOnly: true, + excludeAutomations: true, + }) + ).sessions, + ).toEqual([]); expect( (await getSessions(otherAuth, { ids: [session.id] })).sessions, ).toEqual([]); diff --git a/apps/web/src/lib/server/sessions.ts b/apps/web/src/lib/server/sessions.ts index 782acc2735..02a5af92b6 100644 --- a/apps/web/src/lib/server/sessions.ts +++ b/apps/web/src/lib/server/sessions.ts @@ -21,6 +21,7 @@ import { like, llmUsageEvents, lt, + ne, or, privateSessionAccess, repositories, @@ -76,6 +77,7 @@ type SessionListInput = { q?: string | null; ids?: string[]; ownedOnly?: boolean; + excludeAutomations?: boolean; includeJudgedStatus?: boolean; before?: string | null; limit?: number; @@ -525,6 +527,7 @@ function listConditions( return and( sessionListScope(auth), input.ownedOnly ? sessionOwnerScope(auth) : undefined, + input.excludeAutomations ? ne(sessions.ownerKind, 'automation') : undefined, eq(sessions.visibility, 'visible'), archive === 'archived' ? isNotNull(sessions.archivedAt) diff --git a/apps/web/src/trpc/commands/sessions/index.ts b/apps/web/src/trpc/commands/sessions/index.ts index 74280af3ac..697a0d5361 100644 --- a/apps/web/src/trpc/commands/sessions/index.ts +++ b/apps/web/src/trpc/commands/sessions/index.ts @@ -563,6 +563,7 @@ export const sessionsListInputSchema = z.object({ q: z.string().max(200).nullish(), ids: z.array(z.string().uuid()).max(20).optional(), ownedOnly: z.boolean().optional(), + excludeAutomations: z.boolean().optional(), before: z.string().nullish(), limit: z.number().int().min(1).max(200).optional(), }); diff --git a/apps/web/src/trpc/routers/sessions.test.ts b/apps/web/src/trpc/routers/sessions.test.ts index 2c6391a428..aeeadbca71 100644 --- a/apps/web/src/trpc/routers/sessions.test.ts +++ b/apps/web/src/trpc/routers/sessions.test.ts @@ -1,12 +1,18 @@ import { + automations, db, eq, + ensureSessionForFastConversation, + ensureSessionForTask, + fastAgentConversations, + inArray, sessionFactory, sessions, sessionTasks, taskFactory, tasks, userFactory, + users, } from '@roomote/db/server'; import type { UserAuthSuccess } from '@/types'; import { appRouter } from './_app'; @@ -21,6 +27,124 @@ function auth(userId: string, isAdmin = false) { } describe('private Session procedures', () => { + it('excludes automation origins before pagination only when requested', async () => { + const owner = await userFactory.create(); + const other = await userFactory.create(); + await db + .insert(automations) + .values({ key: 'suggester' }) + .onConflictDoNothing(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + ownerAutomation: 'suggester', + surface: 'automation', + workspaceId: crypto.randomUUID(), + conversationId: crypto.randomUUID(), + }) + .returning(); + const fastSession = await ensureSessionForFastConversation( + db, + conversation!.id, + ); + const automationTask = await taskFactory.create({ + initiatorKind: 'automation', + initiatorAutomation: 'suggester', + }); + const taskSession = await ensureSessionForTask(db, { + taskId: automationTask.id, + }); + const humanSessions = await Promise.all( + [300, 200, 100].map((activityAt) => + sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + title: 'Automation report', + sourceSurface: 'github', + sourceTrigger: 'webhook', + activityAt, + }), + ), + ); + const privateSession = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: other.id, + privacy: 'private', + privateOwnerUserId: other.id, + }); + // An automation task attached later must not reclassify a human origin. + const attachedTask = await taskFactory.create({ + initiatorKind: 'automation', + initiatorAutomation: 'suggester', + }); + await db.insert(sessionTasks).values({ + sessionId: humanSessions[0]!.id, + taskId: attachedTask.id, + origin: 'direct_launch', + }); + const ids = [ + fastSession.id, + taskSession.id, + privateSession.id, + ...humanSessions.map((s) => s.id), + ]; + const caller = appRouter.createCaller({ auth: auth(owner.id, true) }); + try { + expect(fastSession.ownerKind).toBe('automation'); + expect(taskSession.ownerKind).toBe('automation'); + const defaults = await caller.sessions.list({ ids }); + expect(defaults.sessions.map((s) => s.id)).toEqual([ + expect.any(String), + expect.any(String), + ...humanSessions.map((s) => s.id), + ]); + expect(defaults.sessions.map((s) => s.id)).toEqual( + expect.arrayContaining([fastSession.id, taskSession.id]), + ); + expect( + ( + await caller.sessions.list({ ids, excludeAutomations: false }) + ).sessions.map((s) => s.id), + ).toEqual(defaults.sessions.map((s) => s.id)); + + let before: string | null = null; + for (const [index, human] of humanSessions.entries()) { + const page = await caller.sessions.list({ + ids, + excludeAutomations: true, + limit: 1, + before, + }); + expect(page.sessions.map((s) => s.id)).toEqual([human.id]); + expect(page.nextCursor === null).toBe( + index === humanSessions.length - 1, + ); + before = page.nextCursor; + } + const owned = await caller.sessions.list({ + ids, + ownedOnly: true, + excludeAutomations: true, + }); + expect(owned.sessions.map((s) => s.id)).toEqual( + humanSessions.map((s) => s.id), + ); + const history = await caller.sessions.list({ ids, scope: 'automations' }); + expect(history.sessions.map((s) => s.id)).toEqual( + expect.arrayContaining([fastSession.id, taskSession.id]), + ); + } finally { + await db.delete(sessions).where(inArray(sessions.id, ids)); + await db + .delete(tasks) + .where(inArray(tasks.id, [automationTask.id, attachedTask.id])); + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, conversation!.id)); + await db.delete(users).where(inArray(users.id, [owner.id, other.id])); + } + }); + it('keeps task lookup, rename, and unarchive owner-only', async () => { const owner = await userFactory.create(); const other = await userFactory.create();