fix(gateway): unify chat display projection

This commit is contained in:
Peter Steinberger
2026-04-26 05:33:46 +01:00
parent dc9ce2a1bf
commit 5f2273e81e
18 changed files with 987 additions and 743 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,
});
});
});

View File

@@ -9,7 +9,6 @@ type EmbeddedCallGateway = <T = Record<string, unknown>>(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<string, unknown>): 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();

View File

@@ -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<string, unknown>) };
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<string, unknown>) };
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<string, unknown>;
const total = toFiniteNumber(c.total);
return total !== undefined ? { total } : undefined;
}
function sanitizeUsage(raw: unknown): Record<string, number> | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const u = raw as Record<string, unknown>;
const out: Record<string, number> = {};
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<string, unknown>).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<string, unknown>) };
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<string, unknown>;
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<string, unknown>): 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<string, unknown>): 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<Record<string, unknown>> {
return messages.filter(
(message): message is Record<string, unknown> =>
Boolean(message) && typeof message === "object" && !Array.isArray(message),
);
}
function filterVisibleProjectedHistoryMessages(
messages: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
if (messages.length === 0) {
return messages;
}
let changed = false;
const visible: Array<Record<string, unknown>> = [];
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<Record<string, unknown>> {
const source = options?.stripEnvelope === false ? messages : stripEnvelopeFromMessages(messages);
return filterVisibleProjectedHistoryMessages(
toProjectedMessages(
sanitizeChatHistoryMessages(source, options?.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS),
),
);
}
export function limitChatDisplayMessages<T>(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<Record<string, unknown>> {
return limitChatDisplayMessages(
projectChatDisplayMessages(messages, options),
options?.maxMessages,
);
}
export function projectChatDisplayMessage(
message: unknown,
options?: { maxChars?: number; stripEnvelope?: boolean },
): Record<string, unknown> | undefined {
return projectChatDisplayMessages([message], options)[0];
}

View File

@@ -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";
}

View File

@@ -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);
}
}

View File

@@ -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<string, unknown>) };
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<string, unknown>) };
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<string, number> | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const u = raw as Record<string, unknown>;
const out: Record<string, number> = {};
// 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<string, unknown>).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<string, unknown>;
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<string, unknown>) };
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<string, unknown>;
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<typeof extractCanvasFromText>;
@@ -1809,15 +1407,12 @@ function broadcastChatFinal(params: {
message?: Record<string, unknown>;
}) {
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
const strippedEnvelopeMessage = stripEnvelopeFromMessage(params.message) as
| Record<string, unknown>
| 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<string, unknown>,
),
message,
};
context.broadcast("chat", chatPayload);
context.nodeSendToSession(sessionKey, "chat", chatPayload);

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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,

View File

@@ -80,6 +80,7 @@ function resolveManifestProviderAuthEnvVarCandidates(
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
preferPersisted: false,
includeDisabled: true,
});
const candidates: Record<string, string[]> = {};

View File

@@ -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?: {

View File

@@ -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<string, unknown>;
}>();
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({

View File

@@ -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;

View File

@@ -407,10 +407,14 @@ function handleTerminalChatEvent(
host: GatewayHost,
payload: ChatEventPayload | undefined,
state: ReturnType<typeof handleChatEvent>,
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<typeof resetToolStream>[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;
}

View File

@@ -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;