fix(dashboard-agent): name a head-started chat instead of leaving it New chat

The gate counted transcript length, and a warm first turn arrives with the model's opening step already in it — so the very first exchange looked like a later one.
This commit is contained in:
Katia Bulatova
2026-08-07 14:06:24 +00:00
parent c025bbfcb4
commit 2ff7ec008b
2 changed files with 44 additions and 5 deletions
@@ -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);
});
@@ -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<string, Promise<void>>();
/**
* 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,