From a5cc25b9ebd6d0618bd941d53f80d65519a3b505 Mon Sep 17 00:00:00 2001 From: Davit Date: Fri, 15 May 2026 14:41:15 +0400 Subject: [PATCH] fixed message duplication in protocol 4 --- api/src/routes/message/controller.ts | 94 ++++++++++++++---------- api/src/services/openclaw/chat.ts | 34 +++++++-- api/src/services/openclaw/jsonlParser.ts | 31 +++++++- package.json | 2 +- 4 files changed, 111 insertions(+), 50 deletions(-) diff --git a/api/src/routes/message/controller.ts b/api/src/routes/message/controller.ts index 602a1ea..93ebe2f 100644 --- a/api/src/routes/message/controller.ts +++ b/api/src/routes/message/controller.ts @@ -428,47 +428,63 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?: synced += toInsert.length; } - /* Refresh tool steps on already-linked assistant rows. A long-running - * tool finishes AFTER its parent assistant turn was first synced, so - * the toolResult lands in JSONL on a later poll. Without this pass, - * the row keeps `output: null` forever and the UI shows "(no result - * captured)". We compare canonical JSON to skip no-op writes. */ - const liveAssistantSteps = jsonlMessages.filter( - (m) => m.role === 'assistant' && m.toolSteps && m.toolSteps.length > 0 + /* Refresh already-linked assistant rows against the current JSONL. + * + * - toolSteps: a long-running tool finishes AFTER its parent assistant + * turn was first synced; the toolResult lands in JSONL on a later + * poll, so without this pass `output` stays null forever. + * - text / thinking: OpenClaw 2026.5.12 (gateway v4) writes each + * assistant turn twice in JSONL; older rows stored the concatenated + * doubled text. Now that `parseMessagesFromJsonl` dedupes, we + * overwrite the stale doubled value so the UI heals on next poll. + * + * We compare canonical JSON to skip no-op writes. */ + const liveAssistants = jsonlMessages.filter( + (m) => m.role === 'assistant' && m.externalId ); - if (liveAssistantSteps.length) { - const liveIds = liveAssistantSteps.map((m) => m.externalId!).filter(Boolean); - if (liveIds.length) { - const existing = await msgRepo.find({ - where: { - conversationId: convId, - role: 'assistant', - externalId: In(liveIds), - }, - select: ['_id', 'externalId', 'toolSteps'], - }); - const dbByExt = new Map(existing.map((m) => [m.externalId!, m])); - const refreshes: Array<{ id: number; toolSteps: typeof liveAssistantSteps[number]['toolSteps'] }> = []; - liveAssistantSteps.forEach((m) => { - const row = dbByExt.get(m.externalId!); - if (!row) return; - const liveJson = JSON.stringify(m.toolSteps ?? null); - const dbJson = JSON.stringify(row.toolSteps ?? null); - if (liveJson !== dbJson) { - refreshes.push({ id: row._id, toolSteps: m.toolSteps }); - } - }); - if (refreshes.length) { - await Promise.all( - refreshes.map((r) => { - const patch = { toolSteps: r.toolSteps } as unknown as Parameters< - typeof msgRepo.update - >[1]; - return msgRepo.update(r.id, patch); - }) - ); - synced += refreshes.length; + if (liveAssistants.length) { + const liveIds = liveAssistants.map((m) => m.externalId!); + const existing = await msgRepo.find({ + where: { + conversationId: convId, + role: 'assistant', + externalId: In(liveIds), + }, + select: ['_id', 'externalId', 'text', 'thinking', 'toolSteps'], + }); + const dbByExt = new Map(existing.map((m) => [m.externalId!, m])); + type RefreshPatch = { + id: number; + patch: Partial<{ + text: string; + thinking: string | null; + toolSteps: (typeof liveAssistants)[number]['toolSteps']; + }>; + }; + const refreshes: RefreshPatch[] = []; + liveAssistants.forEach((m) => { + const row = dbByExt.get(m.externalId!); + if (!row) return; + const patch: RefreshPatch['patch'] = {}; + if (m.text && m.text !== row.text) patch.text = m.text; + if ((m.thinking ?? null) !== (row.thinking ?? null)) { + patch.thinking = m.thinking ?? null; } + const liveStepsJson = JSON.stringify(m.toolSteps ?? null); + const dbStepsJson = JSON.stringify(row.toolSteps ?? null); + if (liveStepsJson !== dbStepsJson) patch.toolSteps = m.toolSteps; + if (Object.keys(patch).length > 0) { + refreshes.push({ id: row._id, patch }); + } + }); + if (refreshes.length) { + await Promise.all( + refreshes.map((r) => { + const patch = r.patch as unknown as Parameters[1]; + return msgRepo.update(r.id, patch); + }) + ); + synced += refreshes.length; } } } diff --git a/api/src/services/openclaw/chat.ts b/api/src/services/openclaw/chat.ts index 26279cd..c21bbf1 100644 --- a/api/src/services/openclaw/chat.ts +++ b/api/src/services/openclaw/chat.ts @@ -179,13 +179,33 @@ function runAgentViaGateway( const sseType = stream === 'assistant' ? 'response.output_text.delta' : 'response.thinking.delta'; - if (alreadySent.length === 0 || clean.startsWith(alreadySent)) { - if (clean.length > alreadySent.length) { - const newContent = clean.substring(alreadySent.length); - if (stream === 'assistant') assistantSent = clean; - else reasoningSent = clean; - emitter.send(sseType, newContent); - } + /* OpenClaw 2026.5.12 (#80725) bumped the gateway to v4 and now emits the + * assistant turn *twice* on the `agent` channel: once with the raw + * `` wrapping during streaming, then again post-processed + * with the wrapper stripped. Our `stripGatewayTags` reduces both passes + * to the same `clean` text, so the second pass arrives as `clean = "T", + * "Te", "Tes", …` while `alreadySent` already holds the full first pass. + * The old logic treated this as a "rewrite" and re-emitted the full + * content, causing the assistant bubble to render the same reply twice + * concatenated (UI shows `…Still 249 and 153!Test 4 received!…`). + * + * Three cases now: + * 1. `clean` strictly extends `alreadySent` → emit the new tail. + * 2. `clean` is a prefix of `alreadySent` (daemon restarted the same + * content) → drop; the client already shows at least this much. + * 3. Otherwise (genuine rewrite of different content) → fall back to + * emitting the whole `clean`. The SSE consumer appends, which is + * imperfect for true rewrites but matches pre-v4 behaviour and + * doesn't trigger on the duplicate-turn pattern. + */ + if (clean.length > alreadySent.length && clean.startsWith(alreadySent)) { + const newContent = clean.substring(alreadySent.length); + if (stream === 'assistant') assistantSent = clean; + else reasoningSent = clean; + emitter.send(sseType, newContent); + } else if (alreadySent.startsWith(clean)) { + /* Daemon-side restart of identical content (v4 post-process pass). + * Skip — nothing new to surface to the client. */ } else { if (stream === 'assistant') assistantSent = clean; else reasoningSent = clean; diff --git a/api/src/services/openclaw/jsonlParser.ts b/api/src/services/openclaw/jsonlParser.ts index 8376cb0..58d374c 100644 --- a/api/src/services/openclaw/jsonlParser.ts +++ b/api/src/services/openclaw/jsonlParser.ts @@ -247,10 +247,35 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] { return raw.reduce((messages, msg) => { const prev = messages[messages.length - 1]; if (msg.role === 'assistant' && prev?.role === 'assistant') { - prev.text += msg.text; - if (msg.thinking) prev.thinking = (prev.thinking || '') + msg.thinking; + /* OpenClaw 2026.5.12 (#80725, gateway v4) writes each assistant + * turn TWICE to the JSONL: one entry with the raw `` + * wrapper plus toolCall parts, then a second post-processed entry + * with the wrapper stripped and no toolCalls. `extractAssistantText` + * normalises both to the same string, so the old "always append" + * rule doubled every v4 reply (`Test 4 received!…post!Test 4 + * received!…post!`). The four cases below cover the new shape + * without regressing legitimate split turns (older daemons would + * stream multiple disjoint text chunks). */ + if (msg.text && prev.text === msg.text) { + // identical text — second pass of same turn, no-op on text + } else if (msg.text && prev.text && prev.text.includes(msg.text)) { + // prev already covers the new text — drop it + } else if (msg.text && prev.text && msg.text.includes(prev.text)) { + // new entry is a superset (e.g. post-processed full reply) — replace + prev.text = msg.text; + } else if (msg.text) { + prev.text += msg.text; + } + if (msg.thinking) { + if (!prev.thinking) prev.thinking = msg.thinking; + else if (!prev.thinking.includes(msg.thinking)) prev.thinking += msg.thinking; + } if (msg.toolSteps && msg.toolSteps.length > 0) { - prev.toolSteps = [...(prev.toolSteps ?? []), ...msg.toolSteps]; + const seen = new Set((prev.toolSteps ?? []).map((s) => s.id).filter(Boolean)); + const fresh = msg.toolSteps.filter((s) => !s.id || !seen.has(s.id)); + if (fresh.length > 0) { + prev.toolSteps = [...(prev.toolSteps ?? []), ...fresh]; + } } prev.externalId = msg.externalId; prev.timestamp = msg.timestamp || prev.timestamp; diff --git a/package.json b/package.json index 15b54e2..9ee7030 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-client", - "version": "2.5.4", + "version": "2.5.5", "description": "Web-based chat interface for OpenClaw AI agents", "private": true, "type": "module",