mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(gateway): unify chat display projection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
539
src/gateway/chat-display-projection.ts
Normal file
539
src/gateway/chat-display-projection.ts
Normal 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];
|
||||
}
|
||||
102
src/gateway/live-chat-projector.ts
Normal file
102
src/gateway/live-chat-projector.ts
Normal 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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,6 +80,7 @@ function resolveManifestProviderAuthEnvVarCandidates(
|
||||
config: params?.config,
|
||||
workspaceDir: params?.workspaceDir,
|
||||
env: params?.env,
|
||||
preferPersisted: false,
|
||||
includeDisabled: true,
|
||||
});
|
||||
const candidates: Record<string, string[]> = {};
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user