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 =