Skip to content

Commit b21b2ec

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,dashboard-agent-db): keep chats someone else starts for a user out of that user's history
```ts await createChat(db, { id, organizationId, userId, createdByUserId: actingUserId }); listChats(db, { organizationId, userId }); // the owner: their own chats only listChats(db, { organizationId, userId, actingUserId }); // the acting user: the owner's chats + theirs ``` A dashboard agent chat started by someone acting on a user's behalf is no longer saved to that user's history. The chat records who created it: the owner never sees it in their history, unread count, or by id, and the user who created it sees it alongside the owner's own chats while acting as the owner. Such a session can't create watches, so the owner gets no alerts from it, and its messages don't count against the organization's message quota. Mono-RevId: b3fb58f14118f2cb507e6396d7511bfaf0a19d2b
1 parent 5488b4f commit b21b2ec

13 files changed

Lines changed: 1760 additions & 59 deletions

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { prisma } from "~/db.server";
1111
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
1212
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
1313
import { logger } from "~/services/logger.server";
14-
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
14+
import {
15+
getImpersonatorUserId,
16+
hasAdminDisplayAccess,
17+
requireUser,
18+
} from "~/services/session.server";
1519
import { tenantContext } from "~/services/tenantContext.server";
1620
import { selectAccessibleEnvironment } from "~/utils/environmentAccess";
1721
import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";
@@ -100,13 +104,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
100104

101105
// Watches are a separate switch inside the agent, default off, so the panel renders
102106
// without any watch affordance until an org gets it.
103-
const dashboardAgentWatchEnabled = hasDashboardAgentAccess
104-
? await canUseDashboardAgentWatches({
105-
userId: user.id,
106-
organizationSlug,
107-
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
108-
})
109-
: false;
107+
const dashboardAgentActingUserId = user.isImpersonating
108+
? await getImpersonatorUserId(request)
109+
: undefined;
110+
const dashboardAgentWatchEnabled =
111+
hasDashboardAgentAccess && !dashboardAgentActingUserId
112+
? await canUseDashboardAgentWatches({
113+
userId: user.id,
114+
organizationSlug,
115+
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
116+
})
117+
: false;
110118

111119
const promotedDashboardAgentPrompt = hasDashboardAgentAccess
112120
? await getPromotedDashboardAgentPrompt({
@@ -132,6 +140,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
132140
countChatsWithUnreadWork(dashboardAgentDb, {
133141
organizationId: project.organization.id,
134142
userId: user.id,
143+
actingUserId: dashboardAgentActingUserId,
135144
}),
136145
]);
137146
} catch (error) {

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
resolveAgentMessageQuota,
2828
} from "~/services/dashboardAgentQuota.server";
2929
import { logger } from "~/services/logger.server";
30-
import { requireUser } from "~/services/session.server";
30+
import { getImpersonatorUserId, requireUser } from "~/services/session.server";
3131
import { readBoundedBodyText } from "~/utils/boundedRequestBody.server";
3232
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
3333
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
@@ -72,6 +72,7 @@ function tooLarge() {
7272

7373
export async function action({ request, params }: ActionFunctionArgs) {
7474
const user = await requireUser(request);
75+
const actingUserId = user.isImpersonating ? await getImpersonatorUserId(request) : undefined;
7576
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
7677

7778
if (
@@ -121,6 +122,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
121122
!(await chatExists(dashboardAgentDb, {
122123
chatId,
123124
userId: user.id,
125+
actingUserId,
124126
organizationId: project.organizationId,
125127
}))
126128
) {
@@ -171,7 +173,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
171173

172174
// Only a real user message consumes quota; action turns were refused above.
173175
countsAgainstQuota = agentTurnCountsAgainstQuota(parsed);
174-
if (countsAgainstQuota && isDashboardAgentQuotaEnabled()) {
176+
if (countsAgainstQuota && !actingUserId && isDashboardAgentQuotaEnabled()) {
175177
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
176178
organizationId: project.organizationId,
177179
});
@@ -197,11 +199,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
197199
parsed.payload.metadata = {
198200
...pickAgentClientMetadata(parsed.payload.metadata),
199201
// Resolved per turn, server-side: off means no watch tools and no watch guidance.
200-
watchEnabled: await canUseDashboardAgentWatches({
201-
userId: user.id,
202-
organizationSlug,
203-
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
204-
}),
202+
watchEnabled:
203+
!actingUserId &&
204+
(await canUseDashboardAgentWatches({
205+
userId: user.id,
206+
organizationSlug,
207+
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
208+
})),
205209
userActorToken,
206210
apiOrigin: userApiOrigin,
207211
projectRef: project.externalRef,
@@ -230,7 +234,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
230234
const text = await upstream.text();
231235
// Charge quota only for a delivered message: a non-2xx upstream (or a throw below)
232236
// must not burn a send that never reached the agent.
233-
if (countsAgainstQuota && upstream.ok && isDashboardAgentQuotaEnabled()) {
237+
if (countsAgainstQuota && !actingUserId && upstream.ok && isDashboardAgentQuotaEnabled()) {
234238
await recordAgentMessageSent(dashboardAgentDb, {
235239
organizationId: project.organizationId,
236240
});

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts‎

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import {
6565
} from "~/services/dashboardAgentQuota.server";
6666
import { logger } from "~/services/logger.server";
6767
import { resolveTriggerUrisInOrganization } from "~/services/resolveTriggerUriInOrganization.server";
68-
import { requireUser } from "~/services/session.server";
68+
import { getImpersonatorUserId, requireUser } from "~/services/session.server";
6969
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
7070
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
7171
import { canUseDashboardAgentWatches } from "~/v3/canUseDashboardAgentWatches.server";
@@ -112,6 +112,7 @@ const ActionBody = z.object({
112112
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
113113
const user = await requireUser(request);
114114
const userId = user.id;
115+
const actingUserId = user.isImpersonating ? await getImpersonatorUserId(request) : undefined;
115116
const { organizationSlug, projectParam } = EnvironmentParamSchema.parse(params);
116117

117118
if (
@@ -142,11 +143,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
142143
if (!scoped) return json({ error: "Project not found" }, { status: 404 });
143144

144145
// No watches means no wakes: the feed isn't read at all when they're off.
145-
const watchEnabled = await canUseDashboardAgentWatches({
146-
userId,
147-
organizationSlug,
148-
orgFeatureFlags: (scoped.organization.featureFlags as Record<string, unknown>) ?? {},
149-
});
146+
const watchEnabled =
147+
!actingUserId &&
148+
(await canUseDashboardAgentWatches({
149+
userId,
150+
organizationSlug,
151+
orgFeatureFlags: (scoped.organization.featureFlags as Record<string, unknown>) ?? {},
152+
}));
150153

151154
const [feed, unreadWork] = await Promise.all([
152155
watchEnabled
@@ -160,6 +163,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
160163
countChatsWithUnreadWork(dashboardAgentDb, {
161164
organizationId: scoped.organizationId,
162165
userId,
166+
actingUserId,
163167
// The chat the panel has on screen, if any: it is being read as this is counted.
164168
excludeChatId: searchParams.get("chatId") ?? undefined,
165169
}),
@@ -177,7 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
177181
// Free for now: tell a mounted client explicitly, so it drops any cached used/limit
178182
// and cap-reached state from before the switch flipped off, instead of keeping it
179183
// until remount (a `{}` body would silently ignore and keep the stale state).
180-
if (!isDashboardAgentQuotaEnabled()) return json({ enabled: false });
184+
if (!isDashboardAgentQuotaEnabled() || actingUserId) return json({ enabled: false });
181185

182186
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
183187
organizationId: project.organizationId,
@@ -194,8 +198,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
194198
const chatId = searchParams.get("chatId");
195199
if (chatId) {
196200
const [messages, session] = await Promise.all([
197-
getChatMessages(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }),
198-
getSession(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }),
201+
getChatMessages(dashboardAgentDb, {
202+
chatId,
203+
userId,
204+
actingUserId,
205+
organizationId: project.organizationId,
206+
}),
207+
getSession(dashboardAgentDb, {
208+
chatId,
209+
userId,
210+
actingUserId,
211+
organizationId: project.organizationId,
212+
}),
199213
]);
200214
// Null is not an empty transcript: the chat is deleted or another org's, and a 200 would
201215
// read as a real, empty chat.
@@ -221,13 +235,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
221235
const chats = await listChats(dashboardAgentDb, {
222236
organizationId: project.organizationId,
223237
userId,
238+
actingUserId,
224239
});
225240

226-
const watchEnabled = await canUseDashboardAgentWatches({
227-
userId,
228-
organizationSlug,
229-
orgFeatureFlags,
230-
});
241+
const watchEnabled =
242+
!actingUserId &&
243+
(await canUseDashboardAgentWatches({
244+
userId,
245+
organizationSlug,
246+
orgFeatureFlags,
247+
}));
231248

232249
// One query each for all the listed chats, never one per row. With watches off the
233250
// history carries none, so an org that loses the flag stops seeing its old ones.
@@ -254,6 +271,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
254271
listChatIdsWithOpenInvestigations(dashboardAgentDb, {
255272
organizationId: project.organizationId,
256273
userId,
274+
actingUserId,
257275
}),
258276
]);
259277

@@ -285,6 +303,7 @@ function messageTooLarge() {
285303
export const action = async ({ request, params }: ActionFunctionArgs) => {
286304
const user = await requireUser(request);
287305
const userId = user.id;
306+
const actingUserId = user.isImpersonating ? await getImpersonatorUserId(request) : undefined;
288307
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
289308

290309
if (
@@ -337,9 +356,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
337356

338357
wellFormMessageText(firstMessage.parts);
339358

340-
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
341-
organizationId: project.organizationId,
342-
});
359+
const quota = actingUserId
360+
? null
361+
: await resolveAgentMessageQuota(dashboardAgentDb, {
362+
organizationId: project.organizationId,
363+
});
343364
if (quota?.reached) {
344365
return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 });
345366
}
@@ -356,11 +377,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
356377
const clientContext = pickAgentClientMetadata(clientData);
357378
// Server-resolved, never sent by the browser: off means the turn gets no watch
358379
// tools and no watch guidance.
359-
const watchEnabled = await canUseDashboardAgentWatches({
360-
userId,
361-
organizationSlug,
362-
orgFeatureFlags,
363-
});
380+
const watchEnabled =
381+
!actingUserId &&
382+
(await canUseDashboardAgentWatches({
383+
userId,
384+
organizationSlug,
385+
orgFeatureFlags,
386+
}));
364387

365388
// Membership-scoped: dev rows are per-developer, so a token must never be minted for
366389
// someone else's environment — or, when nothing resolves, for no environment at all.
@@ -411,6 +434,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
411434
id: chatId,
412435
organizationId: project.organizationId,
413436
userId,
437+
...(actingUserId ? { createdByUserId: actingUserId } : {}),
414438
...(clientData ? { metadata: { context: clientContext } } : {}),
415439
});
416440

@@ -452,6 +476,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
452476
await softDeleteChat(dashboardAgentDb, {
453477
chatId,
454478
userId,
479+
actingUserId,
455480
organizationId: project.organizationId,
456481
}).catch((cleanupError) => {
457482
logger.error("Failed to remove a dashboard agent chat whose start failed", {
@@ -465,7 +490,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
465490

466491
// Only the head start dispatches the first message here; a cold start sends it through
467492
// the `in` proxy, which counts it there. Counting both would double-count.
468-
if (headStarted) {
493+
if (headStarted && !actingUserId) {
469494
await recordAgentMessageSent(dashboardAgentDb, {
470495
organizationId: project.organizationId,
471496
});
@@ -557,7 +582,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
557582
// The configuration card's submit path. The environment comes from the URL and goes
558583
// through the same re-authorization a background tick passes, never from the body.
559584
if (parsed.data.intent === "watch-create") {
560-
if (!(await canUseDashboardAgentWatches({ userId, organizationSlug, orgFeatureFlags }))) {
585+
if (
586+
actingUserId ||
587+
!(await canUseDashboardAgentWatches({ userId, organizationSlug, orgFeatureFlags }))
588+
) {
561589
return json({ error: "Not found" }, { status: 404 });
562590
}
563591

@@ -597,6 +625,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
597625
!(await chatExists(dashboardAgentDb, {
598626
chatId: targetChatId,
599627
userId,
628+
actingUserId,
600629
organizationId: project.organizationId,
601630
}))
602631
) {
@@ -646,6 +675,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
646675
!(await chatExists(dashboardAgentDb, {
647676
chatId,
648677
userId,
678+
actingUserId,
649679
organizationId: project.organizationId,
650680
}))
651681
) {
@@ -679,11 +709,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
679709
...pickAgentClientMetadata(clientData),
680710
// Server-resolved, like every other field here: the resumed run's first turn
681711
// gets no watch tools while the flag is off.
682-
watchEnabled: await canUseDashboardAgentWatches({
683-
userId,
684-
organizationSlug,
685-
orgFeatureFlags,
686-
}),
712+
watchEnabled:
713+
!actingUserId &&
714+
(await canUseDashboardAgentWatches({
715+
userId,
716+
organizationSlug,
717+
orgFeatureFlags,
718+
})),
687719
organizationId: project.organizationId,
688720
userId,
689721
projectId: project.id,
@@ -728,6 +760,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
728760
!(await chatExists(dashboardAgentDb, {
729761
chatId,
730762
userId,
763+
actingUserId,
731764
organizationId: project.organizationId,
732765
}))
733766
) {
@@ -741,6 +774,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
741774
await renameChat(dashboardAgentDb, {
742775
chatId,
743776
userId,
777+
actingUserId,
744778
organizationId: project.organizationId,
745779
title: parsed.data.title,
746780
});
@@ -751,6 +785,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
751785
await setChatPinned(dashboardAgentDb, {
752786
chatId,
753787
userId,
788+
actingUserId,
754789
organizationId: project.organizationId,
755790
pinned: parsed.data.pinned === "true",
756791
});
@@ -762,6 +797,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
762797
await markChatRead(dashboardAgentDb, {
763798
chatId,
764799
userId,
800+
actingUserId,
765801
organizationId: project.organizationId,
766802
});
767803
return json({ ok: true });
@@ -774,6 +810,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
774810
!(await chatExists(dashboardAgentDb, {
775811
chatId,
776812
userId,
813+
actingUserId,
777814
organizationId: project.organizationId,
778815
}))
779816
) {
@@ -783,6 +820,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
783820
const { cancelledWatches } = await deleteChatWithWatches({
784821
chatId,
785822
userId,
823+
actingUserId,
786824
organizationId: project.organizationId,
787825
});
788826
return json({ ok: true, cancelledWatches });
@@ -803,6 +841,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
803841
!(await chatExists(dashboardAgentDb, {
804842
chatId,
805843
userId,
844+
actingUserId,
806845
organizationId: project.organizationId,
807846
}))
808847
) {

‎apps/webapp/app/services/dashboardAgentWatches.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,7 @@ export async function deleteChatWithWatches(params: {
12331233
chatId: string;
12341234
userId: string;
12351235
organizationId: string;
1236+
actingUserId?: string;
12361237
}): Promise<{ deleted: boolean; cancelledWatches: number }> {
12371238
const result = await softDeleteChat(dashboardAgentDb, params);
12381239
return { deleted: result.deleted, cancelledWatches: result.cancelledWatches.length };

0 commit comments

Comments
 (0)