fix(desktop): settle transform_llm_output final onto the streamed bubble (#96465)

Renderer half of #96467 (7eeb0d4b03), hand-applied onto main's
use-message-stream: completeAssistantMessage takes response_transformed
(appended after persistedTurn) and settles a transformed final in place even
with no prefix relationship, gated on sawAssistantPayload. Contributor vitest
kept (trimmed to the core invariant).
This commit is contained in:
orcunulutas
2026-09-24 17:09:11 +05:30
committed by kshitij
parent 27c9d37730
commit caa8ee61c7
5 changed files with 91 additions and 4 deletions

View File

@@ -371,7 +371,8 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
payload?.response_previewed,
failure,
occurredAt,
payload?.persisted_turn
payload?.persisted_turn,
payload?.response_transformed
)
// Onboarding's first build: between turns is the only moment Setup may

View File

@@ -21,7 +21,8 @@ export interface GatewayEventDeps {
responsePreviewed?: boolean,
failure?: { error: string; partial: boolean },
occurredAt?: number,
persistedTurn?: PersistedTurn | null
persistedTurn?: PersistedTurn | null,
responseTransformed?: boolean
) => void
failAssistantMessage: (
sessionId: string,

View File

@@ -623,7 +623,8 @@ export function useMessageStream({
responsePreviewed?: boolean,
failure?: { error: string; partial: boolean; surface?: ErrorSurface | null },
occurredAt = Date.now() / 1000,
persistedTurn?: PersistedTurn | null
persistedTurn?: PersistedTurn | null,
responseTransformed?: boolean
) => {
let shouldHydrate = false
@@ -800,7 +801,17 @@ export function useMessageStream({
(finalText === existingText || finalText.startsWith(existingText) || existingText.startsWith(finalText))
)
if (existing.pending || (!interimBoundaryPending && finalText && existingText === finalText)) {
// A transform_llm_output hook rewrites the final after streaming (e.g. pseudonym
// restore), so it may share no prefix with the streamed text yet is still this
// turn's authoritative reply: settle it in place rather than append a duplicate.
// Gated on sawAssistantPayload so a stale completion can't overwrite an older bubble.
const transformedCurrentReply = Boolean(responseTransformed && state.sawAssistantPayload && finalText)
if (
existing.pending ||
transformedCurrentReply ||
(!interimBoundaryPending && finalText && existingText === finalText)
) {
nextMessages = settleAt(index)
} else if ((interimBoundaryPending && responsePreviewed) || finalContinuesInterim) {
// Settle the interim in place instead of creating a duplicate —

View File

@@ -0,0 +1,71 @@
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { chatMessageText } from '@/lib/chat-messages'
import { clearSessionTodos } from '@/store/todos'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
let stream: MessageStreamHarness
function mountStream() {
stream = renderMessageStream(SID)
}
const start = () => act(() => stream.handleEvent({ payload: {}, session_id: SID, type: 'message.start' }))
const delta = (text: string) =>
act(() => stream.handleEvent({ payload: { text }, session_id: SID, type: 'message.delta' }))
const interim = (text: string) =>
act(() => stream.handleEvent({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
const completeTransformed = (text: string) =>
act(() =>
stream.handleEvent({ payload: { text, response_transformed: true }, session_id: SID, type: 'message.complete' })
)
function getState(): ClientSessionState {
return stream.state()
}
function assistantTexts(): string[] {
const state = getState()
return state.messages
.filter(m => m.role === 'assistant' && !m.hidden)
.map(m => chatMessageText(m))
.filter(Boolean)
}
describe('useMessageStream response_transformed settlement', () => {
beforeEach(() => {
clearSessionTodos(SID)
})
afterEach(() => {
cleanup()
clearSessionTodos(SID)
vi.restoreAllMocks()
})
it('replaces the streamed bubble with transformed final text that shares no prefix', async () => {
mountStream()
await start()
// A transform_llm_output plugin hook (e.g. pseudonym restore) rewrites the
// final text after streaming finishes. The rewritten text shares NO prefix
// relationship with what was streamed: the prefix-continuity heuristic must
// not reject it — it is this turn's authoritative reply.
await delta('TOKEN_1')
await interim('TOKEN_1')
await completeTransformed('example-service.internal')
const texts = assistantTexts()
expect(texts).toHaveLength(1)
expect(texts[0]).toBe('example-service.internal')
expect(texts).not.toContain('TOKEN_1')
})
})

View File

@@ -204,6 +204,9 @@ export type GatewayEventPayload = {
// message.complete — signals the final text was already previewed via
// interim_assistant_callback, so the UI can settle instead of duplicating.
response_previewed?: boolean
// message.complete — a transform_llm_output hook rewrote the final text after streaming;
// it authoritatively replaces the current turn's streamed text even without a prefix match.
response_transformed?: boolean
persisted_turn?: PersistedTurn | null
// message.complete — history-commit note the gateway surfaced instead of dropping.
warning?: string