mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix(agents): narrow phase-aware history hardening (#61829) (thanks @100yenadmin)
This commit is contained in:
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
|
||||
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
|
||||
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
|
||||
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function asMessages(messages: unknown[]): AgentMessage[] {
|
||||
|
||||
function makeDualToolUseAssistantContent() {
|
||||
return [
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", arguments: {} },
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", input: {} },
|
||||
{ type: "toolUse", id: "tool-2", name: "test2", input: {} },
|
||||
{ type: "text", text: "Done" },
|
||||
];
|
||||
@@ -123,7 +123,7 @@ describe("validateGeminiTurns", () => {
|
||||
{ role: "user", content: "Use tool" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }],
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
@@ -368,7 +368,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "tool-1", name: "test", arguments: {} },
|
||||
{ type: "toolUse", id: "tool-1", name: "test", input: {} },
|
||||
{ type: "text", text: "I'll check that" },
|
||||
],
|
||||
},
|
||||
@@ -389,7 +389,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "tool-1", name: "test", arguments: {} },
|
||||
{ type: "toolUse", id: "tool-1", name: "test", input: {} },
|
||||
{ type: "text", text: "Here's result" },
|
||||
],
|
||||
},
|
||||
@@ -408,7 +408,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
// tool_use should be preserved because matching tool_result exists
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([
|
||||
{ type: "toolUse", id: "tool-1", name: "test", arguments: {} },
|
||||
{ type: "toolUse", id: "tool-1", name: "test", input: {} },
|
||||
{ type: "text", text: "Here's result" },
|
||||
]);
|
||||
});
|
||||
@@ -418,7 +418,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }],
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
]);
|
||||
@@ -458,7 +458,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
// tool-1 should be preserved (has matching tool_result), tool-2 stripped, text preserved
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", arguments: {} },
|
||||
{ type: "toolUse", id: "tool-1", name: "test1", input: {} },
|
||||
{ type: "text", text: "Done" },
|
||||
]);
|
||||
});
|
||||
@@ -468,7 +468,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }],
|
||||
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
|
||||
},
|
||||
// Next is assistant, not user - should not strip
|
||||
{ role: "assistant", content: [{ type: "text", text: "Continue" }] },
|
||||
@@ -479,7 +479,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
expect(result).toHaveLength(3);
|
||||
// Original tool_use should be preserved
|
||||
const assistantContent = (result[1] as { content?: unknown[] }).content;
|
||||
expect(assistantContent).toEqual([{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }]);
|
||||
expect(assistantContent).toEqual([{ type: "toolUse", id: "tool-1", name: "test", input: {} }]);
|
||||
});
|
||||
|
||||
it("is replay-safe across repeated validation passes", () => {
|
||||
@@ -510,47 +510,5 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
|
||||
expect(() => validateAnthropicTurns(msgs)).not.toThrow();
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1]).toMatchObject({ content: "legacy-content" });
|
||||
});
|
||||
|
||||
|
||||
it("matches tool results from standalone toolResult/tool turns and stops at the next assistant", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "test", arguments: {} }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "intermediate" }] },
|
||||
{ role: "toolResult", tool_call_id: "tool-1", content: [{ type: "text", text: "Result" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "New assistant boundary" }],
|
||||
},
|
||||
{ role: "tool", toolUseId: "tool-1", content: [{ type: "text", text: "Late result" }] },
|
||||
] as unknown as AgentMessage[]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect((result[1] as { content?: unknown[] }).content).toEqual([
|
||||
{ type: "toolCall", id: "tool-1", name: "test", arguments: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not synthesize fallback text for aborted mid-transcript tool-only turns", () => {
|
||||
const msgs = asMessages([
|
||||
{ role: "user", content: [{ type: "text", text: "Use tool" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "aborted",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "test", arguments: {} }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Retry" }] },
|
||||
]);
|
||||
|
||||
const result = validateAnthropicTurns(msgs);
|
||||
|
||||
expect(result[1]).toMatchObject({ stopReason: "aborted", content: [] });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
// Extend this union when pi-agent-core adds new tool-call block types
|
||||
type AnthropicContentBlock = {
|
||||
type: "text" | "toolUse" | "toolResult" | "toolCall" | "functionCall";
|
||||
type: "text" | "toolUse" | "toolResult";
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
toolUseId?: string;
|
||||
toolCallId?: string;
|
||||
tool_use_id?: string;
|
||||
tool_call_id?: string;
|
||||
};
|
||||
|
||||
/** Recognizes toolUse, toolCall, and functionCall block types from different providers/core versions */
|
||||
function isToolCallBlock(type: string | undefined): boolean {
|
||||
return type === "toolUse" || type === "toolCall" || type === "functionCall";
|
||||
}
|
||||
|
||||
function isAbortedAssistantTurn(msg: AgentMessage): boolean {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
return false;
|
||||
}
|
||||
const stopReason = (msg as { stopReason?: unknown }).stopReason;
|
||||
return stopReason === "error" || stopReason === "aborted";
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips dangling assistant tool-call blocks (toolUse/toolCall/functionCall)
|
||||
* when no later message in the same assistant span contains a matching
|
||||
* tool_result block. This fixes the "tool_use ids found without tool_result
|
||||
* blocks" error from Anthropic. Aborted/error turns are still filtered for
|
||||
* dangling tool calls, but they do not receive synthesized fallback text.
|
||||
* Strips dangling tool_use blocks from assistant messages when the immediately
|
||||
* following user message does not contain a matching tool_result block.
|
||||
* This fixes the "tool_use ids found without tool_result blocks" error from Anthropic.
|
||||
*/
|
||||
function stripDanglingAnthropicToolUses(messages: AgentMessage[]): AgentMessage[] {
|
||||
const result: AgentMessage[] = [];
|
||||
@@ -52,76 +33,47 @@ function stripDanglingAnthropicToolUses(messages: AgentMessage[]): AgentMessage[
|
||||
content?: AnthropicContentBlock[];
|
||||
};
|
||||
|
||||
const isAbortedTurn = isAbortedAssistantTurn(msg);
|
||||
// Get the next message to check for tool_result blocks
|
||||
const nextMsg = messages[i + 1];
|
||||
const nextMsgRole =
|
||||
nextMsg && typeof nextMsg === "object"
|
||||
? ((nextMsg as { role?: unknown }).role as string | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!Array.isArray(assistantMsg.content)) {
|
||||
// If next message is not user, keep the assistant message as-is
|
||||
if (nextMsgRole !== "user") {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan ALL subsequent messages in this assistant span for matching tool_result blocks.
|
||||
// OpenAI-compatible transcripts can have assistant(toolCall) → user(text) → toolResult
|
||||
// ordering, so we must look beyond the immediate next message.
|
||||
// TODO: optimize to single-pass suffix set if this helper becomes hot.
|
||||
// Collect tool_use_ids from the next user message's tool_result blocks
|
||||
const nextUserMsg = nextMsg as {
|
||||
content?: AnthropicContentBlock[];
|
||||
};
|
||||
const validToolUseIds = new Set<string>();
|
||||
for (let j = i + 1; j < messages.length; j++) {
|
||||
const futureMsg = messages[j];
|
||||
if (!futureMsg || typeof futureMsg !== "object") {
|
||||
continue;
|
||||
}
|
||||
const futureRole = (futureMsg as { role?: unknown }).role as string | undefined;
|
||||
if (futureRole === "assistant") {
|
||||
break;
|
||||
}
|
||||
if (futureRole !== "user" && futureRole !== "toolResult" && futureRole !== "tool") {
|
||||
continue;
|
||||
}
|
||||
const futureUserMsg = futureMsg as {
|
||||
content?: AnthropicContentBlock[];
|
||||
toolUseId?: string;
|
||||
toolCallId?: string;
|
||||
tool_use_id?: string;
|
||||
tool_call_id?: string;
|
||||
};
|
||||
if (futureRole === "toolResult" || futureRole === "tool") {
|
||||
const directToolResultId =
|
||||
futureUserMsg.toolUseId ??
|
||||
futureUserMsg.toolCallId ??
|
||||
futureUserMsg.tool_use_id ??
|
||||
futureUserMsg.tool_call_id;
|
||||
if (directToolResultId) {
|
||||
validToolUseIds.add(directToolResultId);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(futureUserMsg.content)) {
|
||||
continue;
|
||||
}
|
||||
for (const block of futureUserMsg.content) {
|
||||
if (block && block.type === "toolResult") {
|
||||
const toolResultId =
|
||||
block.toolUseId ?? block.toolCallId ?? block.tool_use_id ?? block.tool_call_id;
|
||||
if (toolResultId) {
|
||||
validToolUseIds.add(toolResultId);
|
||||
}
|
||||
if (Array.isArray(nextUserMsg.content)) {
|
||||
for (const block of nextUserMsg.content) {
|
||||
if (block && block.type === "toolResult" && block.toolUseId) {
|
||||
validToolUseIds.add(block.toolUseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out tool-call blocks that don't have matching tool_result
|
||||
const originalContent = assistantMsg.content;
|
||||
// Filter out tool_use blocks that don't have matching tool_result
|
||||
const originalContent = Array.isArray(assistantMsg.content) ? assistantMsg.content : [];
|
||||
const filteredContent = originalContent.filter((block) => {
|
||||
if (!block) {
|
||||
return false;
|
||||
}
|
||||
if (!isToolCallBlock(block.type)) {
|
||||
if (block.type !== "toolUse") {
|
||||
return true;
|
||||
}
|
||||
// Keep tool call if its id is in the valid set
|
||||
// Keep tool_use if its id is in the valid set
|
||||
return validToolUseIds.has(block.id || "");
|
||||
});
|
||||
|
||||
// If all content would be removed, insert a minimal fallback text block for non-aborted turns.
|
||||
if (originalContent.length > 0 && filteredContent.length === 0 && !isAbortedTurn) {
|
||||
// If all content would be removed, insert a minimal fallback text block
|
||||
if (originalContent.length > 0 && filteredContent.length === 0) {
|
||||
result.push({
|
||||
...assistantMsg,
|
||||
content: [{ type: "text", text: "[tool calls omitted]" }],
|
||||
@@ -134,33 +86,6 @@ function stripDanglingAnthropicToolUses(messages: AgentMessage[]): AgentMessage[
|
||||
}
|
||||
}
|
||||
|
||||
// See also: main loop tool_use stripping above
|
||||
// Handle end-of-conversation orphans: if the last message is assistant with
|
||||
// tool-call blocks and no following user message, strip those blocks.
|
||||
if (result.length > 0) {
|
||||
const lastMsg = result[result.length - 1];
|
||||
const lastRole =
|
||||
lastMsg && typeof lastMsg === "object"
|
||||
? ((lastMsg as { role?: unknown }).role as string | undefined)
|
||||
: undefined;
|
||||
if (lastRole === "assistant") {
|
||||
const lastAssistant = lastMsg as { content?: AnthropicContentBlock[] };
|
||||
if (Array.isArray(lastAssistant.content)) {
|
||||
const hasToolUse = lastAssistant.content.some((b) => b && isToolCallBlock(b.type));
|
||||
if (hasToolUse) {
|
||||
const filtered = lastAssistant.content.filter((b) => b && !isToolCallBlock(b.type));
|
||||
result[result.length - 1] =
|
||||
filtered.length > 0 || isAbortedAssistantTurn(lastMsg)
|
||||
? ({ ...lastAssistant, content: filtered } as AgentMessage)
|
||||
: ({
|
||||
...lastAssistant,
|
||||
content: [{ type: "text" as const, text: "[tool calls omitted]" }],
|
||||
} as AgentMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -268,7 +193,6 @@ export function validateAnthropicTurns(messages: AgentMessage[]): AgentMessage[]
|
||||
// First, strip dangling tool_use blocks from assistant messages
|
||||
const stripped = stripDanglingAnthropicToolUses(messages);
|
||||
|
||||
// Then merge consecutive user messages
|
||||
return validateTurnsWithConsecutiveMerge({
|
||||
messages: stripped,
|
||||
role: "user",
|
||||
|
||||
@@ -658,8 +658,30 @@ function sanitizeChatHistoryMessage(
|
||||
changed ||= stripped.changed || res.truncated;
|
||||
} else if (Array.isArray(entry.content)) {
|
||||
const updated = entry.content.map((block) => sanitizeChatHistoryContentBlock(block, maxChars));
|
||||
if (updated.some((item) => item.changed)) {
|
||||
entry.content = updated.map((item) => item.block);
|
||||
const sanitizedBlocks = updated.map((item) => item.block);
|
||||
const hasPhaseMetadata =
|
||||
entry.role === "assistant" &&
|
||||
entry.content.some(
|
||||
(block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
typeof (block as { textSignature?: unknown }).textSignature === "string",
|
||||
);
|
||||
if (hasPhaseMetadata) {
|
||||
const stripped = stripInlineDirectiveTagsForDisplay(
|
||||
extractAssistantVisibleText(entry as Parameters<typeof extractAssistantVisibleText>[0]),
|
||||
);
|
||||
const res = truncateChatHistoryText(stripped.text, maxChars);
|
||||
const nonTextBlocks = sanitizedBlocks.filter(
|
||||
(block) =>
|
||||
!block || typeof block !== "object" || (block as { type?: unknown }).type !== "text",
|
||||
);
|
||||
entry.content = res.text
|
||||
? [{ type: "text", text: res.text }, ...nonTextBlocks]
|
||||
: nonTextBlocks;
|
||||
changed = true;
|
||||
} else if (updated.some((item) => item.changed)) {
|
||||
entry.content = sanitizedBlocks;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
|
||||
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||
import { testState } from "./test-helpers.runtime-state.js";
|
||||
import {
|
||||
connectReq,
|
||||
@@ -54,6 +56,56 @@ async function seedSession(params?: { text?: string }) {
|
||||
return { storePath };
|
||||
}
|
||||
|
||||
function makeTranscriptAssistantMessage(params: {
|
||||
text: string;
|
||||
content?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: params.content ?? [{ type: "text", text: params.text }],
|
||||
api: "openai-responses",
|
||||
provider: "openclaw",
|
||||
model: "delivery-mirror",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function appendTranscriptMessage(params: {
|
||||
sessionFile: string;
|
||||
sessionKey: string;
|
||||
message: ReturnType<typeof makeTranscriptAssistantMessage>;
|
||||
emitInlineMessage?: boolean;
|
||||
}): string {
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
const messageId = sessionManager.appendMessage(params.message);
|
||||
emitSessionTranscriptUpdate(
|
||||
params.emitInlineMessage === false
|
||||
? params.sessionFile
|
||||
: {
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
message: params.message,
|
||||
messageId,
|
||||
},
|
||||
);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
async function fetchSessionHistory(
|
||||
port: number,
|
||||
sessionKey: string,
|
||||
@@ -329,7 +381,7 @@ describe("session history HTTP endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("sanitizes unbounded SSE push updates before emitting them", async () => {
|
||||
test("sanitizes phased assistant history entries before returning them", async () => {
|
||||
const storePath = await createSessionStoreFile();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
@@ -342,18 +394,6 @@ describe("session history HTTP endpoints", () => {
|
||||
});
|
||||
|
||||
await withGatewayHarness(async (harness) => {
|
||||
const res = await fetchSessionHistory(harness.port, "agent:main:main", {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const reader = res.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
const streamState = { buffer: "" };
|
||||
const historyEvent = await readSseEvent(reader!, streamState);
|
||||
expect(historyEvent.event).toBe("history");
|
||||
expect((historyEvent.data as { messages?: unknown[] }).messages ?? []).toHaveLength(0);
|
||||
|
||||
const hidden = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "NO_REPLY",
|
||||
@@ -361,11 +401,14 @@ describe("session history HTTP endpoints", () => {
|
||||
});
|
||||
expect(hidden.ok).toBe(true);
|
||||
|
||||
const visible = await appendAssistantMessageToSessionTranscript({
|
||||
if (!hidden.ok) {
|
||||
throw new Error(`append failed: ${hidden.reason}`);
|
||||
}
|
||||
const visibleMessageId = appendTranscriptMessage({
|
||||
sessionFile: hidden.sessionFile,
|
||||
sessionKey: "agent:main:main",
|
||||
text: "Done.",
|
||||
message: {
|
||||
role: "assistant",
|
||||
message: makeTranscriptAssistantMessage({
|
||||
text: "Done.",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
@@ -378,30 +421,26 @@ describe("session history HTTP endpoints", () => {
|
||||
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
storePath,
|
||||
}),
|
||||
emitInlineMessage: false,
|
||||
});
|
||||
expect(visible.ok).toBe(true);
|
||||
|
||||
const messageEvent = await readSseEvent(reader!, streamState);
|
||||
expect(messageEvent.event).toBe("message");
|
||||
expect(
|
||||
(messageEvent.data as { message?: { content?: Array<{ text?: string }> } }).message?.content?.[0]
|
||||
?.text,
|
||||
).toBe("Done.");
|
||||
expect((messageEvent.data as { messageSeq?: number }).messageSeq).toBe(2);
|
||||
expect(
|
||||
(
|
||||
messageEvent.data as {
|
||||
message?: { __openclaw?: { id?: string; seq?: number } };
|
||||
}
|
||||
).message?.__openclaw,
|
||||
).toMatchObject({
|
||||
id: visible.ok ? visible.messageId : undefined,
|
||||
const historyRes = await fetchSessionHistory(harness.port, "agent:main:main");
|
||||
expect(historyRes.status).toBe(200);
|
||||
const body = (await historyRes.json()) as {
|
||||
sessionKey?: string;
|
||||
messages?: Array<{
|
||||
content?: Array<{ text?: string }>;
|
||||
__openclaw?: { id?: string; seq?: number };
|
||||
}>;
|
||||
};
|
||||
expect(body.sessionKey).toBe("agent:main:main");
|
||||
expect(body.messages).toHaveLength(1);
|
||||
expect(body.messages?.[0]?.content?.[0]?.text).toBe("Done.");
|
||||
expect(body.messages?.[0]?.__openclaw).toMatchObject({
|
||||
id: visibleMessageId,
|
||||
seq: 2,
|
||||
});
|
||||
|
||||
await reader?.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -515,6 +554,78 @@ describe("session history HTTP endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("resyncs raw sequence numbering after transcript-only SSE refreshes", async () => {
|
||||
const { storePath } = await seedSession({ text: "first message" });
|
||||
|
||||
await withGatewayHarness(async (harness) => {
|
||||
const res = await fetchSessionHistory(harness.port, "agent:main:main", {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const reader = res.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
const streamState = { buffer: "" };
|
||||
await readSseEvent(reader!, streamState);
|
||||
|
||||
const second = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "second visible message",
|
||||
storePath,
|
||||
});
|
||||
expect(second.ok).toBe(true);
|
||||
|
||||
const secondEvent = await readSseEvent(reader!, streamState);
|
||||
expect(secondEvent.event).toBe("message");
|
||||
expect((secondEvent.data as { messageSeq?: number }).messageSeq).toBe(2);
|
||||
|
||||
if (!second.ok) {
|
||||
throw new Error(`append failed: ${second.reason}`);
|
||||
}
|
||||
appendTranscriptMessage({
|
||||
sessionFile: second.sessionFile,
|
||||
sessionKey: "agent:main:main",
|
||||
message: makeTranscriptAssistantMessage({ text: "NO_REPLY" }),
|
||||
emitInlineMessage: false,
|
||||
});
|
||||
|
||||
const refreshEvent = await readSseEvent(reader!, streamState);
|
||||
expect(refreshEvent.event).toBe("history");
|
||||
expect(
|
||||
(
|
||||
refreshEvent.data as { messages?: Array<{ content?: Array<{ text?: string }> }> }
|
||||
).messages?.map((message) => message.content?.[0]?.text),
|
||||
).toEqual(["first message", "second visible message"]);
|
||||
|
||||
const third = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "third visible message",
|
||||
storePath,
|
||||
});
|
||||
expect(third.ok).toBe(true);
|
||||
|
||||
const thirdEvent = await readSseEvent(reader!, streamState);
|
||||
expect(thirdEvent.event).toBe("message");
|
||||
expect(
|
||||
(thirdEvent.data as { message?: { content?: Array<{ text?: string }> } }).message
|
||||
?.content?.[0]?.text,
|
||||
).toBe("third visible message");
|
||||
expect((thirdEvent.data as { messageSeq?: number }).messageSeq).toBe(4);
|
||||
expect(
|
||||
(
|
||||
thirdEvent.data as {
|
||||
message?: { __openclaw?: { id?: string; seq?: number } };
|
||||
}
|
||||
).message?.__openclaw,
|
||||
).toMatchObject({
|
||||
id: third.ok ? third.messageId : undefined,
|
||||
seq: 4,
|
||||
});
|
||||
|
||||
await reader?.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects session history when operator.read is not requested", async () => {
|
||||
await seedSession({ text: "scope-guarded history" });
|
||||
|
||||
|
||||
@@ -302,13 +302,20 @@ export async function handleSessionHistoryHttpRequest(
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Transcript-only updates can advance the raw transcript without carrying
|
||||
// an inline message payload. Resync the raw seq counter before the next
|
||||
// fast-path append so later messageSeq values stay monotonic.
|
||||
const refreshedRawMessages = readSessionMessages(
|
||||
entry.sessionId,
|
||||
target.storePath,
|
||||
entry.sessionFile,
|
||||
);
|
||||
rawTranscriptSeq =
|
||||
resolveMessageSeq(refreshedRawMessages.at(-1)) ?? refreshedRawMessages.length;
|
||||
// Bounded SSE history refreshes: apply sanitizeChatHistoryMessages before
|
||||
// pagination, consistent with the unbounded path.
|
||||
sentHistory = paginateSessionMessages(
|
||||
sanitizeChatHistoryMessages(
|
||||
readSessionMessages(entry.sessionId, target.storePath, entry.sessionFile),
|
||||
effectiveMaxChars,
|
||||
),
|
||||
sanitizeChatHistoryMessages(refreshedRawMessages, effectiveMaxChars),
|
||||
limit,
|
||||
cursor,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user