Skip to content

Commit ec5068c

Browse files
committed
fix(dashboard-agent): never let the chat title hold a turn open
The generated chat name is best-effort, but the await that collects it is the last gate before a turn completes and the provider request behind it has no deadline of its own. A request that stalled rather than failed hung the whole turn after the model's final word: nothing streamed, nothing was saved, and the run sat there until it was killed. The name now has a deadline; a late one still lands on the row.
1 parent 20ee5a6 commit ec5068c

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

‎internal-packages/dashboard-agent/src/dashboard-agent.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
dashboardAgentEvalTriggerKey,
2525
dashboardAgentModelKey,
2626
dashboardAgentStoreKey,
27+
dashboardAgentTitleDeadlineKey,
2728
dashboardAgentToolsKey,
2829
DEFAULT_CI_EVAL_SAMPLE_RATE,
2930
DEFAULT_EVAL_SAMPLE_RATE,
@@ -110,6 +111,33 @@ describe("dashboardAgent (mock harness)", () => {
110111
]);
111112
});
112113

114+
// A provider request that stalls instead of failing has no deadline of its own, so
115+
// the await that collects the title used to hang the whole turn after the model's
116+
// final word: nothing streamed, nothing persisted, and the run never ended.
117+
it("settles the turn when the title generation never comes back (regression)", async () => {
118+
const { store, calls } = fakeStore();
119+
const model = new MockLanguageModelV3({
120+
doStream: async () => ({ stream: simulateReadableStream({ chunks: textStep("answered") }) }),
121+
doGenerate: (() => new Promise(() => {})) as never,
122+
});
123+
harness = mockChatAgent(dashboardAgent, {
124+
chatId: "chat_title_stalled",
125+
clientData: CLIENT_DATA,
126+
setupLocals: ({ set }) => {
127+
set(dashboardAgentStoreKey, store);
128+
set(dashboardAgentModelKey, model);
129+
set(dashboardAgentTitleDeadlineKey, 30);
130+
},
131+
});
132+
133+
const turn = await harness.sendMessage(userMessage("why do my orders fail?"));
134+
135+
expect(collectText(turn.chunks)).toBe("answered");
136+
await new Promise((r) => setTimeout(r, 30));
137+
expect(calls.persistTurn).toHaveLength(1);
138+
expect(calls.setChatTitleIfDefault).toHaveLength(0);
139+
}, 10_000);
140+
113141
describe("which turn names the chat", () => {
114142
const user = (id: string) => ({ id, role: "user" });
115143
const assistant = (id: string) => ({ id, role: "assistant" });

‎internal-packages/dashboard-agent/src/dashboard-agent.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,37 @@ function cleanTitle(raw: string): string {
283283
*/
284284
const pendingTitles = new Map<string, Promise<void>>();
285285

286+
/**
287+
* How long the turn's settle may wait for that name.
288+
*
289+
* The title is best-effort — a failure only costs the generated name — but the await
290+
* that collects it is the last gate before the turn completes. A provider request
291+
* that stalls rather than fails has no deadline of its own, so without this the
292+
* whole turn hangs after the model's final word: nothing streams, nothing persists,
293+
* and the run sits there until it is killed. 15s is set against the run's own
294+
* `idleTimeoutInSeconds: 60`, so a stalled name delays the client's settle by a
295+
* fraction of it rather than outliving the turn.
296+
*/
297+
export const TITLE_DEADLINE_MS = 15_000;
298+
299+
// Test-only override, so a regression test needn't wait the real deadline.
300+
export const dashboardAgentTitleDeadlineKey = locals.create<number>(
301+
"dashboard-agent.titleDeadlineMs"
302+
);
303+
304+
/** The promise, or nothing, once the deadline passes. */
305+
async function withinTitleDeadline(pending: Promise<void>): Promise<void> {
306+
let timer: ReturnType<typeof setTimeout> | undefined;
307+
const deadline = new Promise<void>((resolve) => {
308+
timer = setTimeout(resolve, locals.get(dashboardAgentTitleDeadlineKey) ?? TITLE_DEADLINE_MS);
309+
});
310+
try {
311+
await Promise.race([pending, deadline]);
312+
} finally {
313+
clearTimeout(timer);
314+
}
315+
}
316+
286317
/**
287318
* Whether this turn is the one that names the chat. Counted in user messages, not in
288319
* transcript length: a head-started turn arrives with the warm first step already in
@@ -440,7 +471,7 @@ export const dashboardAgent = chat.agent({
440471
const pending = pendingTitles.get(chatId);
441472
if (!pending) return;
442473
pendingTitles.delete(chatId);
443-
await pending;
474+
await withinTitleDeadline(pending);
444475
},
445476

446477
onTurnComplete: async ({

0 commit comments

Comments
 (0)