Skip to content

Commit 5f2273e

Browse files
committed
fix(gateway): unify chat display projection
1 parent dc9ce2a commit 5f2273e

18 files changed

Lines changed: 987 additions & 743 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ Docs: https://docs.openclaw.ai
124124
- Channels/ACP bindings: time out configured binding readiness checks instead of letting Discord preflight hang forever when an ACP target never settles. Fixes #68776.
125125
- Control UI: hide the chat loading skeleton during background history reloads when existing messages or active stream content are already visible, avoiding reload flashes on high-latency local gateways. Fixes #71844. Thanks @WolvenRA.
126126
- Control UI: keep locally optimistic chat messages visible when a history reload temporarily returns empty, avoiding lost first-turn messages on high-latency gateways. Fixes #71878. Thanks @WolvenRA.
127+
- Control UI: keep chat history limits based on visible messages after filtering heartbeat and control-only transcript rows, so recent hidden entries no longer make older visible replies disappear. Thanks @WolvenRA.
127128
- Agents/images: scrub old `[media attached: ...]`, `[Image: source: ...]`,
128129
and `media://inbound/...` markers from pruned model replay context so stale
129130
media refs are not rehydrated as fresh prompt images. Fixes #71868. Thanks

src/agents/pi-embedded-subscribe.handlers.messages.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage | undefi
5050
return provider === "openclaw" && (model === "delivery-mirror" || model === "gateway-injected");
5151
}
5252

53+
function isOpenAiResponsesAssistantMessage(message: AgentMessage | undefined): boolean {
54+
if (!message || message.role !== "assistant") {
55+
return false;
56+
}
57+
const api = normalizeOptionalString((message as { api?: unknown }).api) ?? "";
58+
return api === "openai-responses" || api === "azure-openai-responses";
59+
}
60+
5361
function resolveAssistantStreamItemId(params: {
5462
contentIndex?: unknown;
5563
message: AgentMessage | undefined;
@@ -481,7 +489,12 @@ export function handleMessageUpdate(
481489
contentIndex: assistantRecord?.contentIndex,
482490
message: partialAssistant,
483491
});
484-
if (deliveryPhase && streamItemId) {
492+
const isPhasePendingOpenAiResponsesTextItem =
493+
evtType !== "text_end" &&
494+
!deliveryPhase &&
495+
Boolean(streamItemId) &&
496+
isOpenAiResponsesAssistantMessage(partialAssistant);
497+
if ((deliveryPhase || isPhasePendingOpenAiResponsesTextItem) && streamItemId) {
485498
const previousStreamItemId = ctx.state.lastAssistantStreamItemId;
486499
if (previousStreamItemId && previousStreamItemId !== streamItemId) {
487500
void ctx.flushBlockReplyBuffer({ assistantMessageIndex: ctx.state.assistantMessageIndex });
@@ -493,6 +506,9 @@ export function handleMessageUpdate(
493506
if (deliveryPhase === "commentary") {
494507
return;
495508
}
509+
if (isPhasePendingOpenAiResponsesTextItem) {
510+
return;
511+
}
496512
const phaseAwareVisibleText = coerceChatContentText(
497513
extractAssistantVisibleText(partialAssistant),
498514
).trim();
@@ -584,7 +600,7 @@ export function handleMessageUpdate(
584600
delta: deltaText,
585601
replace,
586602
mediaUrls,
587-
phase: assistantPhase,
603+
phase: deliveryPhase ?? assistantPhase,
588604
});
589605
emitAgentEvent({
590606
runId: ctx.params.runId,

src/agents/tools/embedded-gateway-stub.runtime.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
export { resolveSessionAgentId } from "../../agents/agent-scope.js";
22
export { loadConfig } from "../../config/config.js";
3-
export { stripEnvelopeFromMessages } from "../../gateway/chat-sanitize.js";
3+
export {
4+
projectRecentChatDisplayMessages,
5+
resolveEffectiveChatHistoryMaxChars,
6+
} from "../../gateway/chat-display-projection.js";
47
export { augmentChatHistoryWithCliSessionImports } from "../../gateway/cli-session-history.js";
58
export { getMaxChatHistoryMessagesBytes } from "../../gateway/server-constants.js";
69
export {
710
augmentChatHistoryWithCanvasBlocks,
811
CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES,
912
enforceChatHistoryFinalBudget,
1013
replaceOversizedChatHistoryMessages,
11-
resolveEffectiveChatHistoryMaxChars,
12-
sanitizeChatHistoryMessages,
1314
} from "../../gateway/server-methods/chat.js";
1415
export { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js";
1516
export {

src/agents/tools/embedded-gateway-stub.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ import { createEmbeddedCallGateway } from "./embedded-gateway-stub.js";
44
const runtime = vi.hoisted(() => ({
55
loadConfig: vi.fn(() => ({ agents: { list: [{ id: "main", default: true }] } })),
66
resolveSessionKeyFromResolveParams: vi.fn(),
7+
resolveSessionAgentId: vi.fn(() => "main"),
8+
loadSessionEntry: vi.fn(() => ({
9+
cfg: {},
10+
storePath: "/tmp/openclaw-sessions.json",
11+
entry: { sessionId: "sess-main" },
12+
})),
13+
resolveSessionModelRef: vi.fn(() => ({ provider: "openai" })),
14+
readSessionMessages: vi.fn((): unknown[] => []),
15+
augmentChatHistoryWithCliSessionImports: vi.fn(
16+
({ localMessages }: { localMessages?: unknown[] }) => localMessages ?? [],
17+
),
18+
resolveEffectiveChatHistoryMaxChars: vi.fn(() => 100_000),
19+
projectRecentChatDisplayMessages: vi.fn((messages: unknown[]): unknown[] => messages),
20+
augmentChatHistoryWithCanvasBlocks: vi.fn((messages: unknown[]) => messages),
21+
getMaxChatHistoryMessagesBytes: vi.fn(() => 100_000),
22+
CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES: 100_000,
23+
replaceOversizedChatHistoryMessages: vi.fn(({ messages }: { messages: unknown[] }) => ({
24+
messages,
25+
})),
26+
capArrayByJsonBytes: vi.fn((items: unknown[]) => ({ items })),
27+
enforceChatHistoryFinalBudget: vi.fn(({ messages }: { messages: unknown[] }) => ({ messages })),
728
}));
829

930
vi.mock("./embedded-gateway-stub.runtime.js", () => runtime);
@@ -12,6 +33,8 @@ describe("embedded gateway stub", () => {
1233
beforeEach(() => {
1334
runtime.loadConfig.mockClear();
1435
runtime.resolveSessionKeyFromResolveParams.mockReset();
36+
runtime.projectRecentChatDisplayMessages.mockClear();
37+
runtime.readSessionMessages.mockClear();
1538
});
1639

1740
it("resolves sessions through the gateway session resolver", async () => {
@@ -48,4 +71,45 @@ describe("embedded gateway stub", () => {
4871
}),
4972
).rejects.toThrow("No session found: missing");
5073
});
74+
75+
it("projects embedded chat history through the shared display projector", async () => {
76+
const rawMessages = [
77+
{ role: "user", content: "hello" },
78+
{ role: "assistant", content: "hi" },
79+
];
80+
const projectedMessages = [{ role: "assistant", content: "hi" }];
81+
runtime.readSessionMessages.mockReturnValueOnce(rawMessages);
82+
runtime.projectRecentChatDisplayMessages.mockReturnValueOnce(projectedMessages);
83+
84+
const callGateway = createEmbeddedCallGateway();
85+
const result = await callGateway<{ messages: unknown[] }>({
86+
method: "chat.history",
87+
params: { sessionKey: "agent:main:main" },
88+
});
89+
90+
expect(runtime.projectRecentChatDisplayMessages).toHaveBeenCalledWith(rawMessages, {
91+
maxChars: 100_000,
92+
maxMessages: 200,
93+
});
94+
expect(result.messages).toEqual(projectedMessages);
95+
});
96+
97+
it("passes the full raw history to projection before limiting visible messages", async () => {
98+
const rawMessages = [
99+
{ role: "user", content: "visible older" },
100+
{ role: "assistant", content: "hidden newer" },
101+
];
102+
runtime.readSessionMessages.mockReturnValueOnce(rawMessages);
103+
104+
const callGateway = createEmbeddedCallGateway();
105+
await callGateway<{ messages: unknown[] }>({
106+
method: "chat.history",
107+
params: { sessionKey: "agent:main:main", limit: 1 },
108+
});
109+
110+
expect(runtime.projectRecentChatDisplayMessages).toHaveBeenCalledWith(rawMessages, {
111+
maxChars: 100_000,
112+
maxMessages: 1,
113+
});
114+
});
51115
});

src/agents/tools/embedded-gateway-stub.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ type EmbeddedCallGateway = <T = Record<string, unknown>>(opts: CallGatewayOption
99
interface EmbeddedGatewayRuntime {
1010
resolveSessionAgentId: (opts: { sessionKey: string; config: OpenClawConfig }) => string;
1111
loadConfig: () => OpenClawConfig;
12-
stripEnvelopeFromMessages: (msgs: unknown[]) => unknown[];
1312
augmentChatHistoryWithCliSessionImports: (opts: {
1413
entry: unknown;
1514
provider: string | undefined;
@@ -26,7 +25,10 @@ interface EmbeddedGatewayRuntime {
2625
maxSingleMessageBytes: number;
2726
}) => { messages: unknown[] };
2827
resolveEffectiveChatHistoryMaxChars: (cfg: OpenClawConfig) => number;
29-
sanitizeChatHistoryMessages: (msgs: unknown[], maxChars: number) => unknown[];
28+
projectRecentChatDisplayMessages: (
29+
msgs: unknown[],
30+
opts?: { maxChars?: number; maxMessages?: number },
31+
) => unknown[];
3032
capArrayByJsonBytes: (items: unknown[], maxBytes: number) => { items: unknown[] };
3133
listSessionsFromStore: (opts: {
3234
cfg: OpenClawConfig;
@@ -124,10 +126,11 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
124126
const max = Math.min(hardMax, requested);
125127
const effectiveMaxChars = rt.resolveEffectiveChatHistoryMaxChars(cfg);
126128

127-
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
128-
const sanitized = rt.stripEnvelopeFromMessages(sliced);
129129
const normalized = rt.augmentChatHistoryWithCanvasBlocks(
130-
rt.sanitizeChatHistoryMessages(sanitized, effectiveMaxChars),
130+
rt.projectRecentChatDisplayMessages(rawMessages, {
131+
maxChars: effectiveMaxChars,
132+
maxMessages: max,
133+
}),
131134
);
132135

133136
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();

0 commit comments

Comments
 (0)