diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf2e3358822..3a6ef1f9da4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter: stop adding empty DeepSeek V4 `reasoning_content` placeholders to assistant tool-call replay messages and strip empty replay artifacts before follow-up Chat Completions requests, so `openrouter/deepseek/deepseek-v4-pro` no longer fails after tool use. Fixes #82150. (#82158) Thanks @luyao618 and @Suquir0.
- Gateway/approvals: treat `turnSourceTo` as optional in `canBridgeNoDeviceChatApprovalFromBackend`, matching the existing optional handling of `turnSourceAccountId` and `turnSourceThreadId`. Channels without a recipient concept (webchat, control-ui) leave `turnSourceTo` null on both the approval snapshot and the replay params, so the prior required-string check rejected every backend replay with `APPROVAL_CLIENT_MISMATCH`. Cross-channel replay is still gated by the required `turnSourceChannel` and `sessionKey` checks. Fixes #82132. (#82136) Thanks @ottodeng.
- Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns.
+- Cron: preserve rotated transcript identity after session-bound scheduled runs compact, so `sessionTarget: "current"` keeps the next user message on the same conversation. Fixes #82164. Thanks @weissfl.
- Twitch: keep gateway accounts running until shutdown instead of treating successful monitor startup as a clean channel exit, preventing immediate auto-restart loops. Fixes #60071. (#81853) Thanks @edenfunf.
- Agents/auto-reply: honor `agents.defaults.silentReply` and per-surface group silent-reply policy when generic agent-run failure fallbacks decide whether to send visible fallback text. Fixes #82060. (#82086) Thanks @taozengabc.
- Discord: render channel topic context as structured untrusted metadata in reply prompts and stop duplicating inbound message bodies or exposing raw `EXTERNAL_UNTRUSTED_CONTENT` envelopes. Fixes #82168. Thanks @ronan-dandelion-cult.
diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts
index de33df550d5..abdafabb5e9 100644
--- a/src/agents/pi-embedded-runner/run.ts
+++ b/src/agents/pi-embedded-runner/run.ts
@@ -1169,6 +1169,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: activeSessionId,
+ sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -1465,6 +1466,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: activeSessionId,
+ sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -1967,6 +1969,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
+ sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -1997,6 +2000,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
+ sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -2068,6 +2072,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
+ sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -2108,6 +2113,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
+ sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
diff --git a/src/agents/pi-embedded-runner/run/helpers.test.ts b/src/agents/pi-embedded-runner/run/helpers.test.ts
index 19872908427..3ea2be102d8 100644
--- a/src/agents/pi-embedded-runner/run/helpers.test.ts
+++ b/src/agents/pi-embedded-runner/run/helpers.test.ts
@@ -1,6 +1,10 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
-import { resolveFinalAssistantRawText, resolveFinalAssistantVisibleText } from "./helpers.js";
+import {
+ buildErrorAgentMeta,
+ resolveFinalAssistantRawText,
+ resolveFinalAssistantVisibleText,
+} from "./helpers.js";
function makeAssistantMessage(
content: AssistantMessage["content"],
@@ -73,3 +77,21 @@ describe("resolveFinalAssistantVisibleText", () => {
expect(resolveFinalAssistantRawText(lastAssistant)).toBe("keep this");
});
});
+
+describe("buildErrorAgentMeta", () => {
+ it("preserves active session file for error exits after transcript rotation", () => {
+ expect(
+ buildErrorAgentMeta({
+ sessionId: "session-rotated",
+ sessionFile: "/tmp/session-rotated.jsonl",
+ provider: "anthropic",
+ model: "claude-opus-4-6",
+ usageAccumulator: {},
+ lastRunPromptUsage: undefined,
+ }),
+ ).toMatchObject({
+ sessionId: "session-rotated",
+ sessionFile: "/tmp/session-rotated.jsonl",
+ });
+ });
+});
diff --git a/src/agents/pi-embedded-runner/run/helpers.ts b/src/agents/pi-embedded-runner/run/helpers.ts
index 72dd04730fb..21f21d70221 100644
--- a/src/agents/pi-embedded-runner/run/helpers.ts
+++ b/src/agents/pi-embedded-runner/run/helpers.ts
@@ -161,6 +161,7 @@ export function buildUsageAgentMetaFields(params: {
*/
export function buildErrorAgentMeta(params: {
sessionId: string;
+ sessionFile?: string;
provider: string;
model: string;
contextTokens?: number;
@@ -177,6 +178,7 @@ export function buildErrorAgentMeta(params: {
});
return {
sessionId: params.sessionId,
+ ...(params.sessionFile ? { sessionFile: params.sessionFile } : {}),
provider: params.provider,
model: params.model,
...(params.contextTokens ? { contextTokens: params.contextTokens } : {}),
diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts
index b7d4d670b8d..508eacb2bf7 100644
--- a/src/cron/isolated-agent.session-identity.test.ts
+++ b/src/cron/isolated-agent.session-identity.test.ts
@@ -4,7 +4,12 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as modelThinkingDefault from "../agents/model-thinking-default.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
-import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
+import {
+ makeCfg,
+ makeJob,
+ writeSessionStore,
+ writeSessionStoreEntries,
+} from "./isolated-agent.test-harness.js";
import {
DEFAULT_AGENT_TURN_PAYLOAD,
DEFAULT_MESSAGE,
@@ -18,6 +23,7 @@ import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-h
import {
mockRunCronFallbackPassthrough,
runEmbeddedPiAgentMock,
+ updateSessionStoreMock,
} from "./isolated-agent/run.test-harness.js";
setupRunCronIsolatedAgentTurnSuite();
@@ -142,6 +148,79 @@ describe("runCronIsolatedAgentTurn session identity", () => {
});
});
+ it("persists rotated transcript identity for session-bound cron runs", async () => {
+ await withTempHome(async (home) => {
+ const deps = makeDeps();
+ const boundSessionKey = "agent:main:telegram:direct:42";
+ const originalSessionFile = path.join(home, "bound-session.jsonl");
+ const rotatedSessionFile = path.join(home, "bound-session-rotated.jsonl");
+ const storePath = await writeSessionStoreEntries(home, {
+ [boundSessionKey]: {
+ sessionId: "bound-session",
+ sessionFile: originalSessionFile,
+ updatedAt: Date.now(),
+ lastInteractionAt: Date.now() - 1_000,
+ systemSent: true,
+ },
+ });
+ runEmbeddedPiAgentMock.mockResolvedValueOnce({
+ payloads: [{ text: "ok" }],
+ meta: {
+ durationMs: 5,
+ agentMeta: {
+ sessionId: "bound-session-rotated",
+ sessionFile: rotatedSessionFile,
+ provider: "anthropic",
+ model: "claude-opus-4-6",
+ compactionCount: 1,
+ compactionTokensAfter: 42,
+ },
+ },
+ });
+ updateSessionStoreMock.mockImplementation(async (_storePath, update) => {
+ const store = {
+ [boundSessionKey]: {
+ sessionId: "bound-session",
+ sessionFile: originalSessionFile,
+ updatedAt: Date.now(),
+ lastInteractionAt: Date.now() - 1_000,
+ systemSent: true,
+ },
+ };
+ update(store);
+ });
+
+ const res = await runCronIsolatedAgentTurn({
+ cfg: makeCfg(home, storePath),
+ deps,
+ job: {
+ ...makeJob(DEFAULT_AGENT_TURN_PAYLOAD),
+ sessionTarget: `session:${boundSessionKey}`,
+ delivery: { mode: "none" },
+ },
+ message: DEFAULT_MESSAGE,
+ sessionKey: boundSessionKey,
+ lane: "cron",
+ });
+
+ expect(res.status).toBe("ok");
+ expect(res.sessionId).toBe("bound-session-rotated");
+
+ const finalPersist = updateSessionStoreMock.mock.calls.at(-1);
+ expect(finalPersist?.[0]).toBe(storePath);
+ const persistedStore: Record = {};
+ (finalPersist?.[1] as (store: typeof persistedStore) => void)(persistedStore);
+ expect(persistedStore[boundSessionKey]).toEqual(
+ expect.objectContaining({
+ sessionId: "bound-session-rotated",
+ sessionFile: rotatedSessionFile,
+ usageFamilyKey: boundSessionKey,
+ usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
+ }),
+ );
+ });
+ });
+
it("uses lightweight bootstrap context for command-style cron payloads", async () => {
await withTempHome(async (home) => {
await runCronTurn(home, {
diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts
index d1bd7b0f666..738e62943b1 100644
--- a/src/cron/isolated-agent/run-session-state.test.ts
+++ b/src/cron/isolated-agent/run-session-state.test.ts
@@ -4,7 +4,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
-import { createPersistCronSessionEntry, type MutableCronSession } from "./run-session-state.js";
+import {
+ adoptCronRunSessionMetadata,
+ createPersistCronSessionEntry,
+ type MutableCronSession,
+} from "./run-session-state.js";
function makeSessionEntry(overrides?: Partial): SessionEntry {
return {
@@ -159,6 +163,56 @@ describe("createPersistCronSessionEntry", () => {
expect(cronSession.store["agent:main:session"]).toBe(cronSession.sessionEntry);
});
+
+ it("adopts rotated run transcript metadata before persisting session-bound cron state", async () => {
+ const cronSession = makeCronSession(
+ makeSessionEntry({
+ sessionId: "bound-session",
+ sessionFile: "/tmp/bound-session.jsonl",
+ }),
+ );
+ const changed = adoptCronRunSessionMetadata({
+ entry: cronSession.sessionEntry,
+ sessionKey: "agent:main:telegram:direct:42",
+ runMeta: {
+ sessionId: "bound-session-rotated",
+ sessionFile: "/tmp/bound-session-rotated.jsonl",
+ },
+ });
+ const updateSessionStore = vi.fn(
+ async (_storePath, update: (store: Record) => void) => {
+ const store: Record = {};
+ update(store);
+ expect(store["agent:main:telegram:direct:42"]).toEqual({
+ sessionId: "bound-session-rotated",
+ sessionFile: "/tmp/bound-session-rotated.jsonl",
+ usageFamilyKey: "agent:main:telegram:direct:42",
+ usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
+ updatedAt: 1000,
+ systemSent: true,
+ });
+ },
+ );
+
+ expect(changed).toBe(true);
+ const persist = createPersistCronSessionEntry({
+ isFastTestEnv: false,
+ cronSession,
+ agentSessionKey: "agent:main:telegram:direct:42",
+ updateSessionStore,
+ });
+
+ await persist();
+
+ expect(cronSession.store["agent:main:telegram:direct:42"]).toEqual({
+ sessionId: "bound-session-rotated",
+ sessionFile: "/tmp/bound-session-rotated.jsonl",
+ usageFamilyKey: "agent:main:telegram:direct:42",
+ usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
+ updatedAt: 1000,
+ systemSent: true,
+ });
+ });
});
async function createTranscriptFile(): Promise {
diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts
index 337a1422ec6..a3c16abc1ed 100644
--- a/src/cron/isolated-agent/run-session-state.ts
+++ b/src/cron/isolated-agent/run-session-state.ts
@@ -26,6 +26,11 @@ function cronTranscriptExists(entry: SessionEntry): boolean {
return Boolean(sessionFile && fs.existsSync(sessionFile));
}
+function normalizeSessionField(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry {
const next = { ...entry } as Partial;
delete next.sessionId;
@@ -61,6 +66,43 @@ export function createPersistCronSessionEntry(params: {
};
}
+export function adoptCronRunSessionMetadata(params: {
+ entry: MutableCronSessionEntry;
+ sessionKey: string;
+ runMeta?: {
+ sessionId?: string;
+ sessionFile?: string;
+ };
+}): boolean {
+ const nextSessionId = normalizeSessionField(params.runMeta?.sessionId);
+ const nextSessionFile = normalizeSessionField(params.runMeta?.sessionFile);
+ if (!nextSessionFile) {
+ return false;
+ }
+
+ let changed = false;
+ const previousSessionId = params.entry.sessionId;
+ if (nextSessionId && nextSessionId !== previousSessionId) {
+ params.entry.sessionId = nextSessionId;
+ params.entry.usageFamilyKey = params.entry.usageFamilyKey ?? params.sessionKey;
+ params.entry.usageFamilySessionIds = Array.from(
+ new Set([
+ ...(params.entry.usageFamilySessionIds ?? []),
+ ...(previousSessionId ? [previousSessionId] : []),
+ nextSessionId,
+ ]),
+ );
+ changed = true;
+ }
+
+ if (nextSessionFile !== params.entry.sessionFile) {
+ params.entry.sessionFile = nextSessionFile;
+ changed = true;
+ }
+
+ return changed;
+}
+
export async function persistCronSkillsSnapshotIfChanged(params: {
isFastTestEnv: boolean;
cronSession: MutableCronSession;
diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts
index b5e24fb81a7..ddf21fd8183 100644
--- a/src/cron/isolated-agent/run.ts
+++ b/src/cron/isolated-agent/run.ts
@@ -37,6 +37,7 @@ import {
import { resolveCronModelSelection } from "./model-selection.js";
import { buildCronAgentDefaultsConfig } from "./run-config.js";
import {
+ adoptCronRunSessionMetadata,
createPersistCronSessionEntry,
markCronSessionPreRun,
persistCronSkillsSnapshotIfChanged,
@@ -580,7 +581,7 @@ async function prepareCronRunContext(params: {
});
const withRunSession: WithRunSession = (result) => ({
...result,
- sessionId: runSessionId,
+ sessionId: cronSession.sessionEntry.sessionId ?? runSessionId,
sessionKey: runSessionKey,
});
if (!cronSession.sessionEntry.label?.trim() && baseSessionKey.startsWith("cron:")) {
@@ -852,6 +853,11 @@ async function finalizeCronRun(params: {
if (finalRunResult.meta?.systemPromptReport) {
prepared.cronSession.sessionEntry.systemPromptReport = finalRunResult.meta.systemPromptReport;
}
+ adoptCronRunSessionMetadata({
+ entry: prepared.cronSession.sessionEntry,
+ sessionKey: prepared.agentSessionKey,
+ runMeta: finalRunResult.meta?.agentMeta,
+ });
const usage = finalRunResult.meta?.agentMeta?.usage;
const promptTokens = finalRunResult.meta?.agentMeta?.promptTokens;
const modelUsed =