mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
671 lines
21 KiB
TypeScript
671 lines
21 KiB
TypeScript
// Control UI chat module implements stream reconciliation behavior.
|
|
import { asNullableRecord as asToolRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import {
|
|
isToolCallContentType,
|
|
isToolResultContentType,
|
|
resolveToolUseId,
|
|
} from "../../../../src/chat/tool-content.js";
|
|
import {
|
|
streamSegmentHasItemId,
|
|
streamSegmentUsesAccumulatedText,
|
|
trimAccumulatedStreamPrefix,
|
|
} from "../../lib/chat/chat-types.ts";
|
|
import { extractText } from "../../lib/chat/message-extract.ts";
|
|
import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
} from "../../lib/string-coerce.ts";
|
|
import { resetToolStream } from "./tool-stream.ts";
|
|
|
|
type StreamReconciliationState = {
|
|
chatStream: string | null;
|
|
chatStreamStartedAt: number | null;
|
|
};
|
|
|
|
type ToolStreamHost = StreamReconciliationState & {
|
|
chatStreamSegments?: Array<{
|
|
text?: unknown;
|
|
ts?: unknown;
|
|
toolCallId?: unknown;
|
|
itemId?: unknown;
|
|
}>;
|
|
chatToolMessages?: unknown[];
|
|
toolStreamById?: Map<string, unknown>;
|
|
toolStreamOrder?: unknown[];
|
|
};
|
|
|
|
type VisibleAssistantStreamPart = {
|
|
text: string;
|
|
replacementText: string;
|
|
source: "segment" | "current";
|
|
timestamp: number;
|
|
itemId?: string;
|
|
toolCallId?: string;
|
|
};
|
|
|
|
type ToolMessageRef = {
|
|
id: string;
|
|
};
|
|
|
|
const TOOL_NAME_FIELDS = ["toolName", "tool_name"] as const;
|
|
|
|
function addToolRef(refs: ToolMessageRef[], seen: Set<string>, id: string | undefined) {
|
|
if (!id || seen.has(id)) {
|
|
return;
|
|
}
|
|
seen.add(id);
|
|
refs.push({ id });
|
|
}
|
|
|
|
function isToolLikeRole(role: unknown): boolean {
|
|
return typeof role === "string" && normalizeRoleForGrouping(role).toLowerCase() === "tool";
|
|
}
|
|
|
|
function hasToolName(message: Record<string, unknown>): boolean {
|
|
return TOOL_NAME_FIELDS.some((field) => Boolean(normalizeOptionalString(message[field])));
|
|
}
|
|
|
|
function toolContentBlocks(message: Record<string, unknown>): Record<string, unknown>[] {
|
|
return Array.isArray(message.content)
|
|
? message.content.filter(
|
|
(block): block is Record<string, unknown> => Boolean(block) && typeof block === "object",
|
|
)
|
|
: [];
|
|
}
|
|
|
|
function isToolContentBlock(block: Record<string, unknown>): boolean {
|
|
return isToolCallContentType(block.type) || isToolResultContentType(block.type);
|
|
}
|
|
|
|
function extractToolMessageRefs(message: unknown): ToolMessageRef[] {
|
|
const record = asToolRecord(message);
|
|
if (!record) {
|
|
return [];
|
|
}
|
|
|
|
const refs: ToolMessageRef[] = [];
|
|
const seen = new Set<string>();
|
|
const blocks = toolContentBlocks(record);
|
|
const topLevelToolId = resolveToolUseId(record);
|
|
const messageHasToolShape =
|
|
isToolLikeRole(record.role) || hasToolName(record) || blocks.some(isToolContentBlock);
|
|
|
|
if (messageHasToolShape) {
|
|
addToolRef(refs, seen, topLevelToolId);
|
|
}
|
|
|
|
for (const block of blocks) {
|
|
if (!isToolContentBlock(block)) {
|
|
continue;
|
|
}
|
|
addToolRef(refs, seen, resolveToolUseId(block) ?? topLevelToolId);
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
type AssistantMessageVisibility = (message: unknown) => boolean;
|
|
type StreamVisibility = (stream: string) => boolean;
|
|
|
|
type MaterializeVisibleStreamOptions = {
|
|
includeCurrent?: boolean;
|
|
requirePersistedTool?: boolean;
|
|
replacementMessages?: unknown[];
|
|
persistCommentary?: boolean;
|
|
isHiddenAssistantMessage: AssistantMessageVisibility;
|
|
isHiddenStreamText: StreamVisibility;
|
|
};
|
|
|
|
export function currentLiveToolCallIds(state: StreamReconciliationState): string[] {
|
|
const toolHost = state as ToolStreamHost;
|
|
return Array.isArray(toolHost.toolStreamOrder)
|
|
? toolHost.toolStreamOrder.filter(
|
|
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
|
)
|
|
: [];
|
|
}
|
|
|
|
function lastUserMessageIndex(messages: unknown[]): number {
|
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
const message = messages[index];
|
|
if (!message || typeof message !== "object") {
|
|
continue;
|
|
}
|
|
const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role);
|
|
if (role === "user") {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
export function maybeResetToolStream(
|
|
state: StreamReconciliationState,
|
|
opts?: { preserveStreamSegments?: boolean },
|
|
) {
|
|
const toolHost = state as ToolStreamHost & Partial<Parameters<typeof resetToolStream>[0]>;
|
|
if (
|
|
toolHost.toolStreamById instanceof Map &&
|
|
Array.isArray(toolHost.toolStreamOrder) &&
|
|
Array.isArray(toolHost.chatToolMessages) &&
|
|
Array.isArray(toolHost.chatStreamSegments)
|
|
) {
|
|
const preservedStreamSegments = opts?.preserveStreamSegments
|
|
? [...toolHost.chatStreamSegments]
|
|
: null;
|
|
resetToolStream(toolHost as Parameters<typeof resetToolStream>[0]);
|
|
if (preservedStreamSegments) {
|
|
toolHost.chatStreamSegments = preservedStreamSegments;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function clearToolStreamSegments(state: StreamReconciliationState) {
|
|
const toolHost = state as ToolStreamHost;
|
|
if (Array.isArray(toolHost.chatStreamSegments)) {
|
|
toolHost.chatStreamSegments = [];
|
|
}
|
|
}
|
|
|
|
export function persistedCurrentToolStreamIds(
|
|
messages: unknown[],
|
|
state: StreamReconciliationState,
|
|
): Set<string> {
|
|
const liveToolIds = currentLiveToolCallIds(state);
|
|
const matchedToolIds = new Set<string>();
|
|
if (liveToolIds.length === 0) {
|
|
return matchedToolIds;
|
|
}
|
|
const liveToolIdSet = new Set(liveToolIds);
|
|
const persistedToolIds = new Set<string>();
|
|
for (const message of messages.slice(lastUserMessageIndex(messages) + 1)) {
|
|
for (const ref of extractToolMessageRefs(message)) {
|
|
persistedToolIds.add(ref.id);
|
|
}
|
|
}
|
|
for (const id of persistedToolIds) {
|
|
if (liveToolIdSet.has(id)) {
|
|
matchedToolIds.add(id);
|
|
}
|
|
}
|
|
return matchedToolIds;
|
|
}
|
|
|
|
function buildAssistantStreamMessage(
|
|
stream: string,
|
|
replacementText = stream,
|
|
timestamp = Date.now(),
|
|
source: VisibleAssistantStreamPart["source"] = "current",
|
|
itemId?: string,
|
|
): Record<string, unknown> {
|
|
return {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: stream }],
|
|
timestamp,
|
|
openclawStreamFallback: {
|
|
replacementText,
|
|
source,
|
|
...(itemId ? { itemId } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function streamFallbackReplacementText(message: unknown): string | null {
|
|
if (!message || typeof message !== "object") {
|
|
return null;
|
|
}
|
|
const fallback = (message as { openclawStreamFallback?: unknown }).openclawStreamFallback;
|
|
if (!fallback || typeof fallback !== "object") {
|
|
return null;
|
|
}
|
|
const replacementText = (fallback as { replacementText?: unknown }).replacementText;
|
|
if (typeof replacementText === "string" && replacementText.trim()) {
|
|
return replacementText.trim();
|
|
}
|
|
return extractText(message)?.trim() ?? null;
|
|
}
|
|
|
|
function terminalMessageReplacesStreamFallback(message: unknown, fallback: unknown): boolean {
|
|
const fallbackText = streamFallbackReplacementText(fallback);
|
|
if (!fallbackText) {
|
|
return false;
|
|
}
|
|
const metadata = (fallback as { openclawStreamFallback?: unknown }).openclawStreamFallback;
|
|
const source =
|
|
metadata && typeof metadata === "object"
|
|
? (metadata as { source?: unknown }).source
|
|
: undefined;
|
|
const itemId =
|
|
metadata && typeof metadata === "object"
|
|
? (metadata as { itemId?: unknown }).itemId
|
|
: undefined;
|
|
if (source === "segment" && typeof itemId === "string" && itemId.trim()) {
|
|
return false;
|
|
}
|
|
const terminalText = extractText(message)?.trim();
|
|
return Boolean(
|
|
terminalText && (terminalText === fallbackText || terminalText.startsWith(fallbackText)),
|
|
);
|
|
}
|
|
|
|
export function appendTerminalAssistantMessage(messages: unknown[], message: unknown): unknown[] {
|
|
const retainedMessages = messages.filter((existing, index) => {
|
|
if (index <= lastUserMessageIndex(messages)) {
|
|
return true;
|
|
}
|
|
return !terminalMessageReplacesStreamFallback(message, existing);
|
|
});
|
|
return [...retainedMessages, message];
|
|
}
|
|
|
|
function visibleAssistantStreamText(
|
|
stream: string | null,
|
|
isHiddenStreamText: StreamVisibility,
|
|
): string | null {
|
|
if (!stream?.trim() || isHiddenStreamText(stream)) {
|
|
return null;
|
|
}
|
|
return stream;
|
|
}
|
|
|
|
function hasAssistantStreamReplacement(
|
|
messages: unknown[],
|
|
stream: string,
|
|
isHiddenAssistantMessage: AssistantMessageVisibility,
|
|
): boolean {
|
|
const expected = stream.trim();
|
|
if (!expected) {
|
|
return false;
|
|
}
|
|
const startIndex = lastUserMessageIndex(messages) + 1;
|
|
return messages.slice(startIndex).some((message) => {
|
|
if (!message || typeof message !== "object") {
|
|
return false;
|
|
}
|
|
const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role);
|
|
if (role && role !== "assistant") {
|
|
return false;
|
|
}
|
|
if (role === "assistant" && isHiddenAssistantMessage(message)) {
|
|
return false;
|
|
}
|
|
const text = extractText(message)?.trim();
|
|
return Boolean(text && (text === expected || text.startsWith(expected)));
|
|
});
|
|
}
|
|
|
|
function streamFallbackItemId(message: unknown): string | null {
|
|
if (!message || typeof message !== "object") {
|
|
return null;
|
|
}
|
|
const fallback = (message as { openclawStreamFallback?: unknown }).openclawStreamFallback;
|
|
if (!fallback || typeof fallback !== "object") {
|
|
return null;
|
|
}
|
|
const itemId = (fallback as { itemId?: unknown }).itemId;
|
|
return typeof itemId === "string" && itemId.trim() ? itemId.trim() : null;
|
|
}
|
|
|
|
function hasKeyedAssistantStreamReplacement(messages: unknown[], itemId: string): boolean {
|
|
const startIndex = lastUserMessageIndex(messages) + 1;
|
|
return messages.slice(startIndex).some((message) => streamFallbackItemId(message) === itemId);
|
|
}
|
|
|
|
function visibleAssistantStreamParts(
|
|
state: StreamReconciliationState,
|
|
opts: Pick<MaterializeVisibleStreamOptions, "includeCurrent" | "isHiddenStreamText">,
|
|
): VisibleAssistantStreamPart[] {
|
|
const streamHost = state as ToolStreamHost;
|
|
const liveToolIds = currentLiveToolCallIds(state);
|
|
const parts: VisibleAssistantStreamPart[] = [];
|
|
let previousText: string | null = null;
|
|
const segments = Array.isArray(streamHost.chatStreamSegments)
|
|
? streamHost.chatStreamSegments
|
|
: [];
|
|
let toolIndexedSegmentIndex = 0;
|
|
for (const segment of segments) {
|
|
if (!segment || typeof segment.text !== "string") {
|
|
continue;
|
|
}
|
|
const explicitToolCallId =
|
|
typeof segment.toolCallId === "string" && segment.toolCallId.trim()
|
|
? segment.toolCallId.trim()
|
|
: null;
|
|
const usesItemId = streamSegmentHasItemId(segment);
|
|
const itemId =
|
|
usesItemId && typeof segment.itemId === "string" ? segment.itemId.trim() : undefined;
|
|
const indexedToolCallId = usesItemId ? undefined : liveToolIds[toolIndexedSegmentIndex];
|
|
if (!usesItemId) {
|
|
toolIndexedSegmentIndex += 1;
|
|
}
|
|
const usesAccumulatedText = streamSegmentUsesAccumulatedText(segment);
|
|
const visible = visibleAssistantStreamText(
|
|
usesAccumulatedText ? trimAccumulatedStreamPrefix(segment.text, previousText) : segment.text,
|
|
opts.isHiddenStreamText,
|
|
);
|
|
if (visible) {
|
|
parts.push({
|
|
text: visible,
|
|
replacementText: segment.text,
|
|
source: "segment",
|
|
timestamp:
|
|
typeof segment.ts === "number" && Number.isFinite(segment.ts) ? segment.ts : Date.now(),
|
|
...(itemId ? { itemId } : {}),
|
|
toolCallId: explicitToolCallId ?? indexedToolCallId,
|
|
});
|
|
}
|
|
if (usesAccumulatedText && segment.text.trim()) {
|
|
previousText = segment.text;
|
|
}
|
|
}
|
|
if (opts.includeCurrent !== false && typeof state.chatStream === "string") {
|
|
const visible = visibleAssistantStreamText(
|
|
trimAccumulatedStreamPrefix(state.chatStream, previousText),
|
|
opts.isHiddenStreamText,
|
|
);
|
|
if (visible) {
|
|
parts.push({
|
|
text: visible,
|
|
replacementText: state.chatStream,
|
|
source: "current",
|
|
timestamp: state.chatStreamStartedAt ?? Date.now(),
|
|
});
|
|
}
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
export function visibleCurrentAssistantStreamTail(
|
|
state: StreamReconciliationState,
|
|
isHiddenStreamText: StreamVisibility,
|
|
): string | null {
|
|
if (typeof state.chatStream !== "string") {
|
|
return null;
|
|
}
|
|
const streamHost = state as ToolStreamHost;
|
|
const segments = Array.isArray(streamHost.chatStreamSegments)
|
|
? streamHost.chatStreamSegments
|
|
: [];
|
|
let previousText: string | null = null;
|
|
for (const segment of segments) {
|
|
if (
|
|
streamSegmentUsesAccumulatedText(segment) &&
|
|
typeof segment.text === "string" &&
|
|
segment.text.trim()
|
|
) {
|
|
previousText = segment.text;
|
|
}
|
|
}
|
|
return visibleAssistantStreamText(
|
|
trimAccumulatedStreamPrefix(state.chatStream, previousText),
|
|
isHiddenStreamText,
|
|
);
|
|
}
|
|
|
|
function hasAssistantStreamPartReplacement(
|
|
messages: unknown[],
|
|
part: VisibleAssistantStreamPart,
|
|
isHiddenAssistantMessage: AssistantMessageVisibility,
|
|
): boolean {
|
|
if (part.itemId) {
|
|
return hasKeyedAssistantStreamReplacement(messages, part.itemId);
|
|
}
|
|
return (
|
|
hasAssistantStreamReplacement(messages, part.replacementText, isHiddenAssistantMessage) ||
|
|
hasAssistantStreamReplacement(messages, part.text, isHiddenAssistantMessage)
|
|
);
|
|
}
|
|
|
|
function hasVisibleAssistantMessageAfterUser(
|
|
messages: unknown[],
|
|
isHiddenAssistantMessage: AssistantMessageVisibility,
|
|
): boolean {
|
|
const startIndex = lastUserMessageIndex(messages) + 1;
|
|
return messages.slice(startIndex).some((message) => {
|
|
if (!message || typeof message !== "object") {
|
|
return false;
|
|
}
|
|
const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role);
|
|
if (role !== "assistant" || isHiddenAssistantMessage(message)) {
|
|
return false;
|
|
}
|
|
return Boolean(extractText(message)?.trim());
|
|
});
|
|
}
|
|
|
|
export function historyReplacedVisibleStream(
|
|
messages: unknown[],
|
|
state: StreamReconciliationState,
|
|
opts: Pick<
|
|
MaterializeVisibleStreamOptions,
|
|
"includeCurrent" | "isHiddenAssistantMessage" | "isHiddenStreamText" | "persistCommentary"
|
|
>,
|
|
): boolean {
|
|
const parts = visibleAssistantStreamParts(state, opts);
|
|
const requiredParts =
|
|
opts.persistCommentary === true ? parts : parts.filter((part) => !part.itemId);
|
|
return (
|
|
parts.length > 0 &&
|
|
(requiredParts.length > 0 ||
|
|
hasVisibleAssistantMessageAfterUser(messages, opts.isHiddenAssistantMessage)) &&
|
|
requiredParts.every((part) =>
|
|
hasAssistantStreamPartReplacement(messages, part, opts.isHiddenAssistantMessage),
|
|
)
|
|
);
|
|
}
|
|
|
|
export function hasVisibleStreamParts(
|
|
state: StreamReconciliationState,
|
|
opts: Pick<MaterializeVisibleStreamOptions, "includeCurrent" | "isHiddenStreamText">,
|
|
): boolean {
|
|
return visibleAssistantStreamParts(state, opts).length > 0;
|
|
}
|
|
|
|
export function terminalMessageReplacesVisibleStream(
|
|
message: unknown,
|
|
state: StreamReconciliationState,
|
|
opts: Pick<MaterializeVisibleStreamOptions, "isHiddenStreamText" | "persistCommentary">,
|
|
): boolean {
|
|
const terminalText = extractText(message)?.trim();
|
|
if (!terminalText) {
|
|
return false;
|
|
}
|
|
const parts = visibleAssistantStreamParts(state, {
|
|
includeCurrent: true,
|
|
isHiddenStreamText: opts.isHiddenStreamText,
|
|
}).filter((part) => opts.persistCommentary === true || !part.itemId);
|
|
if (parts.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
let searchStart = 0;
|
|
for (const [index, part] of parts.entries()) {
|
|
const text = part.text.trim();
|
|
const matchIndex = terminalText.indexOf(text, searchStart);
|
|
// A terminal replacement must preserve the complete visible stream in order,
|
|
// beginning with its first part. Otherwise both outputs are user-visible.
|
|
if (matchIndex < 0 || (index === 0 && matchIndex !== 0)) {
|
|
return false;
|
|
}
|
|
searchStart = matchIndex + text.length;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function currentToolStreamMessageIndex(
|
|
messages: unknown[],
|
|
state: StreamReconciliationState,
|
|
toolCallId?: string,
|
|
): number {
|
|
const liveToolIds = toolCallId ? new Set([toolCallId]) : new Set(currentLiveToolCallIds(state));
|
|
if (liveToolIds.size === 0) {
|
|
return -1;
|
|
}
|
|
const startIndex = lastUserMessageIndex(messages) + 1;
|
|
for (let index = startIndex; index < messages.length; index++) {
|
|
if (extractToolMessageRefs(messages[index]).some((ref) => liveToolIds.has(ref.id))) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function insertMessageAtIndex(messages: unknown[], message: unknown, index: number): unknown[] {
|
|
return [...messages.slice(0, index), message, ...messages.slice(index)];
|
|
}
|
|
|
|
function timestampOrderedInsertIndex(messages: unknown[], desiredTimestamp: number): number {
|
|
const startIndex = lastUserMessageIndex(messages) + 1;
|
|
for (let index = startIndex; index < messages.length; index++) {
|
|
const timestamp = messageTimestampMs(messages[index]);
|
|
if (timestamp != null && timestamp > desiredTimestamp) {
|
|
return index;
|
|
}
|
|
}
|
|
return messages.length;
|
|
}
|
|
|
|
export function messageTimestampMs(message: unknown): number | null {
|
|
if (!message || typeof message !== "object") {
|
|
return null;
|
|
}
|
|
const timestamp = (message as { timestamp?: unknown; ts?: unknown }).timestamp;
|
|
if (typeof timestamp === "number" && Number.isFinite(timestamp)) {
|
|
return timestamp;
|
|
}
|
|
const ts = (message as { timestamp?: unknown; ts?: unknown }).ts;
|
|
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
|
|
}
|
|
|
|
function timestampForInsertedVisibleStream(
|
|
messages: unknown[],
|
|
index: number,
|
|
desiredTimestamp: number,
|
|
): number {
|
|
const previousTimestamp = messages
|
|
.slice(0, index)
|
|
.toReversed()
|
|
.map(messageTimestampMs)
|
|
.find((timestamp): timestamp is number => timestamp != null);
|
|
const nextTimestamp = messages
|
|
.slice(index)
|
|
.map(messageTimestampMs)
|
|
.find((timestamp): timestamp is number => timestamp != null);
|
|
if (previousTimestamp != null && desiredTimestamp <= previousTimestamp) {
|
|
const afterPrevious = previousTimestamp + 1;
|
|
return nextTimestamp != null && afterPrevious >= nextTimestamp
|
|
? previousTimestamp + (nextTimestamp - previousTimestamp) / 2
|
|
: afterPrevious;
|
|
}
|
|
if (nextTimestamp != null && desiredTimestamp >= nextTimestamp) {
|
|
const beforeNext = nextTimestamp - 1;
|
|
return previousTimestamp != null && beforeNext <= previousTimestamp
|
|
? previousTimestamp + (nextTimestamp - previousTimestamp) / 2
|
|
: beforeNext;
|
|
}
|
|
return desiredTimestamp;
|
|
}
|
|
|
|
export function materializeVisibleStreamState(
|
|
messages: unknown[],
|
|
state: StreamReconciliationState,
|
|
opts: MaterializeVisibleStreamOptions,
|
|
): unknown[] {
|
|
let nextMessages = messages;
|
|
const persistCommentary = opts.persistCommentary === true;
|
|
for (const part of visibleAssistantStreamParts(state, opts)) {
|
|
if (!persistCommentary && part.itemId) {
|
|
continue;
|
|
}
|
|
const replacementMessages = opts.replacementMessages ?? [];
|
|
if (
|
|
hasAssistantStreamPartReplacement(
|
|
[...nextMessages, ...replacementMessages],
|
|
part,
|
|
opts.isHiddenAssistantMessage,
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
const toolIndex =
|
|
part.source === "segment" && part.toolCallId
|
|
? currentToolStreamMessageIndex(nextMessages, state, part.toolCallId)
|
|
: -1;
|
|
if (opts.requirePersistedTool && toolIndex < 0) {
|
|
continue;
|
|
}
|
|
const insertIndex =
|
|
toolIndex >= 0
|
|
? toolIndex
|
|
: part.source === "segment"
|
|
? timestampOrderedInsertIndex(nextMessages, part.timestamp)
|
|
: nextMessages.length;
|
|
const streamMessage = buildAssistantStreamMessage(
|
|
part.text,
|
|
part.replacementText,
|
|
timestampForInsertedVisibleStream(nextMessages, insertIndex, part.timestamp),
|
|
part.source,
|
|
part.itemId,
|
|
);
|
|
nextMessages = insertMessageAtIndex(nextMessages, streamMessage, insertIndex);
|
|
}
|
|
return nextMessages;
|
|
}
|
|
|
|
export function prunePersistedToolStreamMessages(
|
|
state: StreamReconciliationState,
|
|
persistedToolIds: Set<string>,
|
|
) {
|
|
if (persistedToolIds.size === 0) {
|
|
return;
|
|
}
|
|
const toolHost = state as ToolStreamHost;
|
|
const liveToolIds = currentLiveToolCallIds(state);
|
|
if (toolHost.toolStreamById instanceof Map) {
|
|
for (const id of persistedToolIds) {
|
|
toolHost.toolStreamById.delete(id);
|
|
}
|
|
}
|
|
if (Array.isArray(toolHost.toolStreamOrder)) {
|
|
toolHost.toolStreamOrder = toolHost.toolStreamOrder.filter(
|
|
(id): id is string => typeof id === "string" && !persistedToolIds.has(id),
|
|
);
|
|
}
|
|
if (Array.isArray(toolHost.chatToolMessages)) {
|
|
toolHost.chatToolMessages = toolHost.chatToolMessages.filter((message) => {
|
|
const refs = extractToolMessageRefs(message);
|
|
return refs.every((ref) => !persistedToolIds.has(ref.id));
|
|
});
|
|
}
|
|
if (!Array.isArray(toolHost.chatStreamSegments)) {
|
|
return;
|
|
}
|
|
let lastPrunedAccumulatedText: string | null = null;
|
|
let toolIndexedSegmentIndex = 0;
|
|
toolHost.chatStreamSegments = toolHost.chatStreamSegments.flatMap((segment) => {
|
|
const explicitToolCallId =
|
|
typeof segment.toolCallId === "string" && segment.toolCallId.trim()
|
|
? segment.toolCallId.trim()
|
|
: null;
|
|
const usesItemId = streamSegmentHasItemId(segment);
|
|
const indexedToolCallId = usesItemId ? null : (liveToolIds[toolIndexedSegmentIndex] ?? null);
|
|
if (!usesItemId) {
|
|
toolIndexedSegmentIndex += 1;
|
|
}
|
|
const toolCallId = explicitToolCallId ?? indexedToolCallId;
|
|
const text = typeof segment.text === "string" ? segment.text : "";
|
|
if (toolCallId && persistedToolIds.has(toolCallId)) {
|
|
if (streamSegmentUsesAccumulatedText(segment) && text.trim()) {
|
|
lastPrunedAccumulatedText = text;
|
|
}
|
|
return [];
|
|
}
|
|
const nextText =
|
|
lastPrunedAccumulatedText && streamSegmentUsesAccumulatedText(segment)
|
|
? trimAccumulatedStreamPrefix(text, lastPrunedAccumulatedText)
|
|
: text;
|
|
return [{ ...segment, text: nextText }];
|
|
});
|
|
}
|