mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 08:52:46 +00:00
fixed message duplication in protocol 4
This commit is contained in:
@@ -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<typeof msgRepo.update>[1];
|
||||
return msgRepo.update(r.id, patch);
|
||||
})
|
||||
);
|
||||
synced += refreshes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* `<final>…</final>` 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;
|
||||
|
||||
@@ -247,10 +247,35 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
|
||||
return raw.reduce<OpenClawMessage[]>((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 `<final>…</final>`
|
||||
* 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;
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user