From 3bc7d4061b4eeb240b1b9ff98866ea7456036daf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 13:55:24 +0800 Subject: [PATCH] fix(gateway): preserve partial transcript branches --- CHANGELOG.md | 1 + src/gateway/session-transcript-index.fs.ts | 10 ++-- src/gateway/session-utils.fs.test.ts | 66 ++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3795cde596a..e626b4861c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Config persistence: strip malformed pending final-delivery session fields on load so replay/recovery paths skip poisoned reply metadata instead of crashing on raw objects. - Config persistence: strip malformed plugin extension state and promoted session-slot ownership on load so corrupted session rows do not leak poisoned plugin metadata into replay/projection paths. - Gateway/sessions: ignore malformed compaction checkpoint rows during session projection so corrupted stores do not crash session list/describe responses or show bogus checkpoint counts. +- Gateway/sessions: keep reachable transcript history when imported tree transcripts reference missing or legacy parent rows, preventing session history reads from going empty after a partial import. - Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling. - Providers/images: reject malformed successful OpenAI-compatible, OpenAI, Google, fal, and OpenRouter image responses with provider-owned errors instead of raw shape failures, silent invalid base64 skips, or empty image results. - Providers/videos: reject malformed successful xAI, OpenRouter, and fal video create, poll, and result responses with provider-owned errors instead of raw parser failures or long bogus polling. diff --git a/src/gateway/session-transcript-index.fs.ts b/src/gateway/session-transcript-index.fs.ts index 4578bea84f7..7ee278138af 100644 --- a/src/gateway/session-transcript-index.fs.ts +++ b/src/gateway/session-transcript-index.fs.ts @@ -146,7 +146,7 @@ function buildActiveTreeEntries(params: { seen.add(currentId); const entry = params.byId.get(currentId); if (!entry) { - return []; + break; } out.push(entry); currentId = entry.parentId ?? undefined; @@ -206,10 +206,12 @@ async function buildSessionTranscriptIndex( record: parsed, }; rawEntries.push(rawEntry); - if (isTreeTranscriptRecord(parsed) && id) { - hasTreeEntries = true; - leafId = id; + if (id) { byId.set(id, rawEntry); + if (isTreeTranscriptRecord(parsed)) { + hasTreeEntries = true; + leafId = id; + } } }); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 9aa15c3b6d5..98e8de4bfa5 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -886,6 +886,72 @@ describe("readSessionMessages", () => { } }); + test("keeps async active branch rows when imported parent links are incomplete", async () => { + const sessionId = "test-session-tree-async-incomplete-parent"; + writeTranscript(tmpDir, sessionId, [ + { type: "session", version: 3, id: sessionId }, + { + type: "message", + id: "legacy-user", + message: { role: "user", content: "legacy prompt" }, + }, + { + type: "message", + id: "tree-assistant", + parentId: "legacy-user", + message: { role: "assistant", content: "tree reply" }, + }, + { + type: "message", + id: "orphan-tail", + parentId: "missing-imported-parent", + message: { role: "assistant", content: "reachable orphan tail" }, + }, + ]); + clearSessionTranscriptIndexCache(); + + const messages = await readSessionMessagesAsync(sessionId, storePath, undefined, { + mode: "full", + reason: "test imported partial tree selection", + }); + + expect(messages.map((message) => (message as { content?: unknown }).content)).toEqual([ + "reachable orphan tail", + ]); + expectMessageFields(messages[0], { openclaw: { id: "orphan-tail", seq: 1 } }); + }); + + test("keeps legacy async parents when tree transcripts reference pre-v3 rows", async () => { + const sessionId = "test-session-tree-async-legacy-parent"; + writeTranscript(tmpDir, sessionId, [ + { type: "session", version: 1, id: sessionId }, + { + type: "message", + id: "legacy-user", + message: { role: "user", content: "legacy hello" }, + }, + { + type: "message", + id: "tree-assistant", + parentId: "legacy-user", + message: { role: "assistant", content: "tree hello" }, + }, + ]); + clearSessionTranscriptIndexCache(); + + const messages = await readSessionMessagesAsync(sessionId, storePath, undefined, { + mode: "full", + reason: "test legacy parent active tree selection", + }); + + expect(messages.map((message) => (message as { content?: unknown }).content)).toEqual([ + "legacy hello", + "tree hello", + ]); + expectMessageFields(messages[0], { openclaw: { id: "legacy-user", seq: 1 } }); + expectMessageFields(messages[1], { openclaw: { id: "tree-assistant", seq: 2 } }); + }); + test("caches async transcript indexes by file stats", async () => { const sessionId = "test-session-index-cache"; writeTranscript(tmpDir, sessionId, [