diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 17d21b22a02..726a284ac58 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { abortChatRunById, abortChatRunsForProvider, + boundInFlightRunSnapshotForChatHistory, isChatStopCommandText, registerChatAbortController, resolveAgentRunExpiresAtMs, @@ -544,4 +545,24 @@ describe("resolveInFlightRunSnapshot", () => { }), ).toEqual({ runId: "run-b", text: "b" }); }); + + it("keeps in-flight text when it fits the chat history budget", () => { + expect( + boundInFlightRunSnapshotForChatHistory({ + snapshot: { runId: "run-1", text: "partial" }, + messages: [], + maxBytes: 1_000, + }), + ).toEqual({ runId: "run-1", text: "partial" }); + }); + + it("drops oversized in-flight text but keeps the run id for adoption", () => { + expect( + boundInFlightRunSnapshotForChatHistory({ + snapshot: { runId: "run-1", text: "x".repeat(1_000) }, + messages: [], + maxBytes: 100, + }), + ).toEqual({ runId: "run-1", text: "" }); + }); }); diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 8dd1c1bbac6..aba6c7c06c4 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -7,6 +7,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { isAbortRequestText } from "../auto-reply/reply/abort-primitives.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; const DEFAULT_CHAT_RUN_ABORT_GRACE_MS = 60_000; @@ -237,6 +238,25 @@ export function resolveInFlightRunSnapshot(params: { return { runId: best.runId, text: params.chatRunBuffers?.get(best.runId) ?? "" }; } +export function boundInFlightRunSnapshotForChatHistory(params: { + snapshot: { runId: string; text: string } | undefined; + messages: unknown[]; + maxBytes: number; +}): { runId: string; text: string } | undefined { + if (!params.snapshot?.text) { + return params.snapshot; + } + const messagesBytes = jsonUtf8Bytes(params.messages); + const snapshotBytes = jsonUtf8Bytes(params.snapshot); + if (messagesBytes + snapshotBytes <= params.maxBytes) { + return params.snapshot; + } + // The run id is the recovery contract; buffered partial text is opportunistic. + // If it would break the history payload budget, keep adoption and wait for the + // next live delta/final instead of sending an oversized chat.history response. + return { runId: params.snapshot.runId, text: "" }; +} + export type ChatAbortOps = { chatAbortControllers: Map; chatRunBuffers: Map; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1e3be318b42..190011ec7b0 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -99,6 +99,7 @@ import { } from "../../utils/message-channel.js"; import { abortChatRunById, + boundInFlightRunSnapshotForChatHistory, type ChatAbortControllerEntry, type ChatAbortOps, isChatStopCommandText, @@ -2556,6 +2557,11 @@ export const chatHandlers: GatewayRequestHandlers = { agentId: requestedAgentId, defaultAgentId: resolveDefaultAgentId(cfg), }); + const boundedInFlightRun = boundInFlightRunSnapshotForChatHistory({ + snapshot: inFlightRun, + messages: bounded.messages, + maxBytes: maxHistoryBytes, + }); respond(true, { sessionKey, sessionId, @@ -2565,7 +2571,7 @@ export const chatHandlers: GatewayRequestHandlers = { thinkingLevel, fastMode: entry?.fastMode, verboseLevel, - ...(inFlightRun ? { inFlightRun } : {}), + ...(boundedInFlightRun ? { inFlightRun: boundedInFlightRun } : {}), }); }, "chat.message.get": async ({ params, respond, context }) => {