diff --git a/apps/web/src/domains/chat/api/event-parser.test.ts b/apps/web/src/domains/chat/api/event-parser.test.ts index 4e8e27fdd52..41ac3b0b2b9 100644 --- a/apps/web/src/domains/chat/api/event-parser.test.ts +++ b/apps/web/src/domains/chat/api/event-parser.test.ts @@ -28,13 +28,25 @@ describe("parseAssistantEvent", () => { test("parses message_complete with content", () => { const event = parseAssistantEvent("message_complete", { messageId: "msg-1", - displayMessageId: "display-msg-1", content: "Full response", }); expect(event).toEqual({ type: "message_complete", messageId: "msg-1", - displayMessageId: "display-msg-1", + content: "Full response", + attachments: undefined, + }); + }); + + test("ignores legacy displayMessageId on message_complete", () => { + const event = parseAssistantEvent("message_complete", { + messageId: "msg-1", + displayMessageId: "ignored", + content: "Full response", + }); + expect(event).toEqual({ + type: "message_complete", + messageId: "msg-1", content: "Full response", attachments: undefined, }); @@ -125,12 +137,22 @@ describe("parseAssistantEvent", () => { test("parses generation_handoff", () => { const event = parseAssistantEvent("generation_handoff", { messageId: "msg-1", - displayMessageId: "display-msg-1", }); expect(event).toEqual({ type: "generation_handoff", messageId: "msg-1", - displayMessageId: "display-msg-1", + attachments: undefined, + }); + }); + + test("ignores legacy displayMessageId on generation_handoff", () => { + const event = parseAssistantEvent("generation_handoff", { + messageId: "msg-1", + displayMessageId: "ignored", + }); + expect(event).toEqual({ + type: "generation_handoff", + messageId: "msg-1", attachments: undefined, }); }); diff --git a/apps/web/src/domains/chat/api/event-parser.ts b/apps/web/src/domains/chat/api/event-parser.ts index 5476622b013..c89f8d5660d 100644 --- a/apps/web/src/domains/chat/api/event-parser.ts +++ b/apps/web/src/domains/chat/api/event-parser.ts @@ -96,9 +96,6 @@ export function parseAssistantEvent( type: "message_complete", messageId: typeof data.messageId === "string" ? data.messageId : undefined, - ...(typeof data.displayMessageId === "string" - ? { displayMessageId: data.displayMessageId } - : {}), content: typeof data.content === "string" ? data.content : undefined, attachments: parseOutboundAttachments(data.attachments), @@ -109,9 +106,6 @@ export function parseAssistantEvent( type: "generation_handoff", messageId: typeof data.messageId === "string" ? data.messageId : undefined, - ...(typeof data.displayMessageId === "string" - ? { displayMessageId: data.displayMessageId } - : {}), attachments: parseOutboundAttachments(data.attachments), }; diff --git a/apps/web/src/domains/chat/api/event-types.ts b/apps/web/src/domains/chat/api/event-types.ts index 5cf9bb018da..396c1a51369 100644 --- a/apps/web/src/domains/chat/api/event-types.ts +++ b/apps/web/src/domains/chat/api/event-types.ts @@ -112,7 +112,6 @@ export interface AssistantOutboundAttachment { export interface MessageCompleteEvent { type: "message_complete"; messageId?: string; - displayMessageId?: string; content?: string; conversationId?: string; attachments?: AssistantOutboundAttachment[]; @@ -121,7 +120,6 @@ export interface MessageCompleteEvent { export interface GenerationHandoffEvent { type: "generation_handoff"; messageId?: string; - displayMessageId?: string; conversationId?: string; attachments?: AssistantOutboundAttachment[]; } diff --git a/apps/web/src/domains/chat/hooks/stream-message-updaters.test.ts b/apps/web/src/domains/chat/hooks/stream-message-updaters.test.ts index 85f2c1f6f0c..cd590407580 100644 --- a/apps/web/src/domains/chat/hooks/stream-message-updaters.test.ts +++ b/apps/web/src/domains/chat/hooks/stream-message-updaters.test.ts @@ -7,6 +7,7 @@ import { applyToolProgress, applyToolResult, createStreamingBubble, + finalizeMessageComplete, finalizeOnIdle, handleConversationError, stopStreaming, @@ -123,6 +124,134 @@ describe("appendTextDelta", () => { appendTextDelta(prev, "b"); expect(prev[0]!.content).toBe("a"); }); + + it("locks bubble.id to the first id seen — later text_deltas don't overwrite", () => { + // Multi-LLM-call turn: call 1's first text_delta opens the bubble with + // id=A, call 2's text_delta arrives with id=B. The bubble's id must + // stay A (anchor preservation) — the daemon's server-side merge will + // collapse the rows to the first row's id, and the live view must + // match. + const start = createStreamingBubble([userMsg], "Hello", "row-A"); + expect(start[1]!.id).toBe("row-A"); + const result = appendTextDelta(start, " world", "row-B"); + expect(result[1]!.id).toBe("row-A"); + expect(result[1]!.content).toBe("Hello world"); + }); + + it("backfills bubble.id when initial bubble had no id", () => { + const start = createStreamingBubble([userMsg], "Hello"); + expect(start[1]!.id).toBeUndefined(); + const result = appendTextDelta(start, " world", "row-A"); + expect(result[1]!.id).toBe("row-A"); + }); +}); + +// --------------------------------------------------------------------------- +// finalizeMessageComplete +// --------------------------------------------------------------------------- + +describe("finalizeMessageComplete", () => { + it("opens a new finalized assistant bubble when tail is a user message", () => { + const result = finalizeMessageComplete([userMsg], { + type: "message_complete", + conversationId: "c-1", + messageId: "row-A", + content: "done", + }); + + expect(result).toHaveLength(2); + expect(result[1]!.role).toBe("assistant"); + expect(result[1]!.id).toBe("row-A"); + expect(result[1]!.content).toBe("done"); + expect(result[1]!.isStreaming).toBeUndefined(); + }); + + it("opens a new bubble when prev is empty", () => { + const result = finalizeMessageComplete([], { + type: "message_complete", + conversationId: "c-1", + messageId: "row-A", + content: "first", + }); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("row-A"); + }); + + it("returns prev unchanged when tail is user and event has no content/attachments", () => { + const prev = [userMsg]; + const result = finalizeMessageComplete(prev, { + type: "message_complete", + conversationId: "c-1", + messageId: "row-A", + }); + expect(result).toBe(prev); + }); + + it("finalizes a streaming assistant tail and keeps tail.id (anchor preservation)", () => { + const msg = makeAssistantMsg({ id: "bubble-anchor", content: "hello" }); + const result = finalizeMessageComplete([userMsg, msg], { + type: "message_complete", + conversationId: "c-1", + messageId: "inner-row-id", + content: "hello world", + }); + + expect(result).toHaveLength(2); + expect(result[1]!.id).toBe("bubble-anchor"); + expect(result[1]!.isStreaming).toBe(false); + expect(result[1]!.content).toBe("hello world"); + }); + + it("finalizes running tool calls when finalizing", () => { + const toolCall: ChatMessageToolCall = { + id: "t-1", + toolName: "bash", + input: { command: "ls" }, + status: "running", + }; + const msg = makeAssistantMsg({ id: "bubble-A", toolCalls: [toolCall] }); + const result = finalizeMessageComplete([msg], { + type: "message_complete", + conversationId: "c-1", + messageId: "row-B", + }); + expect(result[0]!.toolCalls?.[0]!.status).toBe("completed"); + }); + + it("appends to a finalized assistant tail without overwriting its id (multi-LLM-call turn)", () => { + // Second message_complete in the same agent turn — tail is the bubble + // from the previous call (isStreaming already false). Should keep id. + const tail = makeAssistantMsg({ + id: "bubble-anchor", + content: "first call done", + isStreaming: false, + }); + const result = finalizeMessageComplete([userMsg, tail], { + type: "message_complete", + conversationId: "c-1", + messageId: "row-B", + content: "second call done", + }); + + expect(result).toHaveLength(2); + expect(result[1]!.id).toBe("bubble-anchor"); + expect(result[1]!.content).toBe("second call done"); + }); + + it("ignores legacy displayMessageId on the wire", () => { + // Inbound from an older daemon: a `displayMessageId` field on + // message_complete must be silently ignored — the new contract is + // messageId-only on the wire, anchor preservation is client-side. + const tail = makeAssistantMsg({ id: "bubble-anchor" }); + const legacyEvent = { + type: "message_complete" as const, + conversationId: "c-1", + messageId: "row-B", + displayMessageId: "row-A", + } as Parameters[1]; + const result = finalizeMessageComplete([tail], legacyEvent); + expect(result[0]!.id).toBe("bubble-anchor"); + }); }); // --------------------------------------------------------------------------- @@ -146,10 +275,11 @@ describe("stopStreaming", () => { expect(result).toBe(prev); }); - it("applies optional displayMessageId", () => { - const msg = makeAssistantMsg(); - const result = stopStreaming([msg], { displayMessageId: "d-1" }); - expect(result[0]!.id).toBe("d-1"); + it("keeps tail.id — never stamps a different id onto the bubble", () => { + const msg = makeAssistantMsg({ id: "bubble-anchor" }); + const result = stopStreaming([msg]); + expect(result[0]!.id).toBe("bubble-anchor"); + expect(result[0]!.isStreaming).toBe(false); }); }); diff --git a/apps/web/src/domains/chat/hooks/stream-message-updaters.ts b/apps/web/src/domains/chat/hooks/stream-message-updaters.ts index a33c5f6d004..1d431a2f535 100644 --- a/apps/web/src/domains/chat/hooks/stream-message-updaters.ts +++ b/apps/web/src/domains/chat/hooks/stream-message-updaters.ts @@ -9,10 +9,11 @@ * @see https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state */ -import type { DisplayAttachment, DisplayMessage } from "@/domains/chat/utils/reconcile.js"; +import type { DisplayMessage } from "@/domains/chat/utils/reconcile.js"; import type { Surface } from "@/domains/chat/types/types.js"; import { newStableId } from "@/domains/chat/utils/stable-id.js"; -import type { AllowlistOption, ChatMessageToolCall, DirectoryScopeOption, ScopeOption } from "@/domains/chat/api/event-types.js"; +import { toDisplayAttachments } from "@/domains/chat/api/event-parser.js"; +import type { AllowlistOption, ChatMessageToolCall, DirectoryScopeOption, MessageCompleteEvent, ScopeOption } from "@/domains/chat/api/event-types.js"; import type { ToolActivityMetadata } from "@/assistant/web-activity-types.js"; // --------------------------------------------------------------------------- @@ -116,7 +117,7 @@ export function appendTextDelta( { ...last, content: last.content + text, - id: messageId ?? last.id, + id: last.id ?? messageId, textSegments: segments, contentOrder: order, }, @@ -157,70 +158,68 @@ export function finalizeOnIdle(prev: DisplayMessage[]): DisplayMessage[] { // message_complete // --------------------------------------------------------------------------- -/** Finalize a streaming message with its completed content and attachments. */ +/** + * Apply a `message_complete` event to the message array. + * + * Decision is role-based on the tail: + * - tail is user (or array empty) → push a new finalized assistant bubble + * stamped with `event.messageId`. This covers the start-of-turn case + * where no streaming bubble was opened (e.g. tool-only or aux turns). + * - tail is assistant → finalize it: flip `isStreaming: false`, complete + * any running tool calls, merge in `event.content` / `event.attachments`, + * **keep `tail.id`**. Subsequent `message_complete` events from later + * LLM calls in the same agent turn fold into the same bubble — the + * mirror of the daemon's server-side merge which collapses to the first + * row's id. + */ export function finalizeMessageComplete( prev: DisplayMessage[], - opts: { - content?: string; - displayMessageId?: string; - attachments?: DisplayAttachment[]; - }, + event: MessageCompleteEvent, ): DisplayMessage[] { - const { content, displayMessageId, attachments } = opts; const last = prev[prev.length - 1]; + const attachments = toDisplayAttachments(event.attachments); - if (last?.role === "assistant" && last.isStreaming) { - const finalized = finalizeRunningToolCalls(last.toolCalls); - return [ - ...prev.slice(0, -1), - { - ...last, - isStreaming: false, - id: displayMessageId ?? last.id, - content: content || last.content, - ...(attachments ? { attachments } : {}), - ...(finalized ? { toolCalls: finalized } : {}), - }, - ]; - } - - if (content || attachments) { - if (displayMessageId && prev.some((m) => m.id === displayMessageId)) { - return prev.map((m) => - m.id === displayMessageId - ? { - ...m, - ...(content ? { content } : {}), - ...(attachments && !m.attachments ? { attachments } : {}), - } - : m, - ); - } + if (last?.role !== "assistant") { + if (!event.content && !attachments) return prev; return [ ...prev, { stableId: newStableId("assistant-complete"), - id: displayMessageId, + id: event.messageId, role: "assistant" as const, - content: content ?? "", + content: event.content ?? "", timestamp: Date.now(), ...(attachments ? { attachments } : {}), }, ]; } - return prev; + const finalized = finalizeRunningToolCalls(last.toolCalls); + return [ + ...prev.slice(0, -1), + { + ...last, + isStreaming: false, + // Keep `last.id` — the anchor was locked by the first text_delta / + // tool_use of the turn. The daemon may advance its internal row id + // across multiple LLM calls, but each call's `event.messageId` is + // just a constituent of this display row. + content: event.content || last.content, + ...(attachments ? { attachments } : {}), + ...(finalized ? { toolCalls: finalized } : {}), + }, + ]; } // --------------------------------------------------------------------------- // generation_handoff / stream stop // --------------------------------------------------------------------------- -/** Stop streaming on the last assistant message (handoff or cancellation). */ -export function stopStreaming( - prev: DisplayMessage[], - opts?: { displayMessageId?: string }, -): DisplayMessage[] { +/** + * Stop streaming on the tail assistant bubble (handoff or cancellation). + * Keeps `tail.id` — same anchor preservation as `finalizeMessageComplete`. + */ +export function stopStreaming(prev: DisplayMessage[]): DisplayMessage[] { const last = prev[prev.length - 1]; if (!last || last.role !== "assistant" || !last.isStreaming) return prev; @@ -229,7 +228,6 @@ export function stopStreaming( { ...last, isStreaming: false, - ...(opts?.displayMessageId ? { id: opts.displayMessageId } : {}), }, ]; } diff --git a/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts b/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts index a60fbaa109d..094536bed0c 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts @@ -6,7 +6,6 @@ import { stopStreaming, } from "@/domains/chat/hooks/stream-message-updaters.js"; import type { StreamHandlerContext } from "@/domains/chat/utils/stream-handlers/types.js"; -import { toDisplayAttachments } from "@/domains/chat/api/event-parser.js"; import type { AssistantActivityStateEvent, AssistantTextDeltaEvent, GenerationCancelledEvent, GenerationHandoffEvent, MessageCompleteEvent } from "@/domains/chat/api/event-types.js"; export function handleAssistantTextDelta( @@ -91,15 +90,7 @@ export function handleMessageComplete( epoch: number, ctx: StreamHandlerContext, ): void { - const completedAttachments = toDisplayAttachments(event.attachments); - const displayMessageId = event.displayMessageId ?? event.messageId; - ctx.setMessages((prev) => - finalizeMessageComplete(prev, { - content: event.content, - displayMessageId, - attachments: completedAttachments, - }), - ); + ctx.setMessages((prev) => finalizeMessageComplete(prev, event)); const turnPhaseBefore = ctx.getTurnState().phase; ctx.turnActions.completeTurn(); const convId = ctx.streamContextRef.current?.conversationId; @@ -109,21 +100,20 @@ export function handleMessageComplete( recordChatDiagnostic("sse_message_complete_handled", { convId, turnPhaseBefore, - displayMessageId, + messageId: event.messageId, hasContent: !!event.content, - hasAttachments: !!completedAttachments, + hasAttachments: !!event.attachments?.length, }); ctx.startReconciliationLoop(epoch); } export function handleGenerationHandoff( - event: GenerationHandoffEvent, + _event: GenerationHandoffEvent, ctx: StreamHandlerContext, ): void { ctx.cancelReconciliation(); ctx.turnActions.handoffGeneration(); - const displayMessageId = event.displayMessageId ?? event.messageId; - ctx.setMessages((prev) => stopStreaming(prev, { displayMessageId })); + ctx.setMessages((prev) => stopStreaming(prev)); } export function handleGenerationCancelled( diff --git a/assistant/src/__tests__/message-complete-display-id.test.ts b/assistant/src/__tests__/message-complete-display-id.test.ts deleted file mode 100644 index 3268c5a216f..00000000000 --- a/assistant/src/__tests__/message-complete-display-id.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; - -mock.module("../util/logger.js", () => ({ - getLogger: () => - new Proxy({} as Record, { - get: () => () => {}, - }), -})); - -mock.module("../config/loader.js", () => ({ - getConfig: () => ({ - skills: { - entries: {}, - load: { extraDirs: [], watch: true, watchDebounceMs: 250 }, - install: { nodeManager: "npm" }, - allowBundled: null, - remoteProviders: { - skillssh: { enabled: true }, - clawhub: { enabled: true }, - }, - remotePolicy: { - blockSuspicious: true, - blockMalware: true, - maxSkillsShRisk: "medium", - }, - }, - }), - loadConfig: () => ({}), -})); - -interface AddMessageCall { - conversationId: string; - role: string; - content: string; - metadata?: Record; -} - -const addMessageCalls: AddMessageCall[] = []; - -mock.module("../memory/conversation-crud.js", () => ({ - addMessage: ( - conversationId: string, - role: string, - content: string, - metadata?: Record, - ) => { - addMessageCalls.push({ conversationId, role, content, metadata }); - return { id: `mock-msg-${addMessageCalls.length}` }; - }, - getConversation: () => null, - getMessageById: () => null, - provenanceFromTrustContext: () => ({}), - updateMessageContent: () => {}, -})); - -mock.module("../memory/llm-request-log-store.js", () => ({ - backfillMessageIdOnLogs: () => {}, - recordRequestLog: () => {}, -})); - -mock.module("../memory/memory-recall-log-store.js", () => ({ - backfillMemoryRecallLogMessageId: () => {}, -})); - -mock.module("../memory/memory-v2-activation-log-store.js", () => ({ - backfillMemoryV2ActivationMessageId: () => {}, -})); - -mock.module("../memory/conversation-disk-view.js", () => ({ - syncMessageToDisk: () => {}, -})); - -import type { AgentEvent } from "../agent/loop.js"; -import type { - EventHandlerDeps, - EventHandlerState, -} from "../daemon/conversation-agent-loop-handlers.js"; -import { - createEventHandlerState, - getClientDisplayMessageId, - handleMessageComplete, -} from "../daemon/conversation-agent-loop-handlers.js"; - -const CONVERSATION_ID = "conv-display-id"; - -function makeDeps(): EventHandlerDeps { - return { - ctx: { - conversationId: CONVERSATION_ID, - currentTurnSurfaces: [], - provider: { name: "anthropic" }, - traceEmitter: { emit: () => {} }, - trustContext: { - sourceChannel: "vellum", - trustClass: "guardian", - }, - } as unknown as EventHandlerDeps["ctx"], - onEvent: () => {}, - reqId: "req-display-id", - isFirstMessage: false, - shouldGenerateTitle: false, - rlog: new Proxy({} as Record, { - get: () => () => {}, - }) as unknown as EventHandlerDeps["rlog"], - turnChannelContext: { - userMessageChannel: "vellum", - assistantMessageChannel: "vellum", - } as EventHandlerDeps["turnChannelContext"], - turnInterfaceContext: { - userMessageInterface: "web", - assistantMessageInterface: "web", - } as EventHandlerDeps["turnInterfaceContext"], - }; -} - -function makeMessageCompleteEvent( - content: Extract< - AgentEvent, - { type: "message_complete" } - >["message"]["content"], -): Extract { - return { - type: "message_complete", - message: { role: "assistant", content }, - }; -} - -describe("message_complete display identity", () => { - let state: EventHandlerState; - - beforeEach(() => { - addMessageCalls.length = 0; - state = createEventHandlerState(); - state.turnStartedAt = 1_700_000_000_000; - }); - - test("tracks the merged display id separately from the final row id", async () => { - await handleMessageComplete( - state, - makeDeps(), - makeMessageCompleteEvent([ - { - type: "tool_use", - id: "toolu_1", - name: "bash", - input: { command: "true" }, - }, - ]), - ); - - expect(state.firstAssistantMessageId).toBe("mock-msg-1"); - expect(state.lastAssistantMessageId).toBe("mock-msg-1"); - expect(getClientDisplayMessageId(state)).toBe("mock-msg-1"); - - state.pendingToolResults.set("toolu_1", { - content: "ok", - isError: false, - }); - - await handleMessageComplete( - state, - makeDeps(), - makeMessageCompleteEvent([{ type: "text", text: "done" }]), - ); - - expect(addMessageCalls.map((call) => call.role)).toEqual([ - "assistant", - "user", - "assistant", - ]); - expect(state.firstAssistantMessageId).toBe("mock-msg-1"); - expect(state.lastAssistantMessageId).toBe("mock-msg-3"); - expect(getClientDisplayMessageId(state)).toBe("mock-msg-1"); - }); -}); diff --git a/assistant/src/daemon/conversation-agent-loop-handlers.ts b/assistant/src/daemon/conversation-agent-loop-handlers.ts index af2e7fd29b8..b82141f1f51 100644 --- a/assistant/src/daemon/conversation-agent-loop-handlers.ts +++ b/assistant/src/daemon/conversation-agent-loop-handlers.ts @@ -144,11 +144,6 @@ export interface EventHandlerState { */ contextTooLargeError: unknown; providerErrorUserMessage: string | null; - /** - * First persisted assistant row in this run; history keeps this id when it - * merges tool-turn rows into one display bubble. - */ - firstAssistantMessageId: string | undefined; lastAssistantMessageId: string | undefined; readonly pendingToolResults: Map; readonly persistedToolUseIds: Set; @@ -249,7 +244,6 @@ export function createEventHandlerState(): EventHandlerState { imageTooLargeDetected: false, contextTooLargeError: null, providerErrorUserMessage: null, - firstAssistantMessageId: undefined, lastAssistantMessageId: undefined, pendingToolResults: new Map(), persistedToolUseIds: new Set(), @@ -275,12 +269,6 @@ export function createEventHandlerState(): EventHandlerState { }; } -export function getClientDisplayMessageId( - state: EventHandlerState, -): string | undefined { - return state.firstAssistantMessageId ?? state.lastAssistantMessageId; -} - // ── Shared Helper ──────────────────────────────────────────────────── // providerNameOverride should be supplied when the caller already knows the @@ -1111,7 +1099,6 @@ export async function handleMessageComplete( DEFAULT_TIMEOUTS.persistence, )) as PersistAddResult; const assistantMsg = assistantPersistResult.message; - state.firstAssistantMessageId ??= assistantMsg.id; state.lastAssistantMessageId = assistantMsg.id; // Backfill message_id on all LLM request logs from this turn. diff --git a/assistant/src/daemon/conversation-agent-loop.ts b/assistant/src/daemon/conversation-agent-loop.ts index 09c692b6763..54ed8cab298 100644 --- a/assistant/src/daemon/conversation-agent-loop.ts +++ b/assistant/src/daemon/conversation-agent-loop.ts @@ -162,7 +162,6 @@ import { createEventHandlerState, dispatchAgentEvent, type EventHandlerDeps, - getClientDisplayMessageId, } from "./conversation-agent-loop-handlers.js"; import { approveHostAttachmentRead, @@ -3254,7 +3253,6 @@ export async function runAgentLoopImpl( ctx.lastAssistantAttachments = assistantAttachments; ctx.lastAttachmentWarnings = attachmentResult.directiveWarnings; syncLastAssistantMessageToDisk(); - const clientDisplayMessageId = getClientDisplayMessageId(state); // Re-check: the user may have cancelled during attachment resolution if (abortController.signal.aborted) { @@ -3300,9 +3298,6 @@ export async function runAgentLoopImpl( ...(state.lastAssistantMessageId ? { messageId: state.lastAssistantMessageId } : {}), - ...(clientDisplayMessageId - ? { displayMessageId: clientDisplayMessageId } - : {}), }); publishLoopMessagesChanged(); } else { @@ -3327,9 +3322,6 @@ export async function runAgentLoopImpl( ...(state.lastAssistantMessageId ? { messageId: state.lastAssistantMessageId } : {}), - ...(clientDisplayMessageId - ? { displayMessageId: clientDisplayMessageId } - : {}), }); publishLoopMessagesChanged(); diff --git a/assistant/src/daemon/message-types/conversations.ts b/assistant/src/daemon/message-types/conversations.ts index 213fd521a24..863df819f68 100644 --- a/assistant/src/daemon/message-types/conversations.ts +++ b/assistant/src/daemon/message-types/conversations.ts @@ -321,14 +321,12 @@ export interface GenerationHandoff { queuedCount: number; attachments?: UserMessageAttachment[]; attachmentWarnings?: string[]; - /** Database ID of the final persisted assistant row, if any. */ - messageId?: string; /** - * Database ID used by clients for the rendered assistant bubble. Tool turns - * may persist multiple assistant rows; this matches the history row that - * survives query-time merging. + * Database ID of the completed assistant turn — the id that survives + * query-time merging when a turn persists multiple assistant rows. Matches + * the row the messages route returns. */ - displayMessageId?: string; + messageId?: string; } export interface ModelInfo { diff --git a/assistant/src/daemon/message-types/messages.ts b/assistant/src/daemon/message-types/messages.ts index 540f53e8670..c4abfe64e86 100644 --- a/assistant/src/daemon/message-types/messages.ts +++ b/assistant/src/daemon/message-types/messages.ts @@ -304,14 +304,12 @@ export interface MessageComplete { conversationId?: string; attachments?: UserMessageAttachment[]; attachmentWarnings?: string[]; - /** Database ID of the final persisted assistant row, if any. */ - messageId?: string; /** - * Database ID used by clients for the rendered assistant bubble. Tool turns - * may persist multiple assistant rows; this matches the history row that - * survives query-time merging. + * Database ID of the completed assistant turn — the id that survives + * query-time merging when a turn persists multiple assistant rows. Matches + * the row the messages route returns. */ - displayMessageId?: string; + messageId?: string; /** * Distinguishes a real main-turn completion from auxiliary events such as * call transcripts, call summaries, and watch notifier outputs. Clients