diff --git a/CHANGELOG.md b/CHANGELOG.md index a7bdb43ebea..85476578466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,7 @@ Docs: https://docs.openclaw.ai - Channels/ACP bindings: time out configured binding readiness checks instead of letting Discord preflight hang forever when an ACP target never settles. Fixes #68776. - Control UI: hide the chat loading skeleton during background history reloads when existing messages or active stream content are already visible, avoiding reload flashes on high-latency local gateways. Fixes #71844. Thanks @WolvenRA. - Control UI: keep locally optimistic chat messages visible when a history reload temporarily returns empty, avoiding lost first-turn messages on high-latency gateways. Fixes #71878. Thanks @WolvenRA. +- Control UI: keep chat history limits based on visible messages after filtering heartbeat and control-only transcript rows, so recent hidden entries no longer make older visible replies disappear. Thanks @WolvenRA. - Agents/images: scrub old `[media attached: ...]`, `[Image: source: ...]`, and `media://inbound/...` markers from pruned model replay context so stale media refs are not rehydrated as fresh prompt images. Fixes #71868. Thanks diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index ce3dc3cc0ff..fc026b537e8 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -50,6 +50,14 @@ function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage | undefi return provider === "openclaw" && (model === "delivery-mirror" || model === "gateway-injected"); } +function isOpenAiResponsesAssistantMessage(message: AgentMessage | undefined): boolean { + if (!message || message.role !== "assistant") { + return false; + } + const api = normalizeOptionalString((message as { api?: unknown }).api) ?? ""; + return api === "openai-responses" || api === "azure-openai-responses"; +} + function resolveAssistantStreamItemId(params: { contentIndex?: unknown; message: AgentMessage | undefined; @@ -481,7 +489,12 @@ export function handleMessageUpdate( contentIndex: assistantRecord?.contentIndex, message: partialAssistant, }); - if (deliveryPhase && streamItemId) { + const isPhasePendingOpenAiResponsesTextItem = + evtType !== "text_end" && + !deliveryPhase && + Boolean(streamItemId) && + isOpenAiResponsesAssistantMessage(partialAssistant); + if ((deliveryPhase || isPhasePendingOpenAiResponsesTextItem) && streamItemId) { const previousStreamItemId = ctx.state.lastAssistantStreamItemId; if (previousStreamItemId && previousStreamItemId !== streamItemId) { void ctx.flushBlockReplyBuffer({ assistantMessageIndex: ctx.state.assistantMessageIndex }); @@ -493,6 +506,9 @@ export function handleMessageUpdate( if (deliveryPhase === "commentary") { return; } + if (isPhasePendingOpenAiResponsesTextItem) { + return; + } const phaseAwareVisibleText = coerceChatContentText( extractAssistantVisibleText(partialAssistant), ).trim(); @@ -584,7 +600,7 @@ export function handleMessageUpdate( delta: deltaText, replace, mediaUrls, - phase: assistantPhase, + phase: deliveryPhase ?? assistantPhase, }); emitAgentEvent({ runId: ctx.params.runId, diff --git a/src/agents/tools/embedded-gateway-stub.runtime.ts b/src/agents/tools/embedded-gateway-stub.runtime.ts index cabc2348b09..59d65437f60 100644 --- a/src/agents/tools/embedded-gateway-stub.runtime.ts +++ b/src/agents/tools/embedded-gateway-stub.runtime.ts @@ -1,6 +1,9 @@ export { resolveSessionAgentId } from "../../agents/agent-scope.js"; export { loadConfig } from "../../config/config.js"; -export { stripEnvelopeFromMessages } from "../../gateway/chat-sanitize.js"; +export { + projectRecentChatDisplayMessages, + resolveEffectiveChatHistoryMaxChars, +} from "../../gateway/chat-display-projection.js"; export { augmentChatHistoryWithCliSessionImports } from "../../gateway/cli-session-history.js"; export { getMaxChatHistoryMessagesBytes } from "../../gateway/server-constants.js"; export { @@ -8,8 +11,6 @@ export { CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, enforceChatHistoryFinalBudget, replaceOversizedChatHistoryMessages, - resolveEffectiveChatHistoryMaxChars, - sanitizeChatHistoryMessages, } from "../../gateway/server-methods/chat.js"; export { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; export { diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts index c8647faaf70..91644a3c7f1 100644 --- a/src/agents/tools/embedded-gateway-stub.test.ts +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -4,6 +4,27 @@ import { createEmbeddedCallGateway } from "./embedded-gateway-stub.js"; const runtime = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ agents: { list: [{ id: "main", default: true }] } })), resolveSessionKeyFromResolveParams: vi.fn(), + resolveSessionAgentId: vi.fn(() => "main"), + loadSessionEntry: vi.fn(() => ({ + cfg: {}, + storePath: "/tmp/openclaw-sessions.json", + entry: { sessionId: "sess-main" }, + })), + resolveSessionModelRef: vi.fn(() => ({ provider: "openai" })), + readSessionMessages: vi.fn((): unknown[] => []), + augmentChatHistoryWithCliSessionImports: vi.fn( + ({ localMessages }: { localMessages?: unknown[] }) => localMessages ?? [], + ), + resolveEffectiveChatHistoryMaxChars: vi.fn(() => 100_000), + projectRecentChatDisplayMessages: vi.fn((messages: unknown[]): unknown[] => messages), + augmentChatHistoryWithCanvasBlocks: vi.fn((messages: unknown[]) => messages), + getMaxChatHistoryMessagesBytes: vi.fn(() => 100_000), + CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES: 100_000, + replaceOversizedChatHistoryMessages: vi.fn(({ messages }: { messages: unknown[] }) => ({ + messages, + })), + capArrayByJsonBytes: vi.fn((items: unknown[]) => ({ items })), + enforceChatHistoryFinalBudget: vi.fn(({ messages }: { messages: unknown[] }) => ({ messages })), })); vi.mock("./embedded-gateway-stub.runtime.js", () => runtime); @@ -12,6 +33,8 @@ describe("embedded gateway stub", () => { beforeEach(() => { runtime.loadConfig.mockClear(); runtime.resolveSessionKeyFromResolveParams.mockReset(); + runtime.projectRecentChatDisplayMessages.mockClear(); + runtime.readSessionMessages.mockClear(); }); it("resolves sessions through the gateway session resolver", async () => { @@ -48,4 +71,45 @@ describe("embedded gateway stub", () => { }), ).rejects.toThrow("No session found: missing"); }); + + it("projects embedded chat history through the shared display projector", async () => { + const rawMessages = [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ]; + const projectedMessages = [{ role: "assistant", content: "hi" }]; + runtime.readSessionMessages.mockReturnValueOnce(rawMessages); + runtime.projectRecentChatDisplayMessages.mockReturnValueOnce(projectedMessages); + + const callGateway = createEmbeddedCallGateway(); + const result = await callGateway<{ messages: unknown[] }>({ + method: "chat.history", + params: { sessionKey: "agent:main:main" }, + }); + + expect(runtime.projectRecentChatDisplayMessages).toHaveBeenCalledWith(rawMessages, { + maxChars: 100_000, + maxMessages: 200, + }); + expect(result.messages).toEqual(projectedMessages); + }); + + it("passes the full raw history to projection before limiting visible messages", async () => { + const rawMessages = [ + { role: "user", content: "visible older" }, + { role: "assistant", content: "hidden newer" }, + ]; + runtime.readSessionMessages.mockReturnValueOnce(rawMessages); + + const callGateway = createEmbeddedCallGateway(); + await callGateway<{ messages: unknown[] }>({ + method: "chat.history", + params: { sessionKey: "agent:main:main", limit: 1 }, + }); + + expect(runtime.projectRecentChatDisplayMessages).toHaveBeenCalledWith(rawMessages, { + maxChars: 100_000, + maxMessages: 1, + }); + }); }); diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts index 594c08fa7b4..43314ed5068 100644 --- a/src/agents/tools/embedded-gateway-stub.ts +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -9,7 +9,6 @@ type EmbeddedCallGateway = >(opts: CallGatewayOption interface EmbeddedGatewayRuntime { resolveSessionAgentId: (opts: { sessionKey: string; config: OpenClawConfig }) => string; loadConfig: () => OpenClawConfig; - stripEnvelopeFromMessages: (msgs: unknown[]) => unknown[]; augmentChatHistoryWithCliSessionImports: (opts: { entry: unknown; provider: string | undefined; @@ -26,7 +25,10 @@ interface EmbeddedGatewayRuntime { maxSingleMessageBytes: number; }) => { messages: unknown[] }; resolveEffectiveChatHistoryMaxChars: (cfg: OpenClawConfig) => number; - sanitizeChatHistoryMessages: (msgs: unknown[], maxChars: number) => unknown[]; + projectRecentChatDisplayMessages: ( + msgs: unknown[], + opts?: { maxChars?: number; maxMessages?: number }, + ) => unknown[]; capArrayByJsonBytes: (items: unknown[], maxBytes: number) => { items: unknown[] }; listSessionsFromStore: (opts: { cfg: OpenClawConfig; @@ -124,10 +126,11 @@ async function handleChatHistory(params: Record): Promise<{ const max = Math.min(hardMax, requested); const effectiveMaxChars = rt.resolveEffectiveChatHistoryMaxChars(cfg); - const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; - const sanitized = rt.stripEnvelopeFromMessages(sliced); const normalized = rt.augmentChatHistoryWithCanvasBlocks( - rt.sanitizeChatHistoryMessages(sanitized, effectiveMaxChars), + rt.projectRecentChatDisplayMessages(rawMessages, { + maxChars: effectiveMaxChars, + maxMessages: max, + }), ); const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes(); diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts new file mode 100644 index 00000000000..354dd4811e7 --- /dev/null +++ b/src/gateway/chat-display-projection.ts @@ -0,0 +1,539 @@ +import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js"; +import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; +import { + parseAssistantTextSignature, + resolveAssistantMessagePhase, +} from "../shared/chat-message-content.js"; +import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; +import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; +import { isSuppressedControlReplyText } from "./control-reply-text.js"; + +export const DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS = 8_000; + +type RoleContentMessage = { + role: string; + content?: unknown; +}; + +export function resolveEffectiveChatHistoryMaxChars( + cfg: { gateway?: { webchat?: { chatHistoryMaxChars?: number } } }, + maxChars?: number, +): number { + if (typeof maxChars === "number") { + return maxChars; + } + if (typeof cfg.gateway?.webchat?.chatHistoryMaxChars === "number") { + return cfg.gateway.webchat.chatHistoryMaxChars; + } + return DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS; +} + +function truncateChatHistoryText( + text: string, + maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, +): { text: string; truncated: boolean } { + if (text.length <= maxChars) { + return { text, truncated: false }; + } + return { + text: `${text.slice(0, maxChars)}\n...(truncated)...`, + truncated: true, + }; +} + +export function isToolHistoryBlockType(type: unknown): boolean { + if (typeof type !== "string") { + return false; + } + const normalized = type.trim().toLowerCase(); + return ( + normalized === "toolcall" || + normalized === "tool_call" || + normalized === "tooluse" || + normalized === "tool_use" || + normalized === "toolresult" || + normalized === "tool_result" + ); +} + +function sanitizeChatHistoryContentBlock( + block: unknown, + opts?: { preserveExactToolPayload?: boolean; maxChars?: number }, +): { block: unknown; changed: boolean } { + if (!block || typeof block !== "object") { + return { block, changed: false }; + } + const entry = { ...(block as Record) }; + let changed = false; + const preserveExactToolPayload = + opts?.preserveExactToolPayload === true || isToolHistoryBlockType(entry.type); + const maxChars = opts?.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS; + if (typeof entry.text === "string") { + const stripped = stripInlineDirectiveTagsForDisplay(entry.text); + if (preserveExactToolPayload) { + entry.text = stripped.text; + changed ||= stripped.changed; + } else { + const res = truncateChatHistoryText(stripped.text, maxChars); + entry.text = res.text; + changed ||= stripped.changed || res.truncated; + } + } + if (typeof entry.content === "string") { + const stripped = stripInlineDirectiveTagsForDisplay(entry.content); + if (preserveExactToolPayload) { + entry.content = stripped.text; + changed ||= stripped.changed; + } else { + const res = truncateChatHistoryText(stripped.text, maxChars); + entry.content = res.text; + changed ||= stripped.changed || res.truncated; + } + } + if (typeof entry.partialJson === "string" && !preserveExactToolPayload) { + const res = truncateChatHistoryText(entry.partialJson, maxChars); + entry.partialJson = res.text; + changed ||= res.truncated; + } + if (typeof entry.arguments === "string" && !preserveExactToolPayload) { + const res = truncateChatHistoryText(entry.arguments, maxChars); + entry.arguments = res.text; + changed ||= res.truncated; + } + if (typeof entry.thinking === "string") { + const res = truncateChatHistoryText(entry.thinking, maxChars); + entry.thinking = res.text; + changed ||= res.truncated; + } + if ("thinkingSignature" in entry) { + delete entry.thinkingSignature; + changed = true; + } + const type = typeof entry.type === "string" ? entry.type : ""; + if (type === "image" && typeof entry.data === "string") { + const bytes = Buffer.byteLength(entry.data, "utf8"); + delete entry.data; + entry.omitted = true; + entry.bytes = bytes; + changed = true; + } + if (type === "audio" && entry.source && typeof entry.source === "object") { + const source = { ...(entry.source as Record) }; + if (source.type === "base64" && typeof source.data === "string") { + const bytes = Buffer.byteLength(source.data, "utf8"); + delete source.data; + source.omitted = true; + source.bytes = bytes; + entry.source = source; + changed = true; + } + } + return { block: changed ? entry : block, changed }; +} + +function sanitizeAssistantPhasedContentBlocks(content: unknown[]): { + content: unknown[]; + changed: boolean; +} { + const hasExplicitPhasedText = content.some((block) => { + if (!block || typeof block !== "object") { + return false; + } + const entry = block as { type?: unknown; textSignature?: unknown }; + return ( + entry.type === "text" && Boolean(parseAssistantTextSignature(entry.textSignature)?.phase) + ); + }); + if (!hasExplicitPhasedText) { + return { content, changed: false }; + } + const filtered = content.filter((block) => { + if (!block || typeof block !== "object") { + return true; + } + const entry = block as { type?: unknown; textSignature?: unknown }; + if (entry.type !== "text") { + return true; + } + return parseAssistantTextSignature(entry.textSignature)?.phase === "final_answer"; + }); + return { + content: filtered, + changed: filtered.length !== content.length, + }; +} + +function toFiniteNumber(x: unknown): number | undefined { + return typeof x === "number" && Number.isFinite(x) ? x : undefined; +} + +function sanitizeCost(raw: unknown): { total?: number } | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const c = raw as Record; + const total = toFiniteNumber(c.total); + return total !== undefined ? { total } : undefined; +} + +function sanitizeUsage(raw: unknown): Record | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const u = raw as Record; + const out: Record = {}; + const knownFields = [ + "input", + "output", + "totalTokens", + "inputTokens", + "outputTokens", + "cacheRead", + "cacheWrite", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ]; + + for (const k of knownFields) { + const n = toFiniteNumber(u[k]); + if (n !== undefined) { + out[k] = n; + } + } + + if ("cost" in u && u.cost != null && typeof u.cost === "object") { + const sanitizedCost = sanitizeCost(u.cost); + if (sanitizedCost) { + (out as Record).cost = sanitizedCost; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function sanitizeChatHistoryMessage( + message: unknown, + maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, +): { message: unknown; changed: boolean } { + if (!message || typeof message !== "object") { + return { message, changed: false }; + } + const entry = { ...(message as Record) }; + let changed = false; + const role = typeof entry.role === "string" ? entry.role.toLowerCase() : ""; + const preserveExactToolPayload = + role === "toolresult" || + role === "tool_result" || + role === "tool" || + role === "function" || + typeof entry.toolName === "string" || + typeof entry.tool_name === "string" || + typeof entry.toolCallId === "string" || + typeof entry.tool_call_id === "string"; + + if ("details" in entry) { + delete entry.details; + changed = true; + } + + if (entry.role !== "assistant") { + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; + } + } else { + if ("usage" in entry) { + const sanitized = sanitizeUsage(entry.usage); + if (sanitized) { + entry.usage = sanitized; + } else { + delete entry.usage; + } + changed = true; + } + if ("cost" in entry) { + const sanitized = sanitizeCost(entry.cost); + if (sanitized) { + entry.cost = sanitized; + } else { + delete entry.cost; + } + changed = true; + } + } + + if (typeof entry.content === "string") { + const stripped = stripInlineDirectiveTagsForDisplay(entry.content); + if (preserveExactToolPayload) { + entry.content = stripped.text; + changed ||= stripped.changed; + } else { + const res = truncateChatHistoryText(stripped.text, maxChars); + entry.content = res.text; + changed ||= stripped.changed || res.truncated; + } + } else if (Array.isArray(entry.content)) { + const updated = entry.content.map((block) => + sanitizeChatHistoryContentBlock(block, { preserveExactToolPayload, maxChars }), + ); + if (updated.some((item) => item.changed)) { + entry.content = updated.map((item) => item.block); + changed = true; + } + if (entry.role === "assistant" && Array.isArray(entry.content)) { + const sanitizedPhases = sanitizeAssistantPhasedContentBlocks(entry.content); + if (sanitizedPhases.changed) { + entry.content = sanitizedPhases.content; + changed = true; + } + } + } + + if (typeof entry.text === "string") { + const stripped = stripInlineDirectiveTagsForDisplay(entry.text); + if (preserveExactToolPayload) { + entry.text = stripped.text; + changed ||= stripped.changed; + } else { + const res = truncateChatHistoryText(stripped.text, maxChars); + entry.text = res.text; + changed ||= stripped.changed || res.truncated; + } + } + + return { message: changed ? entry : message, changed }; +} + +function extractAssistantTextForSilentCheck(message: unknown): string | undefined { + if (!message || typeof message !== "object") { + return undefined; + } + const entry = message as Record; + if (entry.role !== "assistant") { + return undefined; + } + if (typeof entry.text === "string") { + return entry.text; + } + if (typeof entry.content === "string") { + return entry.content; + } + if (!Array.isArray(entry.content) || entry.content.length === 0) { + return undefined; + } + + const texts: string[] = []; + for (const block of entry.content) { + if (!block || typeof block !== "object") { + return undefined; + } + const typed = block as { type?: unknown; text?: unknown }; + if (typed.type !== "text" || typeof typed.text !== "string") { + return undefined; + } + texts.push(typed.text); + } + return texts.length > 0 ? texts.join("\n") : undefined; +} + +function hasAssistantNonTextContent(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return false; + } + return content.some( + (block) => block && typeof block === "object" && (block as { type?: unknown }).type !== "text", + ); +} + +function shouldDropAssistantHistoryMessage(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const entry = message as { role?: unknown }; + if (entry.role !== "assistant") { + return false; + } + if (resolveAssistantMessagePhase(message) === "commentary") { + return true; + } + const text = extractAssistantTextForSilentCheck(message); + if (text === undefined || !isSuppressedControlReplyText(text)) { + return false; + } + return !hasAssistantNonTextContent(message); +} + +export function sanitizeChatHistoryMessages( + messages: unknown[], + maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, +): unknown[] { + if (messages.length === 0) { + return messages; + } + let changed = false; + const next: unknown[] = []; + for (const message of messages) { + if (shouldDropAssistantHistoryMessage(message)) { + changed = true; + continue; + } + const res = sanitizeChatHistoryMessage(message, maxChars); + changed ||= res.changed; + if (shouldDropAssistantHistoryMessage(res.message)) { + changed = true; + continue; + } + next.push(res.message); + } + return changed ? next : messages; +} + +function asRoleContentMessage(message: Record): RoleContentMessage | null { + const role = typeof message.role === "string" ? message.role.toLowerCase() : ""; + if (!role) { + return null; + } + return { + role, + ...(message.content !== undefined + ? { content: message.content } + : message.text !== undefined + ? { content: message.text } + : {}), + }; +} + +function isEmptyTextOnlyContent(content: unknown): boolean { + if (typeof content === "string") { + return content.trim().length === 0; + } + if (!Array.isArray(content)) { + return false; + } + if (content.length === 0) { + return true; + } + let sawText = false; + for (const block of content) { + if (!block || typeof block !== "object") { + return false; + } + const entry = block as { type?: unknown; text?: unknown }; + if (entry.type !== "text") { + return false; + } + sawText = true; + if (typeof entry.text !== "string" || entry.text.trim().length > 0) { + return false; + } + } + return sawText; +} + +function shouldHideProjectedHistoryMessage(message: Record): boolean { + const roleContent = asRoleContentMessage(message); + if (!roleContent) { + return false; + } + if (roleContent.role === "user" && isEmptyTextOnlyContent(message.content ?? message.text)) { + return true; + } + if (roleContent.role === "assistant" && isEmptyTextOnlyContent(message.content ?? message.text)) { + return false; + } + if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT)) { + return true; + } + return isHeartbeatOkResponse(roleContent); +} + +function toProjectedMessages(messages: unknown[]): Array> { + return messages.filter( + (message): message is Record => + Boolean(message) && typeof message === "object" && !Array.isArray(message), + ); +} + +function filterVisibleProjectedHistoryMessages( + messages: Array>, +): Array> { + if (messages.length === 0) { + return messages; + } + let changed = false; + const visible: Array> = []; + for (let i = 0; i < messages.length; i++) { + const current = messages[i]; + if (!current) { + continue; + } + const currentRoleContent = asRoleContentMessage(current); + const next = messages[i + 1]; + const nextRoleContent = next ? asRoleContentMessage(next) : null; + if ( + currentRoleContent && + nextRoleContent && + isHeartbeatUserMessage(currentRoleContent, HEARTBEAT_PROMPT) && + isHeartbeatOkResponse(nextRoleContent) + ) { + changed = true; + i++; + continue; + } + if (shouldHideProjectedHistoryMessage(current)) { + changed = true; + continue; + } + visible.push(current); + } + return changed ? visible : messages; +} + +export function projectChatDisplayMessages( + messages: unknown[], + options?: { maxChars?: number; stripEnvelope?: boolean }, +): Array> { + const source = options?.stripEnvelope === false ? messages : stripEnvelopeFromMessages(messages); + return filterVisibleProjectedHistoryMessages( + toProjectedMessages( + sanitizeChatHistoryMessages(source, options?.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS), + ), + ); +} + +export function limitChatDisplayMessages(messages: T[], maxMessages?: number): T[] { + if ( + typeof maxMessages !== "number" || + !Number.isFinite(maxMessages) || + maxMessages <= 0 || + messages.length <= maxMessages + ) { + return messages; + } + return messages.slice(-Math.floor(maxMessages)); +} + +export function projectRecentChatDisplayMessages( + messages: unknown[], + options?: { maxChars?: number; maxMessages?: number; stripEnvelope?: boolean }, +): Array> { + return limitChatDisplayMessages( + projectChatDisplayMessages(messages, options), + options?.maxMessages, + ); +} + +export function projectChatDisplayMessage( + message: unknown, + options?: { maxChars?: number; stripEnvelope?: boolean }, +): Record | undefined { + return projectChatDisplayMessages([message], options)[0]; +} diff --git a/src/gateway/live-chat-projector.ts b/src/gateway/live-chat-projector.ts new file mode 100644 index 00000000000..4a8e63ef887 --- /dev/null +++ b/src/gateway/live-chat-projector.ts @@ -0,0 +1,102 @@ +import { stripInternalRuntimeContext } from "../agents/internal-runtime-context.js"; +import { + SILENT_REPLY_TOKEN, + startsWithSilentToken, + stripLeadingSilentToken, +} from "../auto-reply/tokens.js"; +import { resolveAssistantEventPhase } from "../shared/chat-message-content.js"; +import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; +import { + isSuppressedControlReplyLeadFragment, + isSuppressedControlReplyText, +} from "./control-reply-text.js"; + +export { resolveAssistantEventPhase } from "../shared/chat-message-content.js"; + +function appendUniqueSuffix(base: string, suffix: string): string { + if (!suffix) { + return base; + } + if (!base) { + return suffix; + } + if (base.endsWith(suffix)) { + return base; + } + const maxOverlap = Math.min(base.length, suffix.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + if (base.slice(-overlap) === suffix.slice(0, overlap)) { + return base + suffix.slice(overlap); + } + } + return base + suffix; +} + +export function resolveMergedAssistantText(params: { + previousText: string; + nextText: string; + nextDelta: string; +}): string { + const { previousText, nextText, nextDelta } = params; + if (nextText && previousText) { + if (nextText.startsWith(previousText)) { + return nextText; + } + if (previousText.startsWith(nextText) && !nextDelta) { + return previousText; + } + } + if (nextDelta) { + return appendUniqueSuffix(previousText, nextDelta); + } + if (nextText) { + return nextText; + } + return previousText; +} + +export function normalizeLiveAssistantEventText(params: { text: string; delta?: unknown }): { + text: string; + delta: string; +} { + return { + text: stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(params.text).text), + delta: + typeof params.delta === "string" + ? stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(params.delta).text) + : "", + }; +} + +export function projectLiveAssistantBufferedText( + rawText: string, + options?: { suppressLeadFragments?: boolean }, +): { + text: string; + suppress: boolean; + pendingLeadFragment: boolean; +} { + if (!rawText) { + return { text: "", suppress: true, pendingLeadFragment: false }; + } + if (isSuppressedControlReplyText(rawText)) { + return { text: "", suppress: true, pendingLeadFragment: false }; + } + if (options?.suppressLeadFragments !== false && isSuppressedControlReplyLeadFragment(rawText)) { + return { text: rawText, suppress: true, pendingLeadFragment: true }; + } + const text = startsWithSilentToken(rawText, SILENT_REPLY_TOKEN) + ? stripLeadingSilentToken(rawText, SILENT_REPLY_TOKEN) + : rawText; + if (!text || isSuppressedControlReplyText(text)) { + return { text: "", suppress: true, pendingLeadFragment: false }; + } + if (options?.suppressLeadFragments !== false && isSuppressedControlReplyLeadFragment(text)) { + return { text, suppress: true, pendingLeadFragment: true }; + } + return { text, suppress: false, pendingLeadFragment: false }; +} + +export function shouldSuppressAssistantEventForLiveChat(data: unknown): boolean { + return resolveAssistantEventPhase(data) === "commentary"; +} diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 2c0131cee29..2b6f4f8740d 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,21 +1,16 @@ -import { stripInternalRuntimeContext } from "../agents/internal-runtime-context.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js"; import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; -import { - SILENT_REPLY_TOKEN, - startsWithSilentToken, - stripLeadingSilentToken, -} from "../auto-reply/tokens.js"; import { loadConfig } from "../config/config.js"; import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js"; import { detectErrorKind, type ErrorKind } from "../infra/errors.js"; import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; -import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { setSafeTimeout } from "../utils/timer-delay.js"; import { - isSuppressedControlReplyLeadFragment, - isSuppressedControlReplyText, -} from "./control-reply-text.js"; + normalizeLiveAssistantEventText, + projectLiveAssistantBufferedText, + resolveMergedAssistantText, + shouldSuppressAssistantEventForLiveChat, +} from "./live-chat-projector.js"; import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.runtime.js"; import { persistGatewaySessionLifecycleEvent } from "./server-chat.persist-session-lifecycle.runtime.js"; import { deriveGatewaySessionLifecycleSnapshot } from "./session-lifecycle-state.js"; @@ -89,48 +84,6 @@ function normalizeHeartbeatChatFinalText(params: { return { suppress: false, text: stripped.text }; } -function appendUniqueSuffix(base: string, suffix: string): string { - if (!suffix) { - return base; - } - if (!base) { - return suffix; - } - if (base.endsWith(suffix)) { - return base; - } - const maxOverlap = Math.min(base.length, suffix.length); - for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { - if (base.slice(-overlap) === suffix.slice(0, overlap)) { - return base + suffix.slice(overlap); - } - } - return base + suffix; -} - -function resolveMergedAssistantText(params: { - previousText: string; - nextText: string; - nextDelta: string; -}) { - const { previousText, nextText, nextDelta } = params; - if (nextText && previousText) { - if (nextText.startsWith(previousText)) { - return nextText; - } - if (previousText.startsWith(nextText) && !nextDelta) { - return previousText; - } - } - if (nextDelta) { - return appendUniqueSuffix(previousText, nextDelta); - } - if (nextText) { - return nextText; - } - return previousText; -} - export type ChatRunEntry = { sessionKey: string; clientRunId: string; @@ -689,37 +642,21 @@ export function createAgentEventHandler({ text: string, delta?: unknown, ) => { - const cleanedText = stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(text).text); - const cleanedDelta = - typeof delta === "string" - ? stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(delta).text) - : ""; + const cleaned = normalizeLiveAssistantEventText({ text, delta }); const previousRawText = chatRunState.rawBuffers.get(clientRunId) ?? ""; const mergedRawText = resolveMergedAssistantText({ previousText: previousRawText, - nextText: cleanedText, - nextDelta: cleanedDelta, + nextText: cleaned.text, + nextDelta: cleaned.delta, }); if (!mergedRawText) { return; } chatRunState.rawBuffers.set(clientRunId, mergedRawText); - if (isSuppressedControlReplyText(mergedRawText)) { - chatRunState.buffers.set(clientRunId, ""); - return; - } - if (isSuppressedControlReplyLeadFragment(mergedRawText)) { - chatRunState.buffers.set(clientRunId, mergedRawText); - return; - } - const mergedText = startsWithSilentToken(mergedRawText, SILENT_REPLY_TOKEN) - ? stripLeadingSilentToken(mergedRawText, SILENT_REPLY_TOKEN) - : mergedRawText; + const projected = projectLiveAssistantBufferedText(mergedRawText); + const mergedText = projected.text; chatRunState.buffers.set(clientRunId, mergedText); - if (isSuppressedControlReplyText(mergedText)) { - return; - } - if (isSuppressedControlReplyLeadFragment(mergedText)) { + if (projected.suppress) { return; } if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) { @@ -747,19 +684,26 @@ export function createAgentEventHandler({ nodeSendToSession(sessionKey, "chat", payload); }; - const resolveBufferedChatTextState = (clientRunId: string, sourceRunId: string) => { - const bufferedText = stripInlineDirectiveTagsForDisplay( - chatRunState.buffers.get(clientRunId) ?? "", - ).text.trim(); + const resolveBufferedChatTextState = ( + clientRunId: string, + sourceRunId: string, + options?: { suppressLeadFragments?: boolean }, + ) => { + const bufferedText = normalizeLiveAssistantEventText({ + text: chatRunState.buffers.get(clientRunId) ?? "", + }).text.trim(); const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({ runId: clientRunId, sourceRunId, text: bufferedText, }); - const text = normalizedHeartbeatText.text.trim(); - const shouldSuppressSilent = - normalizedHeartbeatText.suppress || isSuppressedControlReplyText(text); - return { text, shouldSuppressSilent }; + const projected = projectLiveAssistantBufferedText(normalizedHeartbeatText.text.trim(), { + suppressLeadFragments: options?.suppressLeadFragments, + }); + return { + text: projected.text.trim(), + shouldSuppressSilent: normalizedHeartbeatText.suppress || projected.suppress, + }; }; const flushBufferedChatDeltaIfNeeded = ( @@ -768,18 +712,14 @@ export function createAgentEventHandler({ sourceRunId: string, seq: number, ) => { - const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId); - const shouldSuppressSilentLeadFragment = isSuppressedControlReplyLeadFragment(text); + const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { + suppressLeadFragments: true, + }); const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput( clientRunId, sourceRunId, ); - if ( - !text || - shouldSuppressSilent || - shouldSuppressSilentLeadFragment || - shouldSuppressHeartbeatStreaming - ) { + if (!text || shouldSuppressSilent || shouldSuppressHeartbeatStreaming) { return; } @@ -816,7 +756,9 @@ export function createAgentEventHandler({ stopReason?: string, errorKind?: ErrorKind, ) => { - const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId); + const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { + suppressLeadFragments: false, + }); // Flush any throttled delta so streaming clients receive the complete text // before the final event. The 150 ms throttle in emitChatDelta may have // suppressed the most recent chunk, leaving the client with stale text. @@ -983,7 +925,12 @@ export function createAgentEventHandler({ isToolEvent ? { ...toolPayload, ...buildSessionEventSnapshot(sessionKey) } : agentPayload, ); } - if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { + if ( + !isAborted && + evt.stream === "assistant" && + typeof evt.data?.text === "string" && + !shouldSuppressAssistantEventForLiveChat(evt.data) + ) { emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text, evt.data.delta); } } diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index bb69d33d4bd..1af9302ddd5 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -29,13 +29,8 @@ import { normalizeInputProvenance, type InputProvenance } from "../../sessions/i import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { - parseAssistantTextSignature, - resolveAssistantMessagePhase, -} from "../../shared/chat-message-content.js"; import { stripInlineDirectiveTagsForDisplay, - stripInlineDirectiveTagsFromMessageForDisplay, sanitizeReplyDirectiveId, } from "../../utils/directive-tags.js"; import { @@ -57,7 +52,13 @@ import { parseMessageWithAttachments, } from "../chat-attachments.js"; import { MediaOffloadError } from "../chat-attachments.js"; -import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js"; +import { + isToolHistoryBlockType, + projectChatDisplayMessage, + projectRecentChatDisplayMessages, + resolveEffectiveChatHistoryMaxChars, +} from "../chat-display-projection.js"; +import { stripEnvelopeFromMessage } from "../chat-sanitize.js"; import { augmentChatHistoryWithCliSessionImports } from "../cli-session-history.js"; import { isSuppressedControlReplyText } from "../control-reply-text.js"; import { @@ -154,7 +155,12 @@ async function buildWebchatAssistantMediaMessage( }); } -export const DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS = 8_000; +export { + DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, + resolveEffectiveChatHistoryMaxChars, + sanitizeChatHistoryMessages, +} from "../chat-display-projection.js"; + export const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024; const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]"; const MANAGED_OUTGOING_IMAGE_PATH_PREFIX = "/api/chat/media/outgoing/"; @@ -175,19 +181,6 @@ const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ ]); const CHANNEL_SCOPED_SESSION_SHAPES = new Set(["direct", "dm", "group", "channel"]); -export function resolveEffectiveChatHistoryMaxChars( - cfg: { gateway?: { webchat?: { chatHistoryMaxChars?: number } } }, - maxChars?: number, -): number { - if (typeof maxChars === "number") { - return maxChars; - } - if (typeof cfg.gateway?.webchat?.chatHistoryMaxChars === "number") { - return cfg.gateway.webchat.chatHistoryMaxChars; - } - return DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS; -} - type ChatSendDeliveryEntry = { deliveryContext?: { channel?: string; @@ -832,34 +825,6 @@ async function rewriteChatSendUserTurnMediaPaths(params: { }); } -function truncateChatHistoryText( - text: string, - maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, -): { text: string; truncated: boolean } { - if (text.length <= maxChars) { - return { text, truncated: false }; - } - return { - text: `${text.slice(0, maxChars)}\n...(truncated)...`, - truncated: true, - }; -} - -function isToolHistoryBlockType(type: unknown): boolean { - if (typeof type !== "string") { - return false; - } - const normalized = type.trim().toLowerCase(); - return ( - normalized === "toolcall" || - normalized === "tool_call" || - normalized === "tooluse" || - normalized === "tool_use" || - normalized === "toolresult" || - normalized === "tool_result" - ); -} - function extractChatHistoryBlockText(message: unknown): string | undefined { if (!message || typeof message !== "object") { return undefined; @@ -886,373 +851,6 @@ function extractChatHistoryBlockText(message: unknown): string | undefined { return textParts.length > 0 ? textParts.join("\n") : undefined; } -function sanitizeChatHistoryContentBlock( - block: unknown, - opts?: { preserveExactToolPayload?: boolean; maxChars?: number }, -): { block: unknown; changed: boolean } { - if (!block || typeof block !== "object") { - return { block, changed: false }; - } - const entry = { ...(block as Record) }; - let changed = false; - const preserveExactToolPayload = - opts?.preserveExactToolPayload === true || isToolHistoryBlockType(entry.type); - const maxChars = opts?.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS; - if (typeof entry.text === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.text); - if (preserveExactToolPayload) { - entry.text = stripped.text; - changed ||= stripped.changed; - } else { - const res = truncateChatHistoryText(stripped.text, maxChars); - entry.text = res.text; - changed ||= stripped.changed || res.truncated; - } - } - if (typeof entry.content === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.content); - if (preserveExactToolPayload) { - entry.content = stripped.text; - changed ||= stripped.changed; - } else { - const res = truncateChatHistoryText(stripped.text, maxChars); - entry.content = res.text; - changed ||= stripped.changed || res.truncated; - } - } - if (typeof entry.partialJson === "string") { - if (!preserveExactToolPayload) { - const res = truncateChatHistoryText(entry.partialJson, maxChars); - entry.partialJson = res.text; - changed ||= res.truncated; - } - } - if (typeof entry.arguments === "string") { - if (!preserveExactToolPayload) { - const res = truncateChatHistoryText(entry.arguments, maxChars); - entry.arguments = res.text; - changed ||= res.truncated; - } - } - if (typeof entry.thinking === "string") { - const res = truncateChatHistoryText(entry.thinking, maxChars); - entry.thinking = res.text; - changed ||= res.truncated; - } - if ("thinkingSignature" in entry) { - delete entry.thinkingSignature; - changed = true; - } - const type = typeof entry.type === "string" ? entry.type : ""; - if (type === "image" && typeof entry.data === "string") { - const bytes = Buffer.byteLength(entry.data, "utf8"); - delete entry.data; - entry.omitted = true; - entry.bytes = bytes; - changed = true; - } - if (type === "audio" && entry.source && typeof entry.source === "object") { - const source = { ...(entry.source as Record) }; - if (source.type === "base64" && typeof source.data === "string") { - const bytes = Buffer.byteLength(source.data, "utf8"); - delete source.data; - source.omitted = true; - source.bytes = bytes; - entry.source = source; - changed = true; - } - } - return { block: changed ? entry : block, changed }; -} - -function sanitizeAssistantPhasedContentBlocks(content: unknown[]): { - content: unknown[]; - changed: boolean; -} { - const hasExplicitPhasedText = content.some((block) => { - if (!block || typeof block !== "object") { - return false; - } - const entry = block as { type?: unknown; textSignature?: unknown }; - return ( - entry.type === "text" && Boolean(parseAssistantTextSignature(entry.textSignature)?.phase) - ); - }); - if (!hasExplicitPhasedText) { - return { content, changed: false }; - } - const filtered = content.filter((block) => { - if (!block || typeof block !== "object") { - return true; - } - const entry = block as { type?: unknown; textSignature?: unknown }; - if (entry.type !== "text") { - return true; - } - return parseAssistantTextSignature(entry.textSignature)?.phase === "final_answer"; - }); - return { - content: filtered, - changed: filtered.length !== content.length, - }; -} - -/** - * Validate that a value is a finite number, returning undefined otherwise. - */ -function toFiniteNumber(x: unknown): number | undefined { - return typeof x === "number" && Number.isFinite(x) ? x : undefined; -} - -/** - * Sanitize usage metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from malformed transcript JSON. - */ -function sanitizeUsage(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const u = raw as Record; - const out: Record = {}; - - // Whitelist known usage fields and validate they're finite numbers - const knownFields = [ - "input", - "output", - "totalTokens", - "inputTokens", - "outputTokens", - "cacheRead", - "cacheWrite", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ]; - - for (const k of knownFields) { - const n = toFiniteNumber(u[k]); - if (n !== undefined) { - out[k] = n; - } - } - - // Preserve nested usage.cost when present - if ("cost" in u && u.cost != null && typeof u.cost === "object") { - const sanitizedCost = sanitizeCost(u.cost); - if (sanitizedCost) { - (out as Record).cost = sanitizedCost; - } - } - - return Object.keys(out).length > 0 ? out : undefined; -} - -/** - * Sanitize cost metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from calling .toFixed() on non-numbers. - */ -function sanitizeCost(raw: unknown): { total?: number } | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const c = raw as Record; - const total = toFiniteNumber(c.total); - return total !== undefined ? { total } : undefined; -} - -function sanitizeChatHistoryMessage( - message: unknown, - maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, -): { message: unknown; changed: boolean } { - if (!message || typeof message !== "object") { - return { message, changed: false }; - } - const entry = { ...(message as Record) }; - let changed = false; - const role = typeof entry.role === "string" ? entry.role.toLowerCase() : ""; - const preserveExactToolPayload = - role === "toolresult" || - role === "tool_result" || - role === "tool" || - role === "function" || - typeof entry.toolName === "string" || - typeof entry.tool_name === "string" || - typeof entry.toolCallId === "string" || - typeof entry.tool_call_id === "string"; - - if ("details" in entry) { - delete entry.details; - changed = true; - } - - // Keep usage/cost so the chat UI can render per-message token and cost badges. - // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. - if (entry.role !== "assistant") { - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; - } - } else { - // Validate and sanitize usage/cost for assistant messages - if ("usage" in entry) { - const sanitized = sanitizeUsage(entry.usage); - if (sanitized) { - entry.usage = sanitized; - } else { - delete entry.usage; - } - changed = true; - } - if ("cost" in entry) { - const sanitized = sanitizeCost(entry.cost); - if (sanitized) { - entry.cost = sanitized; - } else { - delete entry.cost; - } - changed = true; - } - } - - if (typeof entry.content === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.content); - if (preserveExactToolPayload) { - entry.content = stripped.text; - changed ||= stripped.changed; - } else { - const res = truncateChatHistoryText(stripped.text, maxChars); - entry.content = res.text; - changed ||= stripped.changed || res.truncated; - } - } else if (Array.isArray(entry.content)) { - const updated = entry.content.map((block) => - sanitizeChatHistoryContentBlock(block, { preserveExactToolPayload, maxChars }), - ); - if (updated.some((item) => item.changed)) { - entry.content = updated.map((item) => item.block); - changed = true; - } - if (entry.role === "assistant" && Array.isArray(entry.content)) { - const sanitizedPhases = sanitizeAssistantPhasedContentBlocks(entry.content); - if (sanitizedPhases.changed) { - entry.content = sanitizedPhases.content; - changed = true; - } - } - } - - if (typeof entry.text === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.text); - if (preserveExactToolPayload) { - entry.text = stripped.text; - changed ||= stripped.changed; - } else { - const res = truncateChatHistoryText(stripped.text, maxChars); - entry.text = res.text; - changed ||= stripped.changed || res.truncated; - } - } - - return { message: changed ? entry : message, changed }; -} - -/** - * Extract the visible text from an assistant history message for silent-token checks. - * Returns `undefined` for non-assistant messages or messages with no extractable text. - * When `entry.text` is present it takes precedence over `entry.content` to avoid - * dropping messages that carry real text alongside a stale `content: "NO_REPLY"`. - */ -function extractAssistantTextForSilentCheck(message: unknown): string | undefined { - if (!message || typeof message !== "object") { - return undefined; - } - const entry = message as Record; - if (entry.role !== "assistant") { - return undefined; - } - if (typeof entry.text === "string") { - return entry.text; - } - if (typeof entry.content === "string") { - return entry.content; - } - if (!Array.isArray(entry.content) || entry.content.length === 0) { - return undefined; - } - - const texts: string[] = []; - for (const block of entry.content) { - if (!block || typeof block !== "object") { - return undefined; - } - const typed = block as { type?: unknown; text?: unknown }; - if (typed.type !== "text" || typeof typed.text !== "string") { - return undefined; - } - texts.push(typed.text); - } - return texts.length > 0 ? texts.join("\n") : undefined; -} - -function hasAssistantNonTextContent(message: unknown): boolean { - if (!message || typeof message !== "object") { - return false; - } - const content = (message as { content?: unknown }).content; - if (!Array.isArray(content)) { - return false; - } - return content.some( - (block) => block && typeof block === "object" && (block as { type?: unknown }).type !== "text", - ); -} - -function shouldDropAssistantHistoryMessage(message: unknown): boolean { - if (!message || typeof message !== "object") { - return false; - } - const entry = message as { role?: unknown }; - if (entry.role !== "assistant") { - return false; - } - if (resolveAssistantMessagePhase(message) === "commentary") { - return true; - } - const text = extractAssistantTextForSilentCheck(message); - if (text === undefined || !isSuppressedControlReplyText(text)) { - return false; - } - return !hasAssistantNonTextContent(message); -} - -export function sanitizeChatHistoryMessages( - messages: unknown[], - maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, -): unknown[] { - if (messages.length === 0) { - return messages; - } - let changed = false; - const next: unknown[] = []; - for (const message of messages) { - if (shouldDropAssistantHistoryMessage(message)) { - changed = true; - continue; - } - const res = sanitizeChatHistoryMessage(message, maxChars); - changed ||= res.changed; - if (shouldDropAssistantHistoryMessage(res.message)) { - changed = true; - continue; - } - next.push(res.message); - } - return changed ? next : messages; -} - function appendCanvasBlockToAssistantHistoryMessage(params: { message: unknown; preview: ReturnType; @@ -1809,15 +1407,12 @@ function broadcastChatFinal(params: { message?: Record; }) { const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); - const strippedEnvelopeMessage = stripEnvelopeFromMessage(params.message) as - | Record - | undefined; const payload = { runId: params.runId, sessionKey: params.sessionKey, seq, state: "final" as const, - message: stripInlineDirectiveTagsFromMessageForDisplay(strippedEnvelopeMessage), + message: projectChatDisplayMessage(params.message), }; params.context.broadcast("chat", payload); params.context.nodeSendToSession(params.sessionKey, "chat", payload); @@ -1904,10 +1499,11 @@ export const chatHandlers: GatewayRequestHandlers = { const requested = typeof limit === "number" ? limit : defaultLimit; const max = Math.min(hardMax, requested); const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars); - const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; - const sanitized = stripEnvelopeFromMessages(sliced); const normalized = augmentChatHistoryWithCanvasBlocks( - sanitizeChatHistoryMessages(sanitized, effectiveMaxChars), + projectRecentChatDisplayMessages(rawMessages, { + maxChars: effectiveMaxChars, + maxMessages: max, + }), ); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes); @@ -2821,14 +2417,15 @@ export const chatHandlers: GatewayRequestHandlers = { } // Broadcast to webchat for immediate UI update + const message = projectChatDisplayMessage(appended.message, { + maxChars: resolveEffectiveChatHistoryMaxChars(cfg), + }); const chatPayload = { runId: `inject-${appended.messageId}`, sessionKey, seq: 0, state: "final" as const, - message: stripInlineDirectiveTagsFromMessageForDisplay( - stripEnvelopeFromMessage(appended.message) as Record, - ), + message, }; context.broadcast("chat", chatPayload); context.nodeSendToSession(sessionKey, "chat", chatPayload); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 4837706b609..2309a754150 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -11,6 +11,7 @@ import { buildSystemRunApprovalEnvBinding, } from "../../infra/system-run-approval-binding.js"; import { resetLogger, setLoggerOverride } from "../../logging.js"; +import { projectRecentChatDisplayMessages } from "../chat-display-projection.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; import { validateExecApprovalRequestParams } from "../protocol/index.js"; import { waitForAgentJob } from "./agent-job.js"; @@ -456,6 +457,22 @@ describe("sanitizeChatHistoryMessages", () => { }); }); +describe("projectRecentChatDisplayMessages", () => { + it("applies history limits after dropping display-hidden messages", () => { + const result = projectRecentChatDisplayMessages( + [ + { role: "user", content: "older visible", timestamp: 1 }, + { role: "assistant", content: "older answer", timestamp: 2 }, + { role: "assistant", content: "NO_REPLY", timestamp: 3 }, + { role: "assistant", content: "ANNOUNCE_SKIP", timestamp: 4 }, + ], + { maxMessages: 1 }, + ); + + expect(result).toEqual([{ role: "assistant", content: "older answer", timestamp: 2 }]); + }); +}); + describe("resolveEffectiveChatHistoryMaxChars", () => { it("uses gateway.webchat.chatHistoryMaxChars when RPC maxChars is absent", () => { expect( diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 7d67d1a7e0b..19b2029a2aa 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -1,5 +1,6 @@ import type { SessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import type { SessionTranscriptUpdate } from "../sessions/transcript-events.js"; +import { projectChatDisplayMessage } from "./chat-display-projection.js"; import type { GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js"; import type { SessionEventSubscriberRegistry, @@ -110,22 +111,25 @@ export function createTranscriptUpdateBroadcastHandler(params: { sessionRow: loadGatewaySessionRow(sessionKey), includeSession: true, }); - const message = attachOpenClawTranscriptMeta(update.message, { + const rawMessage = attachOpenClawTranscriptMeta(update.message, { ...(typeof update.messageId === "string" ? { id: update.messageId } : {}), ...(typeof messageSeq === "number" ? { seq: messageSeq } : {}), }); - params.broadcastToConnIds( - "session.message", - { - sessionKey, - message, - ...(typeof update.messageId === "string" ? { messageId: update.messageId } : {}), - ...(typeof messageSeq === "number" ? { messageSeq } : {}), - ...sessionSnapshot, - }, - connIds, - { dropIfSlow: true }, - ); + const message = projectChatDisplayMessage(rawMessage); + if (message) { + params.broadcastToConnIds( + "session.message", + { + sessionKey, + message, + ...(typeof update.messageId === "string" ? { messageId: update.messageId } : {}), + ...(typeof messageSeq === "number" ? { messageSeq } : {}), + ...sessionSnapshot, + }, + connIds, + { dropIfSlow: true }, + ); + } const sessionEventConnIds = params.sessionEventSubscribers.getAll(); if (sessionEventConnIds.size === 0) { diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index c66fa4ab75c..fc99a3b4023 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -1,10 +1,7 @@ -import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js"; -import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; -import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, - sanitizeChatHistoryMessages, -} from "./server-methods/chat.js"; + projectChatDisplayMessages, +} from "./chat-display-projection.js"; import { attachOpenClawTranscriptMeta, readSessionMessages } from "./session-utils.js"; type SessionHistoryTranscriptMeta = { @@ -33,102 +30,6 @@ type SessionHistoryTranscriptTarget = { sessionFile?: string; }; -type RoleContentMessage = { - role: string; - content?: unknown; -}; - -function asRoleContentMessage(message: SessionHistoryMessage): RoleContentMessage | null { - const role = typeof message.role === "string" ? message.role.toLowerCase() : ""; - if (!role) { - return null; - } - return { - role, - ...(message.content !== undefined - ? { content: message.content } - : message.text !== undefined - ? { content: message.text } - : {}), - }; -} - -function isEmptyTextOnlyContent(content: unknown): boolean { - if (typeof content === "string") { - return content.trim().length === 0; - } - if (!Array.isArray(content)) { - return false; - } - if (content.length === 0) { - return true; - } - let sawText = false; - for (const block of content) { - if (!block || typeof block !== "object") { - return false; - } - const entry = block as { type?: unknown; text?: unknown }; - if (entry.type !== "text") { - return false; - } - sawText = true; - if (typeof entry.text !== "string" || entry.text.trim().length > 0) { - return false; - } - } - return sawText; -} - -function shouldHideSanitizedHistoryMessage(message: SessionHistoryMessage): boolean { - const roleContent = asRoleContentMessage(message); - if (!roleContent) { - return false; - } - if (roleContent.role === "user" && isEmptyTextOnlyContent(message.content ?? message.text)) { - return true; - } - if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT)) { - return true; - } - return isHeartbeatOkResponse(roleContent); -} - -function filterVisibleSessionHistoryMessages( - messages: SessionHistoryMessage[], -): SessionHistoryMessage[] { - if (messages.length === 0) { - return messages; - } - let changed = false; - const visible: SessionHistoryMessage[] = []; - for (let i = 0; i < messages.length; i++) { - const current = messages[i]; - if (!current) { - continue; - } - const currentRoleContent = asRoleContentMessage(current); - const next = messages[i + 1]; - const nextRoleContent = next ? asRoleContentMessage(next) : null; - if ( - currentRoleContent && - nextRoleContent && - isHeartbeatUserMessage(currentRoleContent, HEARTBEAT_PROMPT) && - isHeartbeatOkResponse(nextRoleContent) - ) { - changed = true; - i++; - continue; - } - if (shouldHideSanitizedHistoryMessage(current)) { - changed = true; - continue; - } - visible.push(current); - } - return changed ? visible : messages; -} - function resolveCursorSeq(cursor: string | undefined): number | undefined { if (!cursor) { return undefined; @@ -198,13 +99,10 @@ export function buildSessionHistorySnapshot(params: { limit?: number; cursor?: string; }): SessionHistorySnapshot { - const visibleMessages = filterVisibleSessionHistoryMessages( - toSessionHistoryMessages( - sanitizeChatHistoryMessages( - stripEnvelopeFromMessages(params.rawMessages), - params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, - ), - ), + const visibleMessages = toSessionHistoryMessages( + projectChatDisplayMessages(params.rawMessages, { + maxChars: params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, + }), ); const history = paginateSessionMessages(visibleMessages, params.limit, params.cursor); const rawHistoryMessages = toSessionHistoryMessages(params.rawMessages); @@ -276,20 +174,12 @@ export class SessionHistorySseState { ...(typeof update.messageId === "string" ? { id: update.messageId } : {}), seq: this.rawTranscriptSeq, }); - const sanitized = sanitizeChatHistoryMessages( - stripEnvelopeFromMessages([nextMessage]), - this.maxChars, + const [sanitizedMessage] = toSessionHistoryMessages( + projectChatDisplayMessages([nextMessage], { maxChars: this.maxChars }), ); - if (sanitized.length === 0) { - return null; - } - const [sanitizedMessage] = toSessionHistoryMessages(sanitized); if (!sanitizedMessage) { return null; } - if (shouldHideSanitizedHistoryMessage(sanitizedMessage)) { - return null; - } const nextMessages = [...this.sentHistory.messages, sanitizedMessage]; this.sentHistory = buildPaginatedSessionHistory({ messages: nextMessages, diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index a5c1f2250f0..6fd8f27a787 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -80,6 +80,7 @@ function resolveManifestProviderAuthEnvVarCandidates( config: params?.config, workspaceDir: params?.workspaceDir, env: params?.env, + preferPersisted: false, includeDisabled: true, }); const candidates: Record = {}; diff --git a/src/shared/chat-message-content.ts b/src/shared/chat-message-content.ts index 391211be688..638a0995fc1 100644 --- a/src/shared/chat-message-content.ts +++ b/src/shared/chat-message-content.ts @@ -90,6 +90,25 @@ export function resolveAssistantMessagePhase(message: unknown): AssistantPhase | return explicitPhases.size === 1 ? [...explicitPhases][0] : undefined; } +export function resolveAssistantEventPhase(data: unknown): AssistantPhase | undefined { + if (!data || typeof data !== "object") { + return undefined; + } + const record = data as { + phase?: unknown; + message?: unknown; + partial?: unknown; + item?: unknown; + }; + return ( + normalizeAssistantPhase(record.phase) ?? + resolveAssistantMessagePhase(record.message) ?? + resolveAssistantMessagePhase(record.partial) ?? + resolveAssistantMessagePhase(record.item) ?? + resolveAssistantMessagePhase(record) + ); +} + export function extractAssistantTextForPhase( message: unknown, options?: { diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 0b2f5295bf9..cb3eaca91fb 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -47,15 +47,17 @@ vi.mock("../config/config.js", () => ({ loadConfig: () => ({}), })); -vi.mock("../gateway/chat-sanitize.js", () => ({ - stripEnvelopeFromMessages: (messages: unknown[]) => messages, -})); - vi.mock("../gateway/cli-session-history.js", () => ({ augmentChatHistoryWithCliSessionImports: ({ localMessages }: { localMessages?: unknown[] }) => localMessages ?? [], })); +vi.mock("../gateway/chat-display-projection.js", () => ({ + projectChatDisplayMessages: (messages: unknown[]) => messages, + projectRecentChatDisplayMessages: (messages: unknown[]) => messages, + resolveEffectiveChatHistoryMaxChars: () => 100_000, +})); + vi.mock("../gateway/server-constants.js", () => ({ getMaxChatHistoryMessagesBytes: () => 100_000, })); @@ -65,8 +67,6 @@ vi.mock("../gateway/server-methods/chat.js", () => ({ augmentChatHistoryWithCanvasBlocks: (messages: unknown[]) => messages, enforceChatHistoryFinalBudget: ({ messages }: { messages: unknown[] }) => ({ messages }), replaceOversizedChatHistoryMessages: ({ messages }: { messages: unknown[] }) => ({ messages }), - resolveEffectiveChatHistoryMaxChars: () => 100_000, - sanitizeChatHistoryMessages: (messages: unknown[]) => messages, })); vi.mock("../gateway/session-utils.js", () => ({ @@ -231,6 +231,63 @@ describe("EmbeddedTuiBackend", () => { ]); }); + it("keeps final short replies like No after suppressing lead-fragment deltas", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const pending = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + agentCommandFromIngressMock.mockReturnValueOnce(pending.promise); + + const backend = new EmbeddedTuiBackend(); + const events: Array<{ event: string; payload: unknown }> = []; + backend.onEvent = (evt) => { + events.push({ event: evt.event, payload: evt.payload }); + }; + + backend.start(); + await backend.sendChat({ + sessionKey: "agent:main:main", + message: "answer shortly", + runId: "run-local-no", + }); + + registeredListener?.({ + runId: "run-local-no", + stream: "assistant", + data: { text: "No", delta: "No" }, + }); + registeredListener?.({ + runId: "run-local-no", + stream: "lifecycle", + data: { phase: "end", stopReason: "stop" }, + }); + + pending.resolve({ payloads: [{ text: "No" }], meta: {} }); + await flushMicrotasks(); + + const chatPayloads = events + .filter((entry) => entry.event === "chat") + .map( + (entry) => + entry.payload as { state?: string; message?: { content?: Array<{ text?: string }> } }, + ); + const nonEmptyDeltas = chatPayloads.filter( + (payload) => payload.state === "delta" && payload.message?.content?.[0]?.text, + ); + expect(nonEmptyDeltas).toHaveLength(0); + expect(chatPayloads.at(-1)).toEqual( + expect.objectContaining({ + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "No" }], + timestamp: expect.any(Number), + }, + }), + ); + }); + it("emits side-result events for local /btw runs", async () => { const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); agentCommandFromIngressMock.mockResolvedValueOnce({ diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 69ac2c1f5ca..85a359fc403 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -3,12 +3,20 @@ import { agentCommandFromIngress } from "../agents/agent-command.js"; import { resolveSessionAgentId } from "../agents/agent-scope.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { buildAllowedModelSet, resolveThinkingDefault } from "../agents/model-selection.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { createDefaultDeps } from "../cli/deps.js"; import { loadConfig } from "../config/config.js"; import { updateSessionStore } from "../config/sessions.js"; -import { stripEnvelopeFromMessages } from "../gateway/chat-sanitize.js"; +import { + projectRecentChatDisplayMessages, + resolveEffectiveChatHistoryMaxChars, +} from "../gateway/chat-display-projection.js"; import { augmentChatHistoryWithCliSessionImports } from "../gateway/cli-session-history.js"; +import { + normalizeLiveAssistantEventText, + projectLiveAssistantBufferedText, + resolveMergedAssistantText, + shouldSuppressAssistantEventForLiveChat, +} from "../gateway/live-chat-projector.js"; import type { SessionsPatchResult } from "../gateway/protocol/index.js"; import { getMaxChatHistoryMessagesBytes } from "../gateway/server-constants.js"; import { @@ -20,8 +28,6 @@ import { CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, enforceChatHistoryFinalBudget, replaceOversizedChatHistoryMessages, - resolveEffectiveChatHistoryMaxChars, - sanitizeChatHistoryMessages, } from "../gateway/server-methods/chat.js"; import { loadGatewayModelCatalog } from "../gateway/server-model-catalog.js"; import { performGatewaySessionReset } from "../gateway/session-reset-service.js"; @@ -40,7 +46,6 @@ import { applySessionsPatchToStore } from "../gateway/sessions-patch.js"; import { type AgentEventPayload, onAgentEvent } from "../infra/agent-events.js"; import { setEmbeddedMode } from "../infra/embedded-mode.js"; import { defaultRuntime } from "../runtime.js"; -import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import type { ChatSendOptions, @@ -69,59 +74,6 @@ const silentRuntime = { }, }; -function isSilentReplyLeadFragment(text: string): boolean { - const normalized = text.trim().toUpperCase(); - if (!normalized) { - return false; - } - if (!/^[A-Z_]+$/.test(normalized)) { - return false; - } - if (normalized === SILENT_REPLY_TOKEN) { - return false; - } - return SILENT_REPLY_TOKEN.startsWith(normalized); -} - -function appendUniqueSuffix(base: string, suffix: string): string { - if (!suffix) { - return base; - } - if (!base) { - return suffix; - } - if (base.endsWith(suffix)) { - return base; - } - const maxOverlap = Math.min(base.length, suffix.length); - for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { - if (base.slice(-overlap) === suffix.slice(0, overlap)) { - return base + suffix.slice(overlap); - } - } - return base + suffix; -} - -function resolveMergedAssistantText(params: { - previousText: string; - nextText: string; - nextDelta: string; -}): string { - const previous = params.previousText; - const next = params.nextText; - const delta = params.nextDelta; - if (!previous) { - return next || delta; - } - if (next && next.startsWith(previous)) { - return next; - } - if (delta) { - return appendUniqueSuffix(previous, delta); - } - return appendUniqueSuffix(previous, next); -} - function resolveBtwQuestion(message: string): string | undefined { const match = /^\/btw(?::|\s)+(.*)$/i.exec(message.trim()); const question = match?.[1]?.trim(); @@ -252,11 +204,12 @@ export class EmbeddedTuiBackend implements TuiBackend { localMessages, }); const max = Math.min(1000, typeof opts.limit === "number" ? opts.limit : 200); - const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg); - const sanitized = stripEnvelopeFromMessages(sliced); const normalized = augmentChatHistoryWithCanvasBlocks( - sanitizeChatHistoryMessages(sanitized, effectiveMaxChars), + projectRecentChatDisplayMessages(rawMessages, { + maxChars: effectiveMaxChars, + maxMessages: max, + }), ); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes); @@ -400,8 +353,11 @@ export class EmbeddedTuiBackend implements TuiBackend { } private emitChatDelta(runId: string, run: LocalRunState) { - const text = run.buffer.trim(); - if (!text || isSilentReplyText(text, SILENT_REPLY_TOKEN) || isSilentReplyLeadFragment(text)) { + const projected = projectLiveAssistantBufferedText(run.buffer.trim(), { + suppressLeadFragments: true, + }); + const text = projected.text.trim(); + if (!text || projected.suppress) { return; } run.registered = true; @@ -423,11 +379,11 @@ export class EmbeddedTuiBackend implements TuiBackend { } run.finalSent = true; run.registered = true; - const text = run.buffer.trim(); - const shouldIncludeMessage = - Boolean(text) && - !isSilentReplyText(text, SILENT_REPLY_TOKEN) && - !isSilentReplyLeadFragment(text); + const projected = projectLiveAssistantBufferedText(run.buffer.trim(), { + suppressLeadFragments: false, + }); + const text = projected.text.trim(); + const shouldIncludeMessage = Boolean(text) && !projected.suppress; this.emit("chat", { runId, sessionKey: run.sessionKey, @@ -505,16 +461,20 @@ export class EmbeddedTuiBackend implements TuiBackend { data: evt.data, }); - if (evt.stream === "assistant" && !run.isBtw && typeof evt.data?.text === "string") { - const nextText = stripInlineDirectiveTagsForDisplay(evt.data.text).text; - const nextDelta = - typeof evt.data?.delta === "string" - ? stripInlineDirectiveTagsForDisplay(evt.data.delta).text - : ""; + if ( + evt.stream === "assistant" && + !run.isBtw && + typeof evt.data?.text === "string" && + !shouldSuppressAssistantEventForLiveChat(evt.data) + ) { + const cleaned = normalizeLiveAssistantEventText({ + text: evt.data.text, + delta: evt.data.delta, + }); run.buffer = resolveMergedAssistantText({ previousText: run.buffer, - nextText, - nextDelta, + nextText: cleaned.text, + nextDelta: cleaned.delta, }); this.emitChatDelta(evt.runId, run); return; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 52e81e50fb0..a80c3e1b2b5 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -407,10 +407,14 @@ function handleTerminalChatEvent( host: GatewayHost, payload: ChatEventPayload | undefined, state: ReturnType, + activeRunIdBeforeEvent: string | null, ): boolean { if (state !== "final" && state !== "error" && state !== "aborted") { return false; } + if (isEventForDifferentActiveRun(payload, activeRunIdBeforeEvent)) { + return false; + } // Check if tool events were seen before resetting (resetToolStream clears toolStreamOrder). const toolHost = host as unknown as Parameters[0]; const hadToolEvents = toolHost.toolStreamOrder.length > 0; @@ -447,6 +451,13 @@ function handleTerminalChatEvent( return false; } +function isEventForDifferentActiveRun( + payload: ChatEventPayload | undefined, + activeRunId: string | null, +): boolean { + return Boolean(activeRunId && payload?.runId && payload.runId !== activeRunId); +} + function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) { if (payload?.sessionKey) { setLastActiveSessionKey( @@ -463,8 +474,13 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u sideResultHost.chatSideResultTerminalRuns?.delete(payload.runId); return; } + const activeRunIdBeforeEvent = host.chatRunId; const state = handleChatEvent(host as unknown as ChatState, payload); - const historyReloaded = handleTerminalChatEvent(host, payload, state); + const terminalEventIsForDifferentActiveRun = isEventForDifferentActiveRun( + payload, + activeRunIdBeforeEvent, + ); + const historyReloaded = handleTerminalChatEvent(host, payload, state, activeRunIdBeforeEvent); const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload; const deferredSessionKey = deferredReloadHost.pendingSessionMessageReloadSessionKey?.trim(); const payloadSessionKey = payload?.sessionKey?.trim(); @@ -479,7 +495,12 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u if (deferredSessionKey && payloadSessionKey && deferredSessionKey === payloadSessionKey) { deferredReloadHost.pendingSessionMessageReloadSessionKey = null; } - if (state === "final" && !historyReloaded && shouldReloadHistoryForFinalEvent(payload)) { + if ( + state === "final" && + !historyReloaded && + !terminalEventIsForDifferentActiveRun && + shouldReloadHistoryForFinalEvent(payload) + ) { void loadChatHistory(host as unknown as ChatState); return; } diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index 6727b00d824..8f92dad766d 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -501,7 +501,12 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo if (!entry) { // Commit any in-progress streaming text as a segment so it renders // above the tool card instead of below it. - if (host.chatStream && host.chatStream.trim().length > 0) { + if ( + host.chatRunId && + payload.runId === host.chatRunId && + host.chatStream && + host.chatStream.trim().length > 0 + ) { host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }]; host.chatStream = null; host.chatStreamStartedAt = null;