diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f3ca8bb..252ce7e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy. - OpenAI Codex OAuth/login hardening: fail OAuth completion early when the returned token is missing `api.responses.write`, and allow `openclaw models auth login --provider openai-codex` to use the built-in OAuth path even when no provider plugins are installed. (#36660) Thanks @driesvints. - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 726e061be..fd3948349 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -310,6 +310,98 @@ describe("agent event handler", () => { nowSpy.mockRestore(); }); + it("preserves pre-tool assistant text when later segments stream as non-prefix snapshots", () => { + let now = 10_500; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness(); + chatRunState.registry.add("run-segmented", { + sessionKey: "session-segmented", + clientRunId: "client-segmented", + }); + + handler({ + runId: "run-segmented", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "Before tool call", delta: "Before tool call" }, + }); + + now = 10_700; + handler({ + runId: "run-segmented", + seq: 2, + stream: "assistant", + ts: Date.now(), + data: { text: "After tool call", delta: "\nAfter tool call" }, + }); + + emitLifecycleEnd(handler, "run-segmented", 3); + + const chatCalls = chatBroadcastCalls(broadcast); + expect(chatCalls).toHaveLength(3); + const secondPayload = chatCalls[1]?.[1] as { + state?: string; + message?: { content?: Array<{ text?: string }> }; + }; + const finalPayload = chatCalls[2]?.[1] as { + state?: string; + message?: { content?: Array<{ text?: string }> }; + }; + expect(secondPayload.state).toBe("delta"); + expect(secondPayload.message?.content?.[0]?.text).toBe("Before tool call\nAfter tool call"); + expect(finalPayload.state).toBe("final"); + expect(finalPayload.message?.content?.[0]?.text).toBe("Before tool call\nAfter tool call"); + expect(sessionChatCalls(nodeSendToSession)).toHaveLength(3); + nowSpy.mockRestore(); + }); + + it("flushes merged segmented text before final when latest segment is throttled", () => { + let now = 10_800; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness(); + chatRunState.registry.add("run-segmented-flush", { + sessionKey: "session-segmented-flush", + clientRunId: "client-segmented-flush", + }); + + handler({ + runId: "run-segmented-flush", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "Before tool call", delta: "Before tool call" }, + }); + + now = 10_860; + handler({ + runId: "run-segmented-flush", + seq: 2, + stream: "assistant", + ts: Date.now(), + data: { text: "After tool call", delta: "\nAfter tool call" }, + }); + + emitLifecycleEnd(handler, "run-segmented-flush", 3); + + const chatCalls = chatBroadcastCalls(broadcast); + expect(chatCalls).toHaveLength(3); + const flushPayload = chatCalls[1]?.[1] as { + state?: string; + message?: { content?: Array<{ text?: string }> }; + }; + const finalPayload = chatCalls[2]?.[1] as { + state?: string; + message?: { content?: Array<{ text?: string }> }; + }; + expect(flushPayload.state).toBe("delta"); + expect(flushPayload.message?.content?.[0]?.text).toBe("Before tool call\nAfter tool call"); + expect(finalPayload.state).toBe("final"); + expect(finalPayload.message?.content?.[0]?.text).toBe("Before tool call\nAfter tool call"); + expect(sessionChatCalls(nodeSendToSession)).toHaveLength(3); + nowSpy.mockRestore(); + }); + it("does not flush an extra delta when the latest text already broadcast", () => { let now = 11_000; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 67da7cd8d..73f90e8b0 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -89,6 +89,48 @@ function isSilentReplyLeadFragment(text: string): boolean { return SILENT_REPLY_TOKEN.startsWith(normalized); } +function appendUniqueSuffix(base: string, suffix: string): string { + if (!suffix) { + return base; + } + if (!base) { + return suffix; + } + if (base.endsWith(suffix)) { + return base; + } + const maxOverlap = Math.min(base.length, suffix.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + if (base.slice(-overlap) === suffix.slice(0, overlap)) { + return base + suffix.slice(overlap); + } + } + return base + suffix; +} + +function resolveMergedAssistantText(params: { + previousText: string; + nextText: string; + nextDelta: string; +}) { + const { previousText, nextText, nextDelta } = params; + if (nextText && previousText) { + if (nextText.startsWith(previousText)) { + return nextText; + } + if (previousText.startsWith(nextText) && !nextDelta) { + return previousText; + } + } + if (nextDelta) { + return appendUniqueSuffix(previousText, nextDelta); + } + if (nextText) { + return nextText; + } + return previousText; +} + export type ChatRunEntry = { sessionKey: string; clientRunId: string; @@ -302,16 +344,25 @@ export function createAgentEventHandler({ sourceRunId: string, seq: number, text: string, + delta?: unknown, ) => { - const cleaned = stripInlineDirectiveTagsForDisplay(text).text; - if (!cleaned) { + const cleanedText = stripInlineDirectiveTagsForDisplay(text).text; + const cleanedDelta = + typeof delta === "string" ? stripInlineDirectiveTagsForDisplay(delta).text : ""; + const previousText = chatRunState.buffers.get(clientRunId) ?? ""; + const mergedText = resolveMergedAssistantText({ + previousText, + nextText: cleanedText, + nextDelta: cleanedDelta, + }); + if (!mergedText) { return; } - chatRunState.buffers.set(clientRunId, cleaned); - if (isSilentReplyText(cleaned, SILENT_REPLY_TOKEN)) { + chatRunState.buffers.set(clientRunId, mergedText); + if (isSilentReplyText(mergedText, SILENT_REPLY_TOKEN)) { return; } - if (isSilentReplyLeadFragment(cleaned)) { + if (isSilentReplyLeadFragment(mergedText)) { return; } if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) { @@ -323,7 +374,7 @@ export function createAgentEventHandler({ return; } chatRunState.deltaSentAt.set(clientRunId, now); - chatRunState.deltaLastBroadcastLen.set(clientRunId, cleaned.length); + chatRunState.deltaLastBroadcastLen.set(clientRunId, mergedText.length); const payload = { runId: clientRunId, sessionKey, @@ -331,7 +382,7 @@ export function createAgentEventHandler({ state: "delta" as const, message: { role: "assistant", - content: [{ type: "text", text: cleaned }], + content: [{ type: "text", text: mergedText }], timestamp: now, }, }; @@ -512,7 +563,7 @@ export function createAgentEventHandler({ nodeSendToSession(sessionKey, "agent", isToolEvent ? toolPayload : agentPayload); } if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { - emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text); + emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text, evt.data.delta); } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { const evtStopReason = typeof evt.data?.stopReason === "string" ? evt.data.stopReason : undefined;