diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.test.ts b/internal-packages/dashboard-agent/src/dashboard-agent.test.ts index 748b9bb9b..8736db765 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.test.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.test.ts @@ -24,6 +24,7 @@ import { evalSampleRate, extractToolActivity, isCiEvalContext, + isFirstUserExchange, MAX_EVAL_TOOL_OUTPUT_CHARS, sanitizeReplayedToolInputs, truncateEvalToolOutput, @@ -101,6 +102,29 @@ describe("dashboardAgent (mock harness)", () => { ]); }); + describe("which turn names the chat", () => { + const user = (id: string) => ({ id, role: "user" }); + const assistant = (id: string) => ({ id, role: "assistant" }); + + it("names it on the first exchange", () => { + expect(isFirstUserExchange([user("u1")])).toBe(true); + }); + + it("still names it when the turn was head-started", () => { + // The warm first step arrives in `uiMessages`, so the transcript already holds + // two messages on the very first exchange. + expect(isFirstUserExchange([user("u1"), assistant("a1")])).toBe(true); + }); + + it("does not rename on a later exchange", () => { + expect(isFirstUserExchange([user("u1"), assistant("a1"), user("u2")])).toBe(false); + }); + + it("ignores a watch consent record, which the user never typed", () => { + expect(isFirstUserExchange([user("watch-request:watch_1"), user("u1")])).toBe(true); + }); + }); + it("names the chat once, not on every turn", async () => { const { store, calls } = fakeStore(); harness = mockChatAgent(dashboardAgent, { @@ -112,8 +136,9 @@ describe("dashboardAgent (mock harness)", () => { }, }); - await harness.sendMessage(userMessage("first question")); - await harness.sendMessage(userMessage("second question")); + await harness.sendMessage(userMessage("first question", "u1")); + // A distinct id: two turns are two messages, which is what the gate counts. + await harness.sendMessage(userMessage("second question", "u2")); expect(calls.setChatTitleIfDefault).toHaveLength(1); }); diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.ts b/internal-packages/dashboard-agent/src/dashboard-agent.ts index 86776b8e2..23c6fcad9 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.ts @@ -1,3 +1,4 @@ +import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts"; import { chat } from "@trigger.dev/sdk/ai"; import { locals, logger, tasks } from "@trigger.dev/sdk"; import { generateText, stepCountIs, streamText, type ModelMessage, type UIMessage } from "ai"; @@ -272,6 +273,20 @@ function cleanTitle(raw: string): string { */ const pendingTitles = new Map>(); +/** + * Whether this turn is the one that names the chat. Counted in user messages, not in + * transcript length: a head-started turn arrives with the warm first step already in + * `uiMessages`, so a length gate would see two messages on the very first exchange and + * never name the chat at all. A watch's consent record is a user message the user did + * not type, so it doesn't count as an exchange either. + */ +export function isFirstUserExchange(uiMessages: { role: string; id?: string }[]): boolean { + const typed = uiMessages.filter( + (message) => message.role === "user" && !isWatchRequestMessageId(message.id) + ); + return typed.length <= 1; +} + async function generateAndSaveTitle( store: DashboardAgentStore, chatId: string, @@ -382,9 +397,8 @@ export const dashboardAgent = chat.agent({ // Name the chat on the first exchange, started here so it runs while the model // answers. Awaited in `onBeforeTurnComplete`, not here; a failure only costs the - // generated name. The gate is the transcript length at the START of the turn, - // where one message means nothing has been answered yet. - if (uiMessages.length <= 1 && !pendingTitles.has(chatId)) { + // generated name. + if (isFirstUserExchange(uiMessages) && !pendingTitles.has(chatId)) { const store = getStore(); pendingTitles.set( chatId,