mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:00:47 +00:00
fix(auto-reply): replay recent DM turns on silent session rotation
Silent in-reply session rotations (compaction failure, role-ordering conflict) mint a fresh sessionId and an empty transcript, so direct-chat continuity is lost between the previous turn and the rebound one. Carry the tail of user/assistant JSONL records from the prior transcript into the freshly-rotated file so the next model turn still sees recent context. Tool, system, and compaction records are deliberately excluded to avoid reshaping tool/role ordering. Fixes #70853. Made-with: Cursor
This commit is contained in:
committed by
Josh Lehman
parent
7120f5b254
commit
5134344327
@@ -311,6 +311,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
|
||||
- TTS/BlueBubbles: pre-transcode synthesized MP3 audio to opus-in-CAF (mono, 24 kHz — validated against macOS 15.x Messages.app's native voice-memo CAF descriptor) on macOS hosts before handing the file to BlueBubbles, so iMessage renders the result as a native voice-memo bubble with proper duration and waveform UI instead of a plain file attachment. Adds an opt-in `tts.voice.preferAudioFileFormat` channel capability and a magic-byte sniff for the CAF container so the host-local-media validator (which uses `file-type` and didn't recognize CAF natively) can verify the pre-transcoded buffer. Channels that don't opt in are unaffected. (#72586) Fixes #72506. Thanks @omarshahine.
|
||||
- Feishu: retry WebSocket startup failures with monitor-owned backoff while preserving SDK-local heartbeat defaults, so persistent-connection startup failures no longer leave the monitor hung. Fixes #68766; related #42354 and #55532. Thanks @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
|
||||
- Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana.
|
||||
|
||||
## 2026.4.26
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { generateSecureUuid } from "../../infra/secure-random.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { refreshQueuedFollowupSession, type FollowupRun } from "./queue.js";
|
||||
import { replayRecentUserAssistantMessages } from "./session-transcript-replay.js";
|
||||
|
||||
type ResetSessionOptions = {
|
||||
failureLabel: string;
|
||||
@@ -96,6 +97,13 @@ export async function resetReplyRunSession(params: {
|
||||
`Failed to persist session reset after ${params.options.failureLabel} (${params.sessionKey}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
// Silent rotations (compaction/role-ordering) fire without user intent, so
|
||||
// preserve recent user/assistant turns for direct-chat continuity.
|
||||
replayRecentUserAssistantMessages({
|
||||
sourceTranscript: prevEntry.sessionFile,
|
||||
targetTranscript: nextSessionFile,
|
||||
newSessionId: nextSessionId,
|
||||
});
|
||||
params.followupRun.run.sessionId = nextSessionId;
|
||||
params.followupRun.run.sessionFile = nextSessionFile;
|
||||
deps.refreshQueuedFollowupSession({
|
||||
|
||||
51
src/auto-reply/reply/session-transcript-replay.test.ts
Normal file
51
src/auto-reply/reply/session-transcript-replay.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_REPLAY_MAX_MESSAGES,
|
||||
replayRecentUserAssistantMessages,
|
||||
} from "./session-transcript-replay.js";
|
||||
|
||||
const j = (obj: unknown): string => `${JSON.stringify(obj)}\n`;
|
||||
|
||||
describe("replayRecentUserAssistantMessages", () => {
|
||||
let root = "";
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-replay-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const call = (source: string, target: string): number =>
|
||||
replayRecentUserAssistantMessages({
|
||||
sourceTranscript: source,
|
||||
targetTranscript: target,
|
||||
newSessionId: "new-session",
|
||||
});
|
||||
|
||||
it("replays only the user/assistant tail and skips tool/system/malformed records", async () => {
|
||||
const source = path.join(root, "prev.jsonl");
|
||||
const target = path.join(root, "next.jsonl");
|
||||
const lines: string[] = [j({ type: "session", id: "old" })];
|
||||
for (let i = 0; i < DEFAULT_REPLAY_MAX_MESSAGES + 4; i += 1) {
|
||||
lines.push(j({ message: { role: i % 2 === 0 ? "user" : "assistant", content: `m${i}` } }));
|
||||
}
|
||||
lines.push(j({ message: { role: "tool" } }));
|
||||
lines.push(j({ type: "compaction", timestamp: new Date().toISOString() }));
|
||||
lines.push("not-json-line\n");
|
||||
await fs.writeFile(source, lines.join(""), "utf8");
|
||||
|
||||
expect(call(source, target)).toBe(DEFAULT_REPLAY_MAX_MESSAGES);
|
||||
const records = (await fs.readFile(target, "utf8"))
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(records[0]).toMatchObject({ type: "session", id: "new-session" });
|
||||
expect(records).toHaveLength(1 + DEFAULT_REPLAY_MAX_MESSAGES);
|
||||
for (const r of records.slice(1)) {
|
||||
expect(["user", "assistant"]).toContain(r.message.role);
|
||||
}
|
||||
expect(call(path.join(root, "missing.jsonl"), path.join(root, "out.jsonl"))).toBe(0);
|
||||
});
|
||||
});
|
||||
64
src/auto-reply/reply/session-transcript-replay.ts
Normal file
64
src/auto-reply/reply/session-transcript-replay.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
/** Tail kept so DM continuity survives silent session rotations. */
|
||||
export const DEFAULT_REPLAY_MAX_MESSAGES = 6;
|
||||
|
||||
type SessionRecord = { message?: { role?: unknown } };
|
||||
|
||||
/**
|
||||
* Copy the tail of user/assistant JSONL records from a prior transcript into a
|
||||
* freshly-rotated one. Tool, system, and compaction records are skipped so
|
||||
* replay cannot reshape tool/role ordering. Returns 0 on any error.
|
||||
*/
|
||||
export function replayRecentUserAssistantMessages(params: {
|
||||
sourceTranscript?: string;
|
||||
targetTranscript: string;
|
||||
newSessionId: string;
|
||||
maxMessages?: number;
|
||||
}): number {
|
||||
const max = Math.max(0, params.maxMessages ?? DEFAULT_REPLAY_MAX_MESSAGES);
|
||||
const src = params.sourceTranscript;
|
||||
if (max === 0 || !src || !fs.existsSync(src)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const kept: string[] = [];
|
||||
for (const line of fs.readFileSync(src, "utf-8").split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const role = (JSON.parse(line) as SessionRecord | null)?.message?.role;
|
||||
if (role === "user" || role === "assistant") {
|
||||
kept.push(line);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines.
|
||||
}
|
||||
}
|
||||
if (kept.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!fs.existsSync(params.targetTranscript)) {
|
||||
fs.mkdirSync(path.dirname(params.targetTranscript), { recursive: true });
|
||||
const header = JSON.stringify({
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: params.newSessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
fs.writeFileSync(params.targetTranscript, `${header}\n`, { encoding: "utf-8", mode: 0o600 });
|
||||
}
|
||||
const tail = kept.slice(-max);
|
||||
fs.appendFileSync(params.targetTranscript, `${tail.join("\n")}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
return tail.length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user