fix: preserve legacy replay phase boundaries (#61529) (thanks @100yenadmin)

This commit is contained in:
Peter Steinberger
2026-04-06 16:08:47 +01:00
parent a200a746fc
commit 16d0f0567e
3 changed files with 93 additions and 17 deletions

View File

@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including background-safe reconnects, persisted pending approvals, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07.
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
## 2026.4.5

View File

@@ -329,6 +329,16 @@ export function convertMessagesToInputItems(
if (Array.isArray(content)) {
const textParts: string[] = [];
let currentTextPhase: OpenAIResponsesAssistantPhase | undefined;
const hasExplicitBlockPhase = content.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const record = block as { type?: unknown; textSignature?: unknown };
return (
record.type === "text" &&
Boolean(parseAssistantTextSignature(record.textSignature)?.phase)
);
});
const pushAssistantText = (phase?: OpenAIResponsesAssistantPhase) => {
if (textParts.length === 0) {
return;
@@ -354,7 +364,12 @@ export function convertMessagesToInputItems(
if (block.type === "text" && typeof block.text === "string") {
const parsedSignature = parseAssistantTextSignature(block.textSignature);
const blockPhase =
parsedSignature?.phase ?? assistantMessagePhase;
parsedSignature?.phase ??
(parsedSignature?.id
? assistantMessagePhase
: hasExplicitBlockPhase
? undefined
: assistantMessagePhase);
if (textParts.length > 0 && blockPhase !== currentTextPhase) {
pushAssistantText(currentTextPhase);
}

View File

@@ -619,7 +619,7 @@ describe("convertMessagesToInputItems", () => {
[{ id: "call_1", name: "exec", args: { cmd: "ls" } }],
"commentary",
);
const items = convertMessagesToInputItems([msg] as Parameters<
const items = convertMessagesToInputItems([msg] as unknown as Parameters<
typeof convertMessagesToInputItems
>[0]);
const textItem = items.find((i) => i.type === "message");
@@ -648,7 +648,7 @@ describe("convertMessagesToInputItems", () => {
usage: {},
timestamp: 0,
};
const items = convertMessagesToInputItems([msg] as Parameters<
const items = convertMessagesToInputItems([msg] as unknown as Parameters<
typeof convertMessagesToInputItems
>[0]);
expect(items).toHaveLength(1);
@@ -704,7 +704,45 @@ describe("convertMessagesToInputItems", () => {
]);
});
it("inherits message-level phase for untagged blocks, merging with phased text", () => {
it("inherits message-level phase for id-only textSignature blocks, merging with phased text", () => {
const msg = {
role: "assistant" as const,
phase: "final_answer" as const,
content: [
{
type: "text" as const,
text: "Replay. ",
textSignature: JSON.stringify({ v: 1, id: "item_pending_phase" }),
},
{
type: "text" as const,
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
};
expect(
convertMessagesToInputItems([msg] as unknown as Parameters<
typeof convertMessagesToInputItems
>[0]),
).toEqual([
{
type: "message",
role: "assistant",
content: "Replay. Done.",
phase: "final_answer",
},
]);
});
it("keeps truly unsigned legacy blocks separate when phased siblings are present", () => {
const msg = {
role: "assistant" as const,
phase: "final_answer" as const,
@@ -735,10 +773,12 @@ describe("convertMessagesToInputItems", () => {
{
type: "message",
role: "assistant",
// Both blocks share the same effective phase (final_answer):
// the untagged block now inherits message-level phase instead
// of being forced to undefined (#61476).
content: "Legacy. Done.",
content: "Legacy. ",
},
{
type: "message",
role: "assistant",
content: "Done.",
phase: "final_answer",
},
]);
@@ -868,7 +908,7 @@ describe("convertMessagesToInputItems", () => {
it("handles assistant messages with only tool calls (no text)", () => {
const msg = assistantMsg([], [{ id: "call_2", name: "read", args: { path: "/etc/hosts" } }]);
const items = convertMessagesToInputItems([msg] as Parameters<
const items = convertMessagesToInputItems([msg] as unknown as Parameters<
typeof convertMessagesToInputItems
>[0]);
expect(items).toHaveLength(1);
@@ -3163,12 +3203,17 @@ describe("releaseWsSession / hasWsSession", () => {
});
describe("convertMessagesToInputItems — phase inheritance", () => {
it("untagged text blocks inherit message-level phase when siblings have explicit textSignature", () => {
it("keeps unsigned legacy text unphased while id-only replay text inherits message phase", () => {
const msg = {
role: "assistant" as const,
phase: "commentary",
content: [
{ type: "text", text: "Untagged block A" },
{
type: "text",
text: "Replay block",
textSignature: JSON.stringify({ v: 1, id: "s0" }),
},
{
type: "text",
text: "Explicitly final",
@@ -3177,15 +3222,30 @@ describe("convertMessagesToInputItems — phase inheritance", () => {
{ type: "text", text: "Untagged block B" },
],
};
const items = convertMessagesToInputItems([msg] as Parameters<
const items = convertMessagesToInputItems([msg] as unknown as Parameters<
typeof convertMessagesToInputItems
>[0]);
const assistantItems = items.filter((i: Record<string, unknown>) => i.role === "assistant");
// Should produce 3 separate assistant items because phase changes:
// A=commentary, Explicit=final_answer, B=commentary
expect(assistantItems).toHaveLength(3);
expect((assistantItems[0] as Record<string, unknown>).phase).toBe("commentary");
expect((assistantItems[1] as Record<string, unknown>).phase).toBe("final_answer");
expect((assistantItems[2] as Record<string, unknown>).phase).toBe("commentary");
expect(assistantItems).toHaveLength(4);
expect(assistantItems[0]).toMatchObject({
role: "assistant",
content: "Untagged block A",
});
expect((assistantItems[0] as Record<string, unknown>).phase).toBeUndefined();
expect(assistantItems[1]).toMatchObject({
role: "assistant",
content: "Replay block",
phase: "commentary",
});
expect(assistantItems[2]).toMatchObject({
role: "assistant",
content: "Explicitly final",
phase: "final_answer",
});
expect(assistantItems[3]).toMatchObject({
role: "assistant",
content: "Untagged block B",
});
expect((assistantItems[3] as Record<string, unknown>).phase).toBeUndefined();
});
});