Skip to content

Commit 08f9d78

Browse files
committed
merge: propagate review-comment fixes from test/chat-agent-durability-tri-11166
2 parents bb381d3 + f06235c commit 08f9d78

17 files changed

Lines changed: 310 additions & 29 deletions

.changeset/chat-truncated-turn-error.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
---
44

55
Chat in the browser now shows an error when a reply is cut off by a lost connection that can't be re-established, instead of presenting the partial reply as if it had finished.
6+
7+
Watch-mode viewers now keep receiving later turns instead of the stream closing after the first turn completes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Deleting a dashboard agent chat is now scoped to your organization, so a chat can only be removed from within the org it belongs to.

apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ export function DashboardAgentPanel({
199199
if (!res.ok && res.status !== 404) {
200200
console.error(`Dashboard agent: failed to open chat ${id} (${res.status})`);
201201
toast.error("We couldn't open that chat. Try again in a moment.");
202+
// Transient failure: keep the stored pointer so the chat can be reopened.
203+
if (seq === openChatRequestSeq.current) setActive(null);
204+
return;
202205
}
203206
const data = res.ok ? ((await res.json()) as OpenedChatResponse) : undefined;
204207
if (seq !== openChatRequestSeq.current) return;

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
384384
// handover was dispatched and no message was sent: a session the call did create in
385385
// spite of the error idles out having done nothing. The empty row is all there is to undo.
386386
// Swallowed so the start's own error is what surfaces and gets logged.
387-
await softDeleteChat(dashboardAgentDb, { chatId, userId }).catch((cleanupError) => {
387+
await softDeleteChat(dashboardAgentDb, {
388+
chatId,
389+
userId,
390+
organizationId: project.organizationId,
391+
}).catch((cleanupError) => {
388392
logger.error("Failed to remove a dashboard agent chat whose start failed", {
389393
chatId,
390394
error: cleanupError,
@@ -654,8 +658,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
654658
}
655659

656660
case "delete": {
657-
// `deleteChatWithWatches` is owner-scoped but takes no org, so the org scope has
658-
// to be enforced here.
661+
// Existence check gives a 404 for a chat this caller can't see; the delete itself
662+
// is org- and owner-scoped too, and ends the chat's watches in the same transaction.
659663
if (
660664
!(await chatExists(dashboardAgentDb, {
661665
chatId,
@@ -666,7 +670,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
666670
return json({ error: "Chat not found" }, { status: 404 });
667671
}
668672
// The delete and the watch cancellations land in one transaction.
669-
const { cancelledWatches } = await deleteChatWithWatches({ chatId, userId });
673+
const { cancelledWatches } = await deleteChatWithWatches({
674+
chatId,
675+
userId,
676+
organizationId: project.organizationId,
677+
});
670678
return json({ ok: true, cancelledWatches });
671679
}
672680

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1184,11 +1184,12 @@ export async function cancelDashboardAgentWatch(params: {
11841184

11851185
/**
11861186
* Delete a chat and end its watches in one transaction, so no live watch is left on an
1187-
* invisible chat. Owner-scoped, so a chatId the caller doesn't own deletes nothing.
1187+
* invisible chat. Org- and owner-scoped, so a chatId the caller doesn't own deletes nothing.
11881188
*/
11891189
export async function deleteChatWithWatches(params: {
11901190
chatId: string;
11911191
userId: string;
1192+
organizationId: string;
11921193
}): Promise<{ deleted: boolean; cancelledWatches: number }> {
11931194
const result = await softDeleteChat(dashboardAgentDb, params);
11941195
return { deleted: result.deleted, cancelledWatches: result.cancelledWatches.length };
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
createChat,
3+
createDashboardAgentDb,
4+
listChats,
5+
softDeleteChat,
6+
type DashboardAgentDb,
7+
type DashboardAgentDbClient,
8+
} from "@internal/dashboard-agent-db";
9+
import { postgresTest } from "@internal/testcontainers";
10+
import type { PrismaClient } from "@trigger.dev/database";
11+
import { readdirSync, readFileSync } from "node:fs";
12+
import path from "node:path";
13+
import { afterEach, describe, expect } from "vitest";
14+
15+
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
16+
async function applyAgentSchema(prisma: PrismaClient) {
17+
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
18+
const migrations = readdirSync(folder)
19+
.filter((file) => file.endsWith(".sql"))
20+
.sort();
21+
for (const name of migrations) {
22+
const sql = readFileSync(path.join(folder, name), "utf8");
23+
for (const statement of sql.split("--> statement-breakpoint")) {
24+
const trimmed = statement.trim();
25+
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
26+
}
27+
}
28+
}
29+
30+
let agentDbClient: DashboardAgentDbClient | undefined;
31+
32+
async function boot(prisma: PrismaClient, connectionUri: string): Promise<DashboardAgentDb> {
33+
await applyAgentSchema(prisma);
34+
agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
35+
return agentDbClient.db;
36+
}
37+
38+
afterEach(async () => {
39+
await agentDbClient?.close();
40+
agentDbClient = undefined;
41+
});
42+
43+
const ORG = "org_owner";
44+
const OTHER_ORG = "org_other";
45+
const USER = "user_owner";
46+
47+
describe("softDeleteChat tenant isolation", () => {
48+
postgresTest(
49+
"a soft-delete scoped to another org leaves the chat intact",
50+
async ({ prisma, postgresContainer }) => {
51+
const db = await boot(prisma, postgresContainer.getConnectionUri());
52+
53+
await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER });
54+
55+
// Right user, wrong org: must not delete.
56+
const wrongOrg = await softDeleteChat(db, {
57+
chatId: "chat_1",
58+
userId: USER,
59+
organizationId: OTHER_ORG,
60+
});
61+
expect(wrongOrg.deleted).toBe(false);
62+
expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(1);
63+
64+
// Right org and user: deletes.
65+
const rightOrg = await softDeleteChat(db, {
66+
chatId: "chat_1",
67+
userId: USER,
68+
organizationId: ORG,
69+
});
70+
expect(rightOrg.deleted).toBe(true);
71+
expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(0);
72+
},
73+
30_000
74+
);
75+
});

apps/webapp/test/dashboardAgentQuota.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe("the per-(org, period) counter", () => {
116116

117117
// Deleting a chat must not move the counter: it is not joined to chats.
118118
await createChat(db, { id: "chat_del", organizationId: ORG, userId: USER });
119-
await softDeleteChat(db, { chatId: "chat_del", userId: USER });
119+
await softDeleteChat(db, { chatId: "chat_del", userId: USER, organizationId: ORG });
120120
expect(await getAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2);
121121

122122
// The next period and other orgs start fresh.

apps/webapp/test/dashboardAgentWatchCardAtomicity.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe("closing a consented watch investigation's card", () => {
150150
const chatId = "chat_watch_card_deleted";
151151
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
152152
const id = await seed(chatId, openState());
153-
await softDeleteChat(agentDb, { chatId, userId: USER_ID });
153+
await softDeleteChat(agentDb, { chatId, userId: USER_ID, organizationId: ORG_ID });
154154

155155
expect(
156156
await settleInvestigationStateAndCloseCard(agentDb, {

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,13 @@ describe("the chat cascade and the list view", () => {
784784
expect(mine.ok && theirs.ok).toBe(true);
785785
if (!mine.ok || !theirs.ok) return;
786786

787-
expect(await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id })).toEqual({
787+
expect(
788+
await deleteChatWithWatches({
789+
chatId: "chat_1",
790+
userId: seeded.user.id,
791+
organizationId: seeded.organization.id,
792+
})
793+
).toEqual({
788794
deleted: true,
789795
cancelledWatches: 1,
790796
});
@@ -860,7 +866,11 @@ describe("the chat cascade and the list view", () => {
860866
const created = await create({ seeded, chatId: "chat_1" });
861867
expect(created.ok).toBe(true);
862868

863-
await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id });
869+
await deleteChatWithWatches({
870+
chatId: "chat_1",
871+
userId: seeded.user.id,
872+
organizationId: seeded.organization.id,
873+
});
864874

865875
const rows = await ctx.prisma.$queryRawUnsafe<{ message_id: string }[]>(
866876
`select message_id from trigger_dashboard_agent.chat_messages where chat_id = 'chat_1'`
@@ -1518,7 +1528,12 @@ describe("deleting a chat while a watch is being created", () => {
15181528
await seedChat(seeded, chatId);
15191529

15201530
const creating = () => create({ seeded, chatId });
1521-
const deleting = () => deleteChatWithWatches({ chatId, userId: seeded.user.id });
1531+
const deleting = () =>
1532+
deleteChatWithWatches({
1533+
chatId,
1534+
userId: seeded.user.id,
1535+
organizationId: seeded.organization.id,
1536+
});
15221537
const [a, b] = deleteFirst
15231538
? await Promise.all([deleting(), creating()])
15241539
: await Promise.all([creating(), deleting()]);
@@ -1542,7 +1557,11 @@ describe("deleting a chat while a watch is being created", () => {
15421557
await boot(prisma, postgresContainer.getConnectionUri());
15431558
const seeded = await seed(prisma, "race");
15441559
await seedChat(seeded);
1545-
await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id });
1560+
await deleteChatWithWatches({
1561+
chatId: "chat_1",
1562+
userId: seeded.user.id,
1563+
organizationId: seeded.organization.id,
1564+
});
15461565

15471566
expect(await create({ seeded })).toMatchObject({ ok: false, code: "chat_not_found" });
15481567
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toEqual([]);

internal-packages/dashboard-agent-contracts/src/watch-wording.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ export function watchConditionWording(spec: WatchSpec): WatchConditionWording {
463463
label: "If it happens again",
464464
clause: "in case it happens again",
465465
tooltip: "Get notified if this error happens again",
466-
note: `ping me if error ${spec.fingerprint} happens again`,
466+
note: `ping me if error ${shortFingerprint(spec.fingerprint)} happens again`,
467467
};
468468
case "health_recovery":
469469
return {

0 commit comments

Comments
 (0)