Skip to content

Commit 84a289b

Browse files
yaoyi1222clawsweeper[bot]
authored andcommitted
fix(transcript): skip trailing custom entries in tail assistant reader (#83427)
readTailAssistantTextFromSessionTranscript returned undefined on the first non-message line (e.g. an openclaw.cache-ttl custom entry written after the canonical assistant turn), so persistTextTurnTranscript's gap-fill path appended a duplicate assistant message that poisoned the model's context. Skip non-message entries and stop at the first real message (user or assistant) so the legitimate assistant tail is found.
1 parent 4af590a commit 84a289b

3 files changed

Lines changed: 121 additions & 0 deletions

File tree

src/agents/command/attempt-execution.cli.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,77 @@ describe("CLI attempt execution", () => {
685685
expect(validEntries.filter((entry) => entry.type === "message")).toHaveLength(1);
686686
});
687687

688+
it("embedded assistant gap-fill skips trailing openclaw.cache-ttl custom entries (regression for #83427)", async () => {
689+
const sessionKey = "agent:main:subagent:embedded-gap-fill-cache-ttl";
690+
const sessionEntry: SessionEntry = {
691+
sessionId: "session-embedded-gap-fill-cache-ttl",
692+
updatedAt: Date.now(),
693+
};
694+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
695+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
696+
697+
const result = makeCliResult("canonical answer");
698+
result.meta.executionTrace = {
699+
winnerProvider: "anthropic",
700+
winnerModel: "claude-haiku-4-5-20251001",
701+
fallbackUsed: false,
702+
runner: "embedded",
703+
};
704+
705+
const updatedFirst = await persistCliTurnTranscript({
706+
body: "ignored for gap fill",
707+
result,
708+
sessionId: sessionEntry.sessionId,
709+
sessionKey,
710+
sessionEntry,
711+
sessionStore,
712+
storePath,
713+
sessionAgentId: "main",
714+
sessionCwd: tmpDir,
715+
config: {},
716+
embeddedAssistantGapFill: true,
717+
});
718+
const sessionFile = updatedFirst?.sessionFile;
719+
if (typeof sessionFile !== "string") {
720+
throw new Error("Expected CLI transcript session file.");
721+
}
722+
723+
await fs.appendFile(
724+
sessionFile,
725+
`${JSON.stringify({
726+
type: "custom",
727+
customType: "openclaw.cache-ttl",
728+
timestamp: new Date().toISOString(),
729+
data: {
730+
provider: "anthropic",
731+
modelId: "claude-haiku-4-5-20251001",
732+
},
733+
})}\n`,
734+
"utf-8",
735+
);
736+
737+
await persistCliTurnTranscript({
738+
body: "still ignored",
739+
result,
740+
sessionId: sessionEntry.sessionId,
741+
sessionKey,
742+
sessionEntry: updatedFirst,
743+
sessionStore,
744+
storePath,
745+
sessionAgentId: "main",
746+
sessionCwd: tmpDir,
747+
config: {},
748+
embeddedAssistantGapFill: true,
749+
});
750+
751+
const messages = await readSessionMessages(sessionFile);
752+
expect(messages).toHaveLength(1);
753+
expectRecordFields(requireRecord(messages[0], "assistant message"), {
754+
role: "assistant",
755+
content: [{ type: "text", text: "canonical answer" }],
756+
});
757+
});
758+
688759
it("embedded assistant gap-fill appends repeated replies after a user tail", async () => {
689760
const sessionKey = "agent:main:subagent:embedded-repeated-reply";
690761
const sessionEntry: SessionEntry = {

src/config/sessions/transcript.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,46 @@ describe("appendAssistantMessageToSessionTranscript", () => {
381381
expect(tailAssistantText?.text).toBe("Tail delivery mirror");
382382
});
383383

384+
it("scans past trailing non-assistant entries (e.g. openclaw.cache-ttl) to find the latest assistant text", async () => {
385+
// Regression for openclaw/openclaw#83427: the cache-ttl custom entry was
386+
// emitted after the canonical assistant turn, and the tail reader returned
387+
// undefined on the first non-assistant line, so the gap-fill check in
388+
// persistTextTurnTranscript wrote a duplicate `api: "cli"` assistant
389+
// message — poisoning the model's own context with verbatim duplicates.
390+
writeTranscriptStore();
391+
392+
const assistantResult = await appendExactAssistantMessageToSessionTranscript({
393+
sessionKey,
394+
storePath: fixture.storePath(),
395+
message: createExactAssistantMessage({
396+
text: "Canonical answer",
397+
provider: "anthropic",
398+
model: "claude-haiku-4-5-20251001",
399+
}),
400+
});
401+
expect(assistantResult.ok).toBe(true);
402+
if (!assistantResult.ok) {
403+
return;
404+
}
405+
406+
const cacheTtlEntry = `${JSON.stringify({
407+
type: "custom",
408+
customType: "openclaw.cache-ttl",
409+
timestamp: new Date().toISOString(),
410+
data: {
411+
provider: "anthropic",
412+
modelId: "claude-haiku-4-5-20251001",
413+
},
414+
})}\n`;
415+
fs.appendFileSync(assistantResult.sessionFile, cacheTtlEntry, "utf-8");
416+
417+
const tailAssistantText = await readTailAssistantTextFromSessionTranscript(
418+
assistantResult.sessionFile,
419+
);
420+
expect(tailAssistantText?.id).toBe(assistantResult.messageId);
421+
expect(tailAssistantText?.text).toBe("Canonical answer");
422+
});
423+
384424
it("does not reuse an older matching assistant message across turns", async () => {
385425
writeTranscriptStore();
386426

src/config/sessions/transcript.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ export async function readTailAssistantTextFromSessionTranscript(
194194

195195
for await (const line of streamSessionTranscriptLinesReverse(sessionFile)) {
196196
try {
197+
const parsed = JSON.parse(line) as { message?: unknown };
198+
// Skip non-message entries (e.g. `openclaw.cache-ttl` custom events) so
199+
// a metadata line emitted after the canonical assistant turn doesn't
200+
// make the tail reader fall through to "no assistant tail" and cause
201+
// persistTextTurnTranscript to append a duplicate. Stop at any real
202+
// message entry — a user turn means a new turn has started and a
203+
// matching reply is a legitimate repeat, not a gap-fill duplicate.
204+
if (!parsed.message || typeof parsed.message !== "object") {
205+
continue;
206+
}
197207
return parseAssistantTranscriptText(line);
198208
} catch {
199209
continue;

0 commit comments

Comments
 (0)