Skip to content

Commit f73ad96

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(dashboard-agent): persist the conversation through the chat.agent transcript storage
The in-dashboard agent now persists its conversation through the `chat.agent` transcript storage adapter over its own message rows, instead of the platform snapshot plus its own hook writes. The runtime writes the question at turn start, the answer at turn complete, every history edit an action makes, and the resume cursors; the panel reads the same rows. - The hook writes the runtime now owns are removed. Investigation settlement keeps its own transaction so a settled row and its closing card still commit together. - The storage adapter normalises message bodies before writing and refuses to save into a chat owned by another tenant. - The panel's resume cursor comes from the transcript cursors, falling back to the previous session column for chats written by the earlier agent build. - Tests assert on the transcript the runtime saved instead of on store call counts. - Watch wakes and consented investigations are answered with `chat.turn()`. The action files what it has to say as a user-role request under a stable id and returns the turn, so an ordinary turn answers it with the agent's prompt, tools, hooks and transcript save; the response is pinned to the id the panel already knows the record by. The request messages are hidden by the panel and excluded from the message cap. A wake whose wording is fixed is still streamed directly with no model call. Mono-RevId: 7e72e1783e44bd7e7fdec3add6e3c331622c24d1
1 parent 6932966 commit f73ad96

14 files changed

Lines changed: 1207 additions & 842 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { useChat } from "@ai-sdk/react";
22
import type { UIMessage } from "@ai-sdk/react";
33
import type { dashboardAgent } from "@internal/dashboard-agent";
44
import {
5-
isWatchRequestMessageId,
5+
isAgentRequestMessageId,
6+
isTurnRequestMessageId,
67
type AgentIntent,
78
type SuggestedPrompt,
89
type WatchSpec,
@@ -438,7 +439,7 @@ export function DashboardAgentChat({
438439
const retry = useCallback(() => {
439440
if (atMessageCap) return;
440441
const action = retryAction(
441-
messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id)))
442+
messages.filter((m) => !(m.role === "user" && isAgentRequestMessageId(m.id)))
442443
);
443444
retryAgainstAction(action);
444445
}, [messages, atMessageCap, retryAgainstAction]);
@@ -621,7 +622,10 @@ export function DashboardAgentChat({
621622
/>
622623
) : (
623624
<DashboardAgentMessages
624-
messages={messages}
625+
// The request a wake or investigation turn answered is the agent asking
626+
// itself, not something the user typed: it stays in the transcript for
627+
// the runtime and the retry logic, and never renders.
628+
messages={messages.filter((m) => !(m.role === "user" && isTurnRequestMessageId(m.id)))}
625629
activity={activity}
626630
error={effectiveError}
627631
onRetry={retry}

apps/webapp/app/components/dashboard-agent/message-quota.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts";
1+
import { isAgentRequestMessageId } from "@internal/dashboard-agent-contracts";
22

33
// Counted per user across their chats in the org, not per chat, which "New chat"
44
// would reset.
@@ -106,7 +106,7 @@ export const MESSAGE_QUOTA_REACHED_REASON = "You've used your message allowance"
106106
export function countUserMessages(messages: { role: string; id?: string }[]): number {
107107
return messages.reduce(
108108
(total, message) =>
109-
message.role === "user" && !isWatchRequestMessageId(message.id) ? total + 1 : total,
109+
message.role === "user" && !isAgentRequestMessageId(message.id) ? total + 1 : total,
110110
0
111111
);
112112
}

internal-packages/dashboard-agent-contracts/src/agent-records.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { WATCH_REQUEST_MESSAGE_ID_PREFIX } from "./watch.js";
2+
13
/**
24
* Message ids for the records the agent appends to a chat outside a user's turn.
35
*
@@ -25,6 +27,48 @@ export function settledMessageId(messageId: string): string {
2527
return `${messageId}${SETTLED_MESSAGE_ID_SUFFIX}`;
2628
}
2729

30+
/**
31+
* The user-role message a wake or investigation turn answers. A watch action is an
32+
* edit to the conversation, and the turn that follows it needs something to answer,
33+
* so the action files the wake's facts (or the investigation brief) as a request under
34+
* a stable id. It is the agent asking itself, not the user typing, so the panel hides
35+
* it and it never counts against the message cap.
36+
*/
37+
export const WAKE_REQUEST_MESSAGE_ID_PREFIX = "wake-request:";
38+
export const INVESTIGATE_REQUEST_MESSAGE_ID_PREFIX = "investigate-request:";
39+
40+
export function wakeRequestMessageId(actionId: string): string {
41+
return `${WAKE_REQUEST_MESSAGE_ID_PREFIX}${actionId}`;
42+
}
43+
44+
export function investigateRequestMessageId(actionId: string): string {
45+
return `${INVESTIGATE_REQUEST_MESSAGE_ID_PREFIX}${actionId}`;
46+
}
47+
48+
const AGENT_REQUEST_ID_PREFIXES = [
49+
WATCH_REQUEST_MESSAGE_ID_PREFIX,
50+
WAKE_REQUEST_MESSAGE_ID_PREFIX,
51+
INVESTIGATE_REQUEST_MESSAGE_ID_PREFIX,
52+
];
53+
54+
/** The request a wake or investigation turn answers: the agent asking itself. */
55+
export function isTurnRequestMessageId(id: string | undefined | null): boolean {
56+
return (
57+
typeof id === "string" &&
58+
(id.startsWith(WAKE_REQUEST_MESSAGE_ID_PREFIX) ||
59+
id.startsWith(INVESTIGATE_REQUEST_MESSAGE_ID_PREFIX))
60+
);
61+
}
62+
63+
/**
64+
* A user-role message the user did not type: a watch consent record, or the request
65+
* a wake or investigation turn answers. Hidden by the panel, excluded from the message
66+
* cap, and never the exchange that names a chat.
67+
*/
68+
export function isAgentRequestMessageId(id: string | undefined | null): boolean {
69+
return typeof id === "string" && AGENT_REQUEST_ID_PREFIXES.some((p) => id.startsWith(p));
70+
}
71+
2872
const TRAILING_RECORD_ID_PREFIXES = [
2973
WAKE_MESSAGE_ID_PREFIX,
3074
INVESTIGATE_MESSAGE_ID_PREFIX,

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
2+
INVESTIGATE_REQUEST_MESSAGE_ID_PREFIX,
23
investigationBlockSchema,
34
toWellFormedDeep,
45
VIEW_BLOCK_VERSION,
6+
WAKE_REQUEST_MESSAGE_ID_PREFIX,
57
WATCH_REQUEST_MESSAGE_ID_PREFIX,
68
} from "@internal/dashboard-agent-contracts";
79
import { and, desc, eq, inArray, ne, notLike, sql, isNull, type SQL } from "drizzle-orm";
@@ -17,7 +19,6 @@ import {
1719
investigations,
1820
watches,
1921
watchSubmissions,
20-
type ChatSession,
2122
type Investigation,
2223
type NewChatTurnEval,
2324
type Watch,
@@ -121,7 +122,11 @@ export async function countUserMessages(
121122
eq(chats.userId, params.userId),
122123
isNull(chats.deletedAt),
123124
eq(chatMessages.role, "user"),
125+
// The user-role messages the user did not type: a watch consent record, and
126+
// the request a wake or investigation turn answers.
124127
notLike(chatMessages.messageId, `${WATCH_REQUEST_MESSAGE_ID_PREFIX}%`),
128+
notLike(chatMessages.messageId, `${WAKE_REQUEST_MESSAGE_ID_PREFIX}%`),
129+
notLike(chatMessages.messageId, `${INVESTIGATE_REQUEST_MESSAGE_ID_PREFIX}%`),
125130
params.excludeChatId ? ne(chatMessages.chatId, params.excludeChatId) : undefined
126131
)
127132
);
@@ -193,30 +198,60 @@ export async function countChatsWithUnreadWork(
193198
return rows[0]?.count ?? 0;
194199
}
195200

196-
/** Joins `chats` to scope by owner, because `chat_sessions` has no `userId`. */
201+
/** What a refreshed client needs to resume a chat's live stream. */
202+
export type ChatResumeSession = {
203+
chatId: string;
204+
/** The agent run's own token. The webapp mints the browser its own; this is kept for replay. */
205+
publicAccessToken: string | null;
206+
/** The `.out` cursor a reconnecting client resumes from. */
207+
lastEventId: string | null;
208+
runId: string | null;
209+
updatedAt: Date;
210+
};
211+
212+
/**
213+
* Scoped by owner through `chats`, because `chat_sessions` has no `userId`.
214+
*
215+
* The cursor comes from `chats.transcript_cursors`, which the `chat.agent` runtime
216+
* writes through the agent's `TranscriptStorage`. Chats last written by an agent build
217+
* that persisted through its own hooks have it on `chat_sessions.last_event_id`
218+
* instead, so that row is the fallback. Null when neither has been written yet.
219+
*/
197220
export async function getSession(
198221
db: DashboardAgentDb,
199222
params: { chatId: string; userId: string; organizationId: string }
200-
): Promise<ChatSession | null> {
223+
): Promise<ChatResumeSession | null> {
201224
const rows = await db
202225
.select({
203-
chatId: chatSessions.chatId,
226+
chatId: chats.id,
227+
cursors: chats.transcriptCursors,
228+
chatUpdatedAt: chats.updatedAt,
204229
publicAccessToken: chatSessions.publicAccessToken,
205-
lastEventId: chatSessions.lastEventId,
230+
sessionLastEventId: chatSessions.lastEventId,
206231
runId: chatSessions.runId,
207-
updatedAt: chatSessions.updatedAt,
232+
sessionUpdatedAt: chatSessions.updatedAt,
208233
})
209-
.from(chatSessions)
210-
.innerJoin(chats, eq(chats.id, chatSessions.chatId))
234+
.from(chats)
235+
.leftJoin(chatSessions, eq(chatSessions.chatId, chats.id))
211236
.where(
212237
and(
213-
eq(chatSessions.chatId, params.chatId),
238+
eq(chats.id, params.chatId),
214239
eq(chats.userId, params.userId),
215-
eq(chats.organizationId, params.organizationId)
240+
eq(chats.organizationId, params.organizationId),
241+
isNull(chats.deletedAt)
216242
)
217243
)
218244
.limit(1);
219-
return rows[0] ?? null;
245+
const row = rows[0];
246+
if (!row) return null;
247+
if (!row.cursors && row.publicAccessToken === null) return null;
248+
return {
249+
chatId: row.chatId,
250+
publicAccessToken: row.publicAccessToken,
251+
lastEventId: row.cursors?.lastOutEventId ?? row.sessionLastEventId ?? null,
252+
runId: row.runId,
253+
updatedAt: row.sessionUpdatedAt ?? row.chatUpdatedAt,
254+
};
220255
}
221256

222257
/** Owner check for chat-scoped actions, before a session row necessarily exists. */
@@ -645,6 +680,57 @@ export type PendingInvestigationSettlement = {
645680

646681
export type PersistTurnResult = { settled: SettledInvestigation[] };
647682

683+
export type SettleTurnInvestigationsResult = {
684+
settled: SettledInvestigation[];
685+
/** The closing cards, one per settled row, in the order they were written. */
686+
cards: InvestigationCardMessage[];
687+
};
688+
689+
/**
690+
* Close the investigations a turn left running: each terminal revision and its closing
691+
* card, in one transaction. The card is an id-deduped append, so a retried turn writes
692+
* the same transcript rather than a second card.
693+
*
694+
* The transcript itself is the `chat.agent` runtime's to write, through the agent's
695+
* `TranscriptStorage`. Only the settlement lives here, and it has to commit with its
696+
* card: a settled row whose closing card didn't land is a terminal row the stale sweep
697+
* no longer selects, and the panel renders the spinner forever.
698+
*/
699+
export async function settleTurnInvestigations(
700+
db: DashboardAgentDb,
701+
params: { chatId: string; settlements: PendingInvestigationSettlement[] }
702+
): Promise<SettleTurnInvestigationsResult> {
703+
if (params.settlements.length === 0) return { settled: [], cards: [] };
704+
return db.transaction(async (tx) => {
705+
const settled: SettledInvestigation[] = [];
706+
const cards: InvestigationCardMessage[] = [];
707+
for (const pending of params.settlements) {
708+
const result = await upsertInvestigationRevision(tx, {
709+
id: pending.id,
710+
chatId: params.chatId,
711+
projectRef: pending.projectRef,
712+
environmentRef: pending.environmentRef,
713+
state: pending.state,
714+
});
715+
// A row that no longer belongs to this chat/project/env has nothing to close.
716+
if (!result.ok) continue;
717+
718+
const message = investigationSettlementMessage({
719+
investigationId: result.id,
720+
revision: result.revision,
721+
state: pending.state,
722+
});
723+
if (!message) {
724+
throw new Error(`Investigation ${result.id} settled to a state that isn't renderable`);
725+
}
726+
await appendChatMessageOnceByChatId(tx, { chatId: params.chatId, message });
727+
settled.push({ id: result.id, revision: result.revision, state: pending.state });
728+
cards.push(message);
729+
}
730+
return { settled, cards };
731+
});
732+
}
733+
648734
/**
649735
* One transaction: the frontend reads `messages` and `lastEventId` in parallel, so a
650736
* torn write resumes from a stale cursor and double-renders the last turn.

0 commit comments

Comments
 (0)