Skip to content

Commit c3f0d62

Browse files
committed
fix(webapp): undo a new agent chat only when its start never got anywhere
A start that rejects dispatched no handover and sent no message, so the chat row is taken back. A failed access-token mint is left alone: the session is live by then and removing the chat would hide a running agent.
1 parent 14d70be commit c3f0d62

2 files changed

Lines changed: 147 additions & 34 deletions

File tree

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

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -262,33 +262,59 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
262262
...(clientData ? { metadata: { context: clientContext } } : {}),
263263
});
264264

265-
if (headStartMetadata) {
266-
// Injects the delegated token and context into the run's payload server-side.
267-
await startDashboardAgentHeadStart({
268-
chatId,
269-
messages: [firstMessage],
270-
mode: repoSnapshot ? "code" : "assistant",
271-
metadata: headStartMetadata,
272-
});
273-
} else {
274-
// Cold start: the client sends the first message through the `in` proxy, which
275-
// injects the token.
276-
// Same server-owned identity the head-start path injects; the `in` proxy adds the
277-
// delegated token on the first turn.
278-
await startDashboardAgentSession({
279-
chatId,
280-
clientData: {
281-
...clientContext,
282-
organizationId: project.organizationId,
283-
userId,
284-
projectId: project.id,
285-
environmentId: runtimeEnv.id,
286-
environmentName,
287-
},
265+
try {
266+
if (headStartMetadata) {
267+
// Injects the delegated token and context into the run's payload server-side.
268+
await startDashboardAgentHeadStart({
269+
chatId,
270+
messages: [firstMessage],
271+
mode: repoSnapshot ? "code" : "assistant",
272+
metadata: headStartMetadata,
273+
});
274+
} else {
275+
// Cold start: the client sends the first message through the `in` proxy, which
276+
// injects the token.
277+
// Same server-owned identity the head-start path injects; the `in` proxy adds the
278+
// delegated token on the first turn.
279+
await startDashboardAgentSession({
280+
chatId,
281+
clientData: {
282+
...clientContext,
283+
organizationId: project.organizationId,
284+
userId,
285+
projectId: project.id,
286+
environmentId: runtimeEnv.id,
287+
environmentName,
288+
},
289+
});
290+
}
291+
} catch (error) {
292+
// Both starts are one create-session-and-trigger round trip, so a rejection means no
293+
// handover was dispatched and no message was sent: a session the call did create in
294+
// spite of the error idles out having done nothing. The empty row is all there is to undo.
295+
// Swallowed so the start's own error is what surfaces and gets logged.
296+
await softDeleteChat(dashboardAgentDb, { chatId, userId }).catch((cleanupError) => {
297+
logger.error("Failed to remove a dashboard agent chat whose start failed", {
298+
chatId,
299+
error: cleanupError,
300+
});
288301
});
302+
throw error;
289303
}
290304

291-
const publicAccessToken = await mintDashboardAgentToken(chatId);
305+
let publicAccessToken: string;
306+
try {
307+
publicAccessToken = await mintDashboardAgentToken(chatId);
308+
} catch (error) {
309+
// The start resolved, so the session is live and a head start is already streaming into
310+
// it. Deleting the chat here would hide a running agent; the client can ask for a token
311+
// again through the `token` intent.
312+
logger.error("Dashboard agent chat started but its token mint failed", { chatId, error });
313+
return json(
314+
{ error: "The dashboard agent started but couldn't be opened. Try opening it again." },
315+
{ status: 500 }
316+
);
317+
}
292318
return json({ chatId, publicAccessToken, headStarted });
293319
} catch (error) {
294320
logger.error("Failed to create dashboard agent chat", { chatId, error });

apps/webapp/test/dashboardAgentCreateChatOrdering.test.ts

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@ const mocks = vi.hoisted(() => ({
44
createChat: vi.fn(),
55
findEnvironmentBySlug: vi.fn(),
66
mintUserActorToken: vi.fn(),
7+
mintPublicToken: vi.fn(),
78
headStart: vi.fn(),
9+
startSession: vi.fn(),
10+
softDeleteChat: vi.fn(),
11+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
12+
// Mutable so a test can take the head start away and drive the cold path.
13+
env: { SESSION_SECRET: "test-session-secret", ANTHROPIC_API_KEY: "sk-test" } as Record<
14+
string,
15+
string | undefined
16+
>,
817
}));
918

1019
vi.mock("~/db.server", () => ({ $replica: {}, prisma: {} }));
11-
vi.mock("~/env.server", () => ({
12-
env: { SESSION_SECRET: "test-session-secret", ANTHROPIC_API_KEY: "sk-test" },
13-
}));
20+
vi.mock("~/env.server", () => ({ env: mocks.env }));
1421
vi.mock("~/services/session.server", () => ({
1522
requireUser: async () => ({ id: "usr_real", admin: false, isImpersonating: false }),
1623
}));
@@ -30,10 +37,10 @@ vi.mock("~/models/runtimeEnvironment.server", () => ({
3037
vi.mock("~/services/dashboardAgent.server", () => ({
3138
dashboardAgentApiOrigin: () => "https://api.trigger.dev",
3239
isDashboardAgentConfigured: () => true,
33-
mintDashboardAgentToken: async () => "pat_public",
40+
mintDashboardAgentToken: mocks.mintPublicToken,
3441
mintDashboardAgentUserActorToken: mocks.mintUserActorToken,
3542
resolveDashboardAgentRepoSnapshot: async () => null,
36-
startDashboardAgentSession: async () => {},
43+
startDashboardAgentSession: mocks.startSession,
3744
}));
3845
vi.mock("~/services/dashboardAgentHeadStart.server", () => ({
3946
startDashboardAgentHeadStart: mocks.headStart,
@@ -50,11 +57,9 @@ vi.mock("@internal/dashboard-agent-db", () => ({
5057
listChats: vi.fn(),
5158
renameChat: vi.fn(),
5259
setChatPinned: vi.fn(),
53-
softDeleteChat: vi.fn(),
54-
}));
55-
vi.mock("~/services/logger.server", () => ({
56-
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
60+
softDeleteChat: mocks.softDeleteChat,
5761
}));
62+
vi.mock("~/services/logger.server", () => ({ logger: mocks.logger }));
5863

5964
import { action } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent";
6065

@@ -86,6 +91,10 @@ describe("dashboard agent chat creation — nothing fallible after the row exist
8691
.mockReset()
8792
.mockResolvedValue({ id: "env_real", type: "DEVELOPMENT" });
8893
mocks.mintUserActorToken.mockReset().mockResolvedValue("tr_uat_real");
94+
mocks.mintPublicToken.mockReset().mockResolvedValue("pat_public");
95+
mocks.startSession.mockReset().mockResolvedValue(undefined);
96+
mocks.softDeleteChat.mockReset().mockResolvedValue({ deleted: true, cancelledWatches: [] });
97+
mocks.env.ANTHROPIC_API_KEY = "sk-test";
8998
});
9099

91100
it("creates no chat when the environment slug resolves to nothing", async () => {
@@ -97,7 +106,7 @@ describe("dashboard agent chat creation — nothing fallible after the row exist
97106
expect(mocks.createChat).not.toHaveBeenCalled();
98107
});
99108

100-
it("creates no chat when the token mint fails", async () => {
109+
it("creates no chat when the delegated token mint fails", async () => {
101110
mocks.mintUserActorToken.mockRejectedValue(new Error("signing key unavailable"));
102111

103112
const response = await createChatRequest();
@@ -118,5 +127,83 @@ describe("dashboard agent chat creation — nothing fallible after the row exist
118127
environmentId: "env_real",
119128
environmentName: "dev",
120129
});
130+
expect(mocks.softDeleteChat).not.toHaveBeenCalled();
131+
});
132+
});
133+
134+
// A failed start means no handover was dispatched and no message was sent, so any session it
135+
// did create idles out having done nothing — the chat row is safe to take back. Once the start
136+
// has resolved the session is live, and removing the chat would hide a running agent.
137+
describe("dashboard agent chat creation — a start that fails part way", () => {
138+
beforeEach(() => {
139+
mocks.createChat.mockReset().mockResolvedValue(undefined);
140+
mocks.headStart.mockReset().mockResolvedValue(undefined);
141+
mocks.findEnvironmentBySlug
142+
.mockReset()
143+
.mockResolvedValue({ id: "env_real", type: "DEVELOPMENT" });
144+
mocks.mintUserActorToken.mockReset().mockResolvedValue("tr_uat_real");
145+
mocks.mintPublicToken.mockReset().mockResolvedValue("pat_public");
146+
mocks.startSession.mockReset().mockResolvedValue(undefined);
147+
mocks.softDeleteChat.mockReset().mockResolvedValue({ deleted: true, cancelledWatches: [] });
148+
mocks.env.ANTHROPIC_API_KEY = "sk-test";
149+
mocks.logger.error.mockReset();
150+
});
151+
152+
it("takes the chat back when the head start fails", async () => {
153+
mocks.headStart.mockRejectedValue(new Error("session create failed"));
154+
155+
const response = await createChatRequest();
156+
157+
expect(response.status).toBe(500);
158+
expect(mocks.createChat).toHaveBeenCalledTimes(1);
159+
expect(mocks.softDeleteChat).toHaveBeenCalledTimes(1);
160+
expect(mocks.softDeleteChat.mock.calls[0][1]).toMatchObject({
161+
chatId: mocks.createChat.mock.calls[0][1].id,
162+
userId: "usr_real",
163+
});
164+
});
165+
166+
it("takes the chat back when the cold start fails", async () => {
167+
mocks.env.ANTHROPIC_API_KEY = undefined;
168+
mocks.startSession.mockRejectedValue(new Error("session create failed"));
169+
170+
const response = await createChatRequest();
171+
172+
expect(response.status).toBe(500);
173+
expect(mocks.createChat).toHaveBeenCalledTimes(1);
174+
expect(mocks.softDeleteChat).toHaveBeenCalledTimes(1);
175+
});
176+
177+
it("keeps the chat when the session is live and only its access token failed", async () => {
178+
mocks.mintPublicToken.mockRejectedValue(new Error("token mint failed"));
179+
180+
const response = await createChatRequest();
181+
182+
expect(response.status).toBe(500);
183+
expect(mocks.headStart).toHaveBeenCalledTimes(1);
184+
expect(mocks.createChat).toHaveBeenCalledTimes(1);
185+
expect(mocks.softDeleteChat).not.toHaveBeenCalled();
186+
});
187+
188+
it("keeps the chat when a cold-started session's access token failed", async () => {
189+
mocks.env.ANTHROPIC_API_KEY = undefined;
190+
mocks.mintPublicToken.mockRejectedValue(new Error("token mint failed"));
191+
192+
const response = await createChatRequest();
193+
194+
expect(response.status).toBe(500);
195+
expect(mocks.startSession).toHaveBeenCalledTimes(1);
196+
expect(mocks.softDeleteChat).not.toHaveBeenCalled();
197+
});
198+
199+
it("surfaces the start's own failure when taking the chat back also fails", async () => {
200+
mocks.headStart.mockRejectedValue(new Error("session create failed"));
201+
mocks.softDeleteChat.mockRejectedValue(new Error("chat store unavailable"));
202+
203+
const response = await createChatRequest();
204+
205+
expect(response.status).toBe(500);
206+
const logged = mocks.logger.error.mock.calls.map((call: any[]) => call[1]?.error?.message);
207+
expect(logged).toContain("session create failed");
121208
});
122209
});

0 commit comments

Comments
 (0)