fix: bound in-flight history snapshots

This commit is contained in:
Peter Steinberger
2026-06-01 01:37:57 +01:00
parent d1189649f6
commit 9f5031575f
3 changed files with 48 additions and 1 deletions

View File

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

View File

@@ -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<string, ChatAbortControllerEntry>;
chatRunBuffers: Map<string, string>;

View File

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