Skip to content

Commit aad8498

Browse files
committed
fix(webapp): converge the watch transcript when a retried delivery finds its message already streamed
The wake and the consented investigation both stream their message before they append the display copy, and the streamed copy is durable on session.out from that moment. An append that failed therefore left the retry booting with the message already in its history, taking the dedupe branch and never writing the row: the model saw the message, the History panel didn't. Both dedupe branches now re-append the message they found. The append is id-deduped, so repairing when nothing is broken writes nothing.
1 parent 398a298 commit aad8498

2 files changed

Lines changed: 186 additions & 9 deletions

File tree

internal-packages/dashboard-agent/src/watch-actions.test.ts

Lines changed: 171 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,44 @@ import {
2626
USAGE,
2727
} from "./test-support";
2828

29+
/**
30+
* Stands in for `chat_messages`: the append is keyed on (chat_id, message_id) and does
31+
* nothing on conflict, so a repeated append is never a second row. Row counts are what
32+
* the History panel reads, so the retry tests assert those rather than call counts.
33+
*/
34+
function transcriptTable() {
35+
const rows: { chatId: string; messageId: string }[] = [];
36+
const countOf = (chatId: string, messageId: string) =>
37+
rows.filter((row) => row.chatId === chatId && row.messageId === messageId).length;
38+
return {
39+
countOf,
40+
insert(chatId: string, message: UIMessage) {
41+
if (countOf(chatId, message.id) > 0) return false;
42+
rows.push({ chatId, messageId: message.id });
43+
return true;
44+
},
45+
};
46+
}
47+
48+
// A store that writes into `table`, failing the appends `failWhen` selects.
49+
function appendingStore(
50+
table: ReturnType<typeof transcriptTable>,
51+
failWhen: (message: UIMessage) => boolean,
52+
options?: Parameters<typeof fakeStore>[0]
53+
) {
54+
const { store, calls } = fakeStore(options);
55+
const wrapped: DashboardAgentStore = {
56+
...store,
57+
appendMessage: async (args) => {
58+
await store.appendMessage(args);
59+
const message = args.message as UIMessage;
60+
if (failWhen(message)) throw new Error("the append lost the connection");
61+
return table.insert(args.chatId, message);
62+
},
63+
};
64+
return { store: wrapped, calls };
65+
}
66+
2967
describe("watch wake narration", () => {
3068
let harness: MockChatAgentHarness | undefined;
3169

@@ -84,10 +122,14 @@ describe("watch wake narration", () => {
84122
expect(appended.userId).toBe(CLIENT_DATA.userId);
85123
expect(appended.message).toMatchObject({ id: "wake:watch:watch_1:fired", role: "assistant" });
86124

87-
// Same action id again (the watcher retried after appending): deduped.
125+
// Same action id again (the watcher retried after appending): nothing is narrated,
126+
// and the only write is the id-deduped repair of the same message.
88127
const second = await harness.sendAction(WAKE);
89128
expect(collectText(second.chunks)).toBe("");
90-
expect(calls.appendMessage).toHaveLength(1);
129+
expect(calls.appendMessage.map((call) => (call as { message: UIMessage }).message.id)).toEqual([
130+
"wake:watch:watch_1:fired",
131+
"wake:watch:watch_1:fired",
132+
]);
91133
});
92134

93135
// Records the prompt it was asked with, so the wake's framing can be asserted.
@@ -377,6 +419,62 @@ describe("watch wake narration", () => {
377419
"wake:watch:watch_2:expired",
378420
]);
379421
});
422+
423+
/**
424+
* The wake is durable on `session.out` the moment it streams, which is before the
425+
* display copy is written. So an append that fails leaves the model seeing a message
426+
* the History panel doesn't have — and the retry boots with that message already in
427+
* its history. Converging on the row is the retry's job.
428+
*/
429+
it("appends the display copy on a retry that finds the wake already narrated", async () => {
430+
const table = transcriptTable();
431+
const chatId = "chat_wake_retry";
432+
const wakeId = "wake:watch:watch_1:fired";
433+
434+
const failing = appendingStore(table, (message) => message.id === wakeId);
435+
harness = mockChatAgent(dashboardAgent, {
436+
chatId,
437+
clientData: CLIENT_DATA,
438+
setupLocals: ({ set }) => {
439+
set(dashboardAgentStoreKey, failing.store);
440+
set(dashboardAgentModelKey, mockModel([textStep("never asked for")]));
441+
},
442+
});
443+
444+
const first = await harness.sendAction(WAKE);
445+
// Streamed — so it is on `session.out` and in the next boot's history — while the
446+
// row it was supposed to land alongside never arrived.
447+
expect(collectText(first.chunks)).toContain("queue drained");
448+
expect(failing.calls.appendMessage).toHaveLength(1);
449+
expect(table.countOf(chatId, wakeId)).toBe(0);
450+
const durable = first.chunks;
451+
await harness.close();
452+
453+
// The retry is a new run picking up the session, booting its history from the
454+
// chunks the failed one left on `session.out`.
455+
const repairing = appendingStore(table, () => false);
456+
harness = mockChatAgent(dashboardAgent, {
457+
chatId,
458+
clientData: CLIENT_DATA,
459+
continuation: true,
460+
previousRunId: "run_wake_failed",
461+
setupLocals: ({ set }) => {
462+
set(dashboardAgentStoreKey, repairing.store);
463+
set(dashboardAgentModelKey, mockModel([textStep("never asked for")]));
464+
},
465+
});
466+
harness.seedSessionOutTail(durable);
467+
468+
const retry = await harness.sendAction(WAKE);
469+
470+
// Nothing narrated twice, and the display copy converged on exactly one row.
471+
expect(collectText(retry.chunks)).toBe("");
472+
expect(table.countOf(chatId, wakeId)).toBe(1);
473+
474+
// A third delivery repairs nothing, because there is nothing left to repair.
475+
await harness.sendAction(WAKE);
476+
expect(table.countOf(chatId, wakeId)).toBe(1);
477+
});
380478
});
381479

382480
describe("watch investigation", () => {
@@ -529,9 +627,13 @@ describe("watch investigation", () => {
529627
expect(appended.message.id).toBe("investigate:watch:watch_1:fired:investigate");
530628
expect(appended.message.parts.some((part) => part.type === "tool-render_view")).toBe(true);
531629

532-
// The same kick again: nothing runs, nothing is written.
630+
// The same kick again: nothing runs, and the only write is the id-deduped repair of
631+
// the findings message.
533632
await harness.sendAction(INVESTIGATE);
534-
expect(calls.appendMessage).toHaveLength(1);
633+
expect(calls.appendMessage.map((call) => (call as { message: UIMessage }).message.id)).toEqual([
634+
"investigate:watch:watch_1:fired:investigate",
635+
"investigate:watch:watch_1:fired:investigate",
636+
]);
535637
expect(calls.upsertInvestigationRevision).toHaveLength(1);
536638
});
537639

@@ -724,7 +826,10 @@ describe("watch investigation", () => {
724826

725827
const revisions = calls.upsertInvestigationRevision.length;
726828
await harness.sendAction(INVESTIGATE);
727-
expect(calls.appendMessage).toHaveLength(1);
829+
expect(calls.appendMessage.map((call) => (call as { message: UIMessage }).message.id)).toEqual([
830+
"investigate:watch:watch_1:fired:investigate",
831+
"investigate:watch:watch_1:fired:investigate",
832+
]);
728833
expect(calls.upsertInvestigationRevision).toHaveLength(revisions);
729834
});
730835

@@ -787,4 +892,65 @@ describe("watch investigation", () => {
787892
expect(calls.upsertInvestigationRevision).toHaveLength(0);
788893
expect(calls.appendMessage).toHaveLength(0);
789894
});
895+
896+
/**
897+
* The same window the wake has: the findings stream to `session.out` before the display
898+
* copy is appended, so an append that fails leaves the model holding a message the
899+
* History panel lost. The retry finds it already answered and must still land the row.
900+
*/
901+
it("appends the display copy on a retry that finds the investigation already answered", async () => {
902+
const table = transcriptTable();
903+
const chatId = "chat_investigate_retry";
904+
const findingsId = "investigate:watch:watch_1:fired:investigate";
905+
const seeded = { id: "inv_seeded", projectRef: "proj_abc", environmentRef: "env_abc" };
906+
const steps = [
907+
renderStep(concluded, "inv_seeded", "tc_verdict"),
908+
textStep("The payload lost order.total."),
909+
];
910+
911+
const failing = appendingStore(table, (message) => message.id === findingsId, {
912+
openInvestigation: seeded,
913+
});
914+
harness = mockChatAgent(dashboardAgent, {
915+
chatId,
916+
clientData: CLIENT_DATA_WITH_TOKEN,
917+
setupLocals: ({ set }) => {
918+
set(dashboardAgentStoreKey, failing.store);
919+
set(dashboardAgentModelKey, recordingModel(steps).model);
920+
},
921+
});
922+
923+
const first = await harness.sendAction(INVESTIGATE);
924+
// Streamed whole, card part and all, while the row it belongs to never landed.
925+
expect(executedTool(first.chunks)).toBe(true);
926+
expect(failing.calls.appendMessage).toHaveLength(1);
927+
expect(table.countOf(chatId, findingsId)).toBe(0);
928+
const durable = first.chunks;
929+
await harness.close();
930+
931+
// The retry is a new run picking up the session, booting its history from the chunks
932+
// the failed one left on `session.out`.
933+
const repairing = appendingStore(table, () => false, { openInvestigation: seeded });
934+
harness = mockChatAgent(dashboardAgent, {
935+
chatId,
936+
clientData: CLIENT_DATA_WITH_TOKEN,
937+
continuation: true,
938+
previousRunId: "run_investigate_failed",
939+
setupLocals: ({ set }) => {
940+
set(dashboardAgentStoreKey, repairing.store);
941+
set(dashboardAgentModelKey, recordingModel(steps).model);
942+
},
943+
});
944+
harness.seedSessionOutTail(durable);
945+
946+
const retry = await harness.sendAction(INVESTIGATE);
947+
948+
// Nothing investigated a second time, and the display copy converged on one row.
949+
expect(collectText(retry.chunks)).toBe("");
950+
expect(table.countOf(chatId, findingsId)).toBe(1);
951+
952+
// A third delivery repairs nothing, because there is nothing left to repair.
953+
await harness.sendAction(INVESTIGATE);
954+
expect(table.countOf(chatId, findingsId)).toBe(1);
955+
});
790956
});

internal-packages/dashboard-agent/src/watch-actions.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -479,12 +479,18 @@ async function narrateWatchWake(args: {
479479

480480
// Dedup on the action id. Durable, because the history it checks is the
481481
// snapshot the SDK reseeds on every boot — not per-process state.
482-
if (uiMessages.some((message) => message.id === messageId)) {
483-
logger.info("dashboard-agent watch wake already narrated; skipping", {
482+
const narrated = uiMessages.find((message) => message.id === messageId);
483+
if (narrated) {
484+
logger.info("dashboard-agent watch wake already narrated; repairing the display copy", {
484485
chatId,
485486
watchId: action.watchId,
486487
actionId: action.id,
487488
});
489+
// The streamed message is durable before the append is, so a retry can find the
490+
// wake narrated and the display copy still owed. The append is id-deduped, so
491+
// repairing when nothing is broken writes nothing.
492+
const userId = args.clientData?.userId;
493+
if (userId) await getStore().appendMessage({ chatId, userId, message: narrated });
488494
return;
489495
}
490496

@@ -718,12 +724,17 @@ async function conductWatchInvestigation(args: {
718724

719725
// Dedup on the action id, against the durable transcript — a redelivered kick
720726
// must not investigate (or answer) twice.
721-
if (uiMessages.some((message) => message.id === messageId)) {
722-
logger.info("dashboard-agent watch investigation already ran; skipping", {
727+
const alreadyAnswered = uiMessages.find((message) => message.id === messageId);
728+
if (alreadyAnswered) {
729+
logger.info("dashboard-agent watch investigation already ran; repairing the display copy", {
723730
chatId,
724731
watchId: action.watchId,
725732
actionId: action.id,
726733
});
734+
// Same window as the wake's: the findings streamed durably before the append, so a
735+
// retry can owe only the display copy. Id-deduped, so a repeat writes nothing.
736+
const userId = clientData?.userId;
737+
if (userId) await getStore().appendMessage({ chatId, userId, message: alreadyAnswered });
727738
const open = [...latestCards(uiMessages).values()].find(
728739
(card) => card.state === null || card.state.outcome === "in_progress"
729740
);

0 commit comments

Comments
 (0)