From 5328399d75acd86c6da239184bbae979742982d7 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 13 Mar 2026 22:08:01 -0500 Subject: [PATCH] feat: add /btw side-turn MVP Coauthored with Nova. Co-authored-by: Nova --- src/auto-reply/commands-registry.data.ts | 16 ++ src/auto-reply/commands-registry.test.ts | 15 ++ src/auto-reply/reply/agent-runner-utils.ts | 1 + .../reply/get-reply-run.media-only.test.ts | 63 ++++++++ src/auto-reply/reply/get-reply-run.ts | 152 ++++++++++++------ .../get-reply.reset-hooks-fallback.test.ts | 93 +++++++++++ src/auto-reply/reply/get-reply.ts | 56 ++++++- src/auto-reply/reply/queue/types.ts | 1 + src/auto-reply/reply/session.ts | 98 ++++++----- 9 files changed, 398 insertions(+), 97 deletions(-) diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index c499f03c526..195767d6b8b 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -144,6 +144,22 @@ function buildChatCommands(): ChatCommandDefinition[] { textAlias: "/commands", category: "status", }), + defineChatCommand({ + key: "btw", + nativeName: "btw", + description: "Ask an ephemeral follow-up about the active session.", + textAlias: "/btw", + category: "status", + args: [ + { + name: "question", + description: "Inline follow-up question", + type: "string", + required: true, + captureRemaining: true, + }, + ], + }), defineChatCommand({ key: "skill", nativeName: "skill", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 326211560ee..2227aa81001 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -36,6 +36,7 @@ describe("commands registry", () => { it("exposes native specs", () => { const specs = listNativeCommandSpecs(); + expect(specs.find((spec) => spec.name === "btw")).toBeTruthy(); expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); expect(specs.find((spec) => spec.name === "stop")).toBeTruthy(); expect(specs.find((spec) => spec.name === "skill")).toBeTruthy(); @@ -209,6 +210,20 @@ describe("commands registry", () => { expect(modeArg?.choices).toEqual(["status", "on", "off"]); }); + it("registers /btw as an inline-question command", () => { + const command = findCommandByNativeName("btw"); + expect(command).toMatchObject({ + key: "btw", + nativeName: "btw", + textAliases: ["/btw"], + }); + expect(command?.args?.[0]).toMatchObject({ + name: "question", + required: true, + captureRemaining: true, + }); + }); + it("detects known text commands", () => { const detection = getCommandDetection(); expect(detection.exact.has("/commands")).toBe(true); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index c6e71a9bab0..9f3776cc28b 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -192,6 +192,7 @@ export function buildEmbeddedRunBaseParams(params: { thinkLevel: params.run.thinkLevel, verboseLevel: params.run.verboseLevel, reasoningLevel: params.run.reasoningLevel, + disableTools: params.run.disableTools, execOverrides: params.run.execOverrides, bashElevated: params.run.bashElevated, timeoutMs: params.run.timeoutMs, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 829b3937009..a45d8af4c02 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { runPreparedReply } from "./get-reply-run.js"; @@ -79,6 +82,7 @@ vi.mock("./typing-mode.js", () => ({ resolveTypingMode: vi.fn().mockReturnValue("off"), })); +import { resolveSessionFilePath } from "../../config/sessions.js"; import { runReplyAgent } from "./agent-runner.js"; import { routeReply } from "./route-reply.js"; import { drainFormattedSystemEvents } from "./session-updates.js"; @@ -396,4 +400,63 @@ describe("runPreparedReply media-only handling", () => { // Queue body (used by steer mode) must keep the full original text. expect(call?.followupRun.prompt).toContain("low steer this conversation"); }); + + it("forces /btw side turns to run as a single no-tools reply", async () => { + await runPreparedReply( + baseParams({ + blockStreamingEnabled: true, + ephemeralSideTurn: { kind: "btw" }, + }), + ); + + const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0]; + expect(call).toBeTruthy(); + expect(call?.followupRun.run.disableTools).toBe(true); + expect(call?.resolvedQueue.mode).toBe("interrupt"); + expect(call?.shouldSteer).toBe(false); + expect(call?.shouldFollowup).toBe(false); + expect(call?.blockStreamingEnabled).toBe(false); + expect(call?.queueKey).toBe(call?.followupRun.run.sessionId); + expect(call?.queueKey).not.toBe("session-key"); + }); + + it("copies parent transcript into a temporary /btw session without mutating the parent", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-btw-test-")); + const sourceSessionFile = path.join(tempRoot, "parent.jsonl"); + const sourceTranscript = [ + JSON.stringify({ type: "header", sessionId: "parent-session" }), + JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "parent question" }] }, + }), + "", + ].join("\n"); + await fs.writeFile(sourceSessionFile, sourceTranscript, "utf-8"); + + let copiedTranscript = ""; + vi.mocked(runReplyAgent).mockImplementationOnce(async (call) => { + copiedTranscript = await fs.readFile(call.followupRun.run.sessionFile, "utf-8"); + return { text: "ok" }; + }); + vi.mocked(resolveSessionFilePath).mockReturnValueOnce(sourceSessionFile); + + try { + await runPreparedReply( + baseParams({ + ephemeralSideTurn: { kind: "btw" }, + sessionEntry: { + sessionId: "parent-session", + updatedAt: 1, + sessionFile: sourceSessionFile, + }, + storePath: path.join(tempRoot, "sessions.json"), + }), + ); + + expect(copiedTranscript).toBe(sourceTranscript); + await expect(fs.readFile(sourceSessionFile, "utf-8")).resolves.toBe(sourceTranscript); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 760c42aed1a..bf7259cdd75 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1,4 +1,7 @@ import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; @@ -53,6 +56,30 @@ import { appendUntrustedContext } from "./untrusted-context.js"; type AgentDefaults = NonNullable["defaults"]; type ExecOverrides = Pick; +type EphemeralSideTurn = { kind: "btw" }; + +async function createEphemeralSideTurnSession(params: { + agentId: string; + sessionEntry?: SessionEntry; + storePath?: string; +}) { + const sessionId = crypto.randomUUID(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-btw-")); + const sessionFile = path.join(tempDir, `${sessionId}.jsonl`); + const sourceSessionFile = params.sessionEntry + ? resolveSessionFilePath( + params.sessionEntry.sessionId ?? sessionId, + params.sessionEntry, + resolveSessionFilePathOptions({ agentId: params.agentId, storePath: params.storePath }), + ) + : undefined; + if (sourceSessionFile) { + await fs.copyFile(sourceSessionFile, sessionFile); + } else { + await fs.writeFile(sessionFile, "", "utf-8"); + } + return { sessionId, sessionFile, tempDir }; +} function buildResetSessionNoticeText(params: { provider: string; @@ -177,6 +204,7 @@ type RunPreparedReplyParams = { storePath?: string; workspaceDir: string; abortedLastRun: boolean; + ephemeralSideTurn?: EphemeralSideTurn; }; export async function runPreparedReply( @@ -219,6 +247,7 @@ export async function runPreparedReply( storePath, workspaceDir, sessionStore, + ephemeralSideTurn, } = params; let { sessionEntry, @@ -230,6 +259,9 @@ export async function runPreparedReply( abortedLastRun, } = params; let currentSystemSent = systemSent; + const persistedSessionStore = ephemeralSideTurn ? undefined : sessionStore; + const persistedStorePath = ephemeralSideTurn ? undefined : storePath; + const abortedLastRunForRun = ephemeralSideTurn ? false : abortedLastRun; const isFirstTurnInSession = isNewSession || !currentSystemSent; const isGroupChat = sessionCtx.ChatType === "group"; @@ -324,11 +356,11 @@ export async function runPreparedReply( : "[User sent media without caption]"; let prefixedBodyBase = await applySessionHints({ baseBody: effectiveBaseBody, - abortedLastRun, + abortedLastRun: abortedLastRunForRun, sessionEntry, - sessionStore, + sessionStore: persistedSessionStore, sessionKey, - storePath, + storePath: persistedStorePath, abortKey: command.abortKey, }); const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "channel"; @@ -367,9 +399,9 @@ export async function runPreparedReply( : undefined; const skillResult = await ensureSkillSnapshot({ sessionEntry, - sessionStore, + sessionStore: persistedSessionStore, sessionKey, - storePath, + storePath: persistedStorePath, sessionId, isFirstTurnInSession, workspaceDir, @@ -399,12 +431,18 @@ export async function runPreparedReply( }; } resolvedThinkLevel = "high"; - if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") { + if ( + !ephemeralSideTurn && + sessionEntry && + sessionStore && + sessionKey && + sessionEntry.thinkingLevel === "xhigh" + ) { sessionEntry.thinkingLevel = "high"; sessionEntry.updatedAt = Date.now(); sessionStore[sessionKey] = sessionEntry; - if (storePath) { - await updateSessionStore(storePath, (store) => { + if (persistedStorePath) { + await updateSessionStore(persistedStorePath, (store) => { store[sessionKey] = sessionEntry; }); } @@ -424,40 +462,53 @@ export async function runPreparedReply( defaultModel, }); } - const sessionIdFinal = sessionId ?? crypto.randomUUID(); - const sessionFile = resolveSessionFilePath( - sessionIdFinal, - sessionEntry, - resolveSessionFilePathOptions({ agentId, storePath }), - ); + const sideTurnSession = ephemeralSideTurn + ? await createEphemeralSideTurnSession({ + agentId, + sessionEntry, + storePath, + }) + : null; + const sessionIdFinal = sideTurnSession?.sessionId ?? sessionId ?? crypto.randomUUID(); + const sessionFile = + sideTurnSession?.sessionFile ?? + resolveSessionFilePath( + sessionIdFinal, + sessionEntry, + resolveSessionFilePathOptions({ agentId, storePath }), + ); // Use bodyWithEvents (events prepended, but no session hints / untrusted context) so // deferred turns receive system events while keeping the same scope as effectiveBaseBody did. const queueBodyBase = [threadContextNote, bodyWithEvents].filter(Boolean).join("\n\n"); const queuedBody = mediaNote ? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim() : queueBodyBase; - const resolvedQueue = resolveQueueSettings({ + const inheritedQueue = resolveQueueSettings({ cfg, channel: sessionCtx.Provider, sessionEntry, inlineMode: perMessageQueueMode, inlineOptions: perMessageQueueOptions, }); - const sessionLaneKey = resolveEmbeddedSessionLane(sessionKey ?? sessionIdFinal); + const resolvedQueue = ephemeralSideTurn ? { mode: "interrupt" as const } : inheritedQueue; + const queueKey = ephemeralSideTurn ? sessionIdFinal : (sessionKey ?? sessionIdFinal); + const sessionLaneKey = resolveEmbeddedSessionLane(queueKey); const laneSize = getQueueSize(sessionLaneKey); if (resolvedQueue.mode === "interrupt" && laneSize > 0) { const cleared = clearCommandLane(sessionLaneKey); const aborted = abortEmbeddedPiRun(sessionIdFinal); logVerbose(`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`); } - const queueKey = sessionKey ?? sessionIdFinal; const isActive = isEmbeddedPiRunActive(sessionIdFinal); const isStreaming = isEmbeddedPiRunStreaming(sessionIdFinal); - const shouldSteer = resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog"; + const shouldSteer = + !ephemeralSideTurn && + (resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog"); const shouldFollowup = - resolvedQueue.mode === "followup" || - resolvedQueue.mode === "collect" || - resolvedQueue.mode === "steer-backlog"; + !ephemeralSideTurn && + (resolvedQueue.mode === "followup" || + resolvedQueue.mode === "collect" || + resolvedQueue.mode === "steer-backlog"); const authProfileId = await resolveSessionAuthProfileOverride({ cfg, provider, @@ -519,6 +570,7 @@ export async function runPreparedReply( verboseLevel: resolvedVerboseLevel, reasoningLevel: resolvedReasoningLevel, elevatedLevel: resolvedElevatedLevel, + ...(ephemeralSideTurn ? { disableTools: true } : {}), execOverrides, bashElevated: { enabled: elevatedEnabled, @@ -534,30 +586,36 @@ export async function runPreparedReply( }, }; - return runReplyAgent({ - commandBody: prefixedCommandBody, - followupRun, - queueKey, - resolvedQueue, - shouldSteer, - shouldFollowup, - isActive, - isStreaming, - opts, - typing, - sessionEntry, - sessionStore, - sessionKey, - storePath, - defaultModel, - agentCfgContextTokens: agentCfg?.contextTokens, - resolvedVerboseLevel: resolvedVerboseLevel ?? "off", - isNewSession, - blockStreamingEnabled, - blockReplyChunking, - resolvedBlockStreamingBreak, - sessionCtx, - shouldInjectGroupIntro, - typingMode, - }); + try { + return await runReplyAgent({ + commandBody: prefixedCommandBody, + followupRun, + queueKey, + resolvedQueue, + shouldSteer, + shouldFollowup, + isActive, + isStreaming, + opts, + typing, + sessionEntry, + sessionStore: persistedSessionStore, + sessionKey, + storePath: persistedStorePath, + defaultModel, + agentCfgContextTokens: agentCfg?.contextTokens, + resolvedVerboseLevel: resolvedVerboseLevel ?? "off", + isNewSession, + blockStreamingEnabled: ephemeralSideTurn ? false : blockStreamingEnabled, + blockReplyChunking, + resolvedBlockStreamingBreak, + sessionCtx, + shouldInjectGroupIntro, + typingMode, + }); + } finally { + if (sideTurnSession) { + await fs.rm(sideTurnSession.tempDir, { recursive: true, force: true }); + } + } } diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts index 110b46af476..b0490079a51 100644 --- a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -49,6 +49,23 @@ function buildNativeResetContext(): MsgContext { }; } +function buildNativeBtwContext(): MsgContext { + return { + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + Body: "/btw what changed?", + RawBody: "/btw what changed?", + CommandBody: "/btw what changed?", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: "agent:main:telegram:direct:123", + From: "telegram:123", + To: "slash:123", + }; +} + function createContinueDirectivesResult(resetHookTriggered: boolean) { return { kind: "continue" as const, @@ -150,4 +167,80 @@ describe("getReplyFromConfig reset-hook fallback", () => { expect(mocks.emitResetCommandHooks).not.toHaveBeenCalled(); }); + + it("rewrites native /btw to the inline question and resolves the target session read-only", async () => { + mocks.handleInlineActions.mockResolvedValueOnce({ kind: "reply", reply: undefined }); + mocks.initSessionState.mockResolvedValueOnce({ + sessionCtx: { BodyStripped: "what changed?" }, + sessionEntry: { + sessionId: "session-1", + updatedAt: 1, + sessionFile: "/tmp/session-1.jsonl", + }, + previousSessionEntry: undefined, + sessionStore: {}, + sessionKey: "agent:main:telegram:direct:123", + sessionId: "session-1", + isNewSession: false, + resetTriggered: false, + systemSent: true, + abortedLastRun: false, + storePath: "/tmp/sessions.json", + sessionScope: "per-sender", + groupResolution: undefined, + isGroup: false, + triggerBodyNormalized: "what changed?", + bodyStripped: "what changed?", + }); + mocks.resolveReplyDirectives.mockResolvedValueOnce(createContinueDirectivesResult(false)); + + await getReplyFromConfig(buildNativeBtwContext(), undefined, {}); + + expect(mocks.initSessionState).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + Body: "what changed?", + CommandBody: "what changed?", + RawBody: "what changed?", + }), + readOnly: true, + }), + ); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledWith( + expect.objectContaining({ + triggerBodyNormalized: "what changed?", + sessionCtx: expect.objectContaining({ + BodyStripped: "what changed?", + }), + }), + ); + }); + + it("returns an error for /btw when there is no active target session", async () => { + mocks.initSessionState.mockResolvedValueOnce({ + sessionCtx: {}, + sessionEntry: { + sessionId: "session-2", + updatedAt: 2, + }, + previousSessionEntry: undefined, + sessionStore: {}, + sessionKey: "agent:main:telegram:direct:123", + sessionId: "session-2", + isNewSession: true, + resetTriggered: false, + systemSent: false, + abortedLastRun: false, + storePath: "/tmp/sessions.json", + sessionScope: "per-sender", + groupResolution: undefined, + isGroup: false, + triggerBodyNormalized: "what changed?", + bodyStripped: "what changed?", + }); + + await expect(getReplyFromConfig(buildNativeBtwContext(), undefined, {})).resolves.toEqual({ + text: "❌ No active session found for /btw.", + }); + }); }); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 81dd478a84a..a1ffdbc55bd 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -14,6 +14,7 @@ import { applyMediaUnderstanding } from "../../media-understanding/apply.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { resolveCommandAuthorization } from "../command-auth.js"; +import { shouldHandleTextCommands } from "../commands-registry.js"; import type { MsgContext } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -54,6 +55,18 @@ function mergeSkillFilters(channelFilter?: string[], agentFilter?: string[]): st return channel.filter((name) => agentSet.has(name)); } +function parseBtwInlineQuestion(text: string | undefined): string | null { + const trimmed = text?.trim(); + if (!trimmed) { + return null; + } + const match = trimmed.match(/^\/btw(?:\s+([\s\S]+))?$/i); + if (!match) { + return null; + } + return match[1]?.trim() ?? ""; +} + export async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, @@ -124,6 +137,36 @@ export async function getReplyFromConfig( opts?.onTypingController?.(typing); const finalized = finalizeInboundContext(ctx); + const commandAuthorized = finalized.CommandAuthorized; + const commandAuth = resolveCommandAuthorization({ + ctx: finalized, + cfg, + commandAuthorized, + }); + const btwQuestion = parseBtwInlineQuestion( + finalized.BodyForCommands ?? finalized.CommandBody ?? finalized.RawBody ?? finalized.Body, + ); + const allowBtwSideTurn = + typeof btwQuestion === "string" && + shouldHandleTextCommands({ + cfg, + surface: finalized.Surface, + commandSource: finalized.CommandSource, + }); + const useBtwSideTurn = allowBtwSideTurn && typeof btwQuestion === "string"; + if (useBtwSideTurn && !commandAuth.isAuthorizedSender) { + return undefined; + } + if (useBtwSideTurn && btwQuestion.length === 0) { + return { text: "⚙️ Usage: /btw " }; + } + if (useBtwSideTurn) { + finalized.Body = btwQuestion; + finalized.BodyForAgent = btwQuestion; + finalized.RawBody = btwQuestion; + finalized.CommandBody = btwQuestion; + finalized.BodyForCommands = btwQuestion; + } if (!isFastTestEnv) { await applyMediaUnderstanding({ @@ -143,16 +186,11 @@ export async function getReplyFromConfig( isFastTestEnv, }); - const commandAuthorized = finalized.CommandAuthorized; - resolveCommandAuthorization({ - ctx: finalized, - cfg, - commandAuthorized, - }); const sessionState = await initSessionState({ ctx: finalized, cfg, commandAuthorized, + readOnly: useBtwSideTurn, }); let { sessionCtx, @@ -173,6 +211,11 @@ export async function getReplyFromConfig( bodyStripped, } = sessionState; + if (useBtwSideTurn && (isNewSession || !sessionEntry?.sessionFile?.trim())) { + typing.cleanup(); + return { text: "❌ No active session found for /btw." }; + } + await applyResetModelOverride({ cfg, agentId, @@ -400,5 +443,6 @@ export async function getReplyFromConfig( storePath, workspaceDir, abortedLastRun, + ...(useBtwSideTurn ? { ephemeralSideTurn: { kind: "btw" as const } } : {}), }); } diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 507f77d499d..969ac3f252a 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -77,6 +77,7 @@ export type FollowupRun = { }; timeoutMs: number; blockReplyBreak: "text_end" | "message_end"; + disableTools?: boolean; ownerNumbers?: string[]; inputProvenance?: InputProvenance; extraSystemPrompt?: string; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a2c0b1c7cf4..ed1f173a62c 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -170,8 +170,10 @@ export async function initSessionState(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; + readOnly?: boolean; }): Promise { const { ctx, cfg, commandAuthorized } = params; + const readOnly = params.readOnly === true; // Native slash commands (Telegram/Discord/Slack) are delivered on a separate // "slash session" key, but should mutate the target chat session. const targetSessionKey = @@ -496,18 +498,24 @@ export async function initSessionState(params: { const fallbackSessionFile = !sessionEntry.sessionFile ? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId) : undefined; - const resolvedSessionFile = await resolveAndPersistSessionFile({ - sessionId: sessionEntry.sessionId, - sessionKey, - sessionStore, - storePath, - sessionEntry, - agentId, - sessionsDir: path.dirname(storePath), - fallbackSessionFile, - activeSessionKey: sessionKey, - }); - sessionEntry = resolvedSessionFile.sessionEntry; + if (readOnly) { + if (!sessionEntry.sessionFile && fallbackSessionFile) { + sessionEntry.sessionFile = fallbackSessionFile; + } + } else { + const resolvedSessionFile = await resolveAndPersistSessionFile({ + sessionId: sessionEntry.sessionId, + sessionKey, + sessionStore, + storePath, + sessionEntry, + agentId, + sessionsDir: path.dirname(storePath), + fallbackSessionFile, + activeSessionKey: sessionKey, + }); + sessionEntry = resolvedSessionFile.sessionEntry; + } if (isNewSession) { sessionEntry.compactionCount = 0; sessionEntry.memoryFlushCompactionCount = undefined; @@ -519,38 +527,40 @@ export async function initSessionState(params: { sessionEntry.outputTokens = undefined; sessionEntry.contextTokens = undefined; } - // Preserve per-session overrides while resetting compaction state on /new. - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; - await updateSessionStore( - storePath, - (store) => { - // Preserve per-session overrides while resetting compaction state on /new. - store[sessionKey] = { ...store[sessionKey], ...sessionEntry }; - if (retiredLegacyMainDelivery) { - store[retiredLegacyMainDelivery.key] = retiredLegacyMainDelivery.entry; - } - }, - { - activeSessionKey: sessionKey, - onWarn: (warning) => - deliverSessionMaintenanceWarning({ - cfg, - sessionKey, - entry: sessionEntry, - warning, - }), - }, - ); - - // Archive old transcript so it doesn't accumulate on disk (#14869). - if (previousSessionEntry?.sessionId) { - archiveSessionTranscripts({ - sessionId: previousSessionEntry.sessionId, + if (!readOnly) { + // Preserve per-session overrides while resetting compaction state on /new. + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; + await updateSessionStore( storePath, - sessionFile: previousSessionEntry.sessionFile, - agentId, - reason: "reset", - }); + (store) => { + // Preserve per-session overrides while resetting compaction state on /new. + store[sessionKey] = { ...store[sessionKey], ...sessionEntry }; + if (retiredLegacyMainDelivery) { + store[retiredLegacyMainDelivery.key] = retiredLegacyMainDelivery.entry; + } + }, + { + activeSessionKey: sessionKey, + onWarn: (warning) => + deliverSessionMaintenanceWarning({ + cfg, + sessionKey, + entry: sessionEntry, + warning, + }), + }, + ); + + // Archive old transcript so it doesn't accumulate on disk (#14869). + if (previousSessionEntry?.sessionId) { + archiveSessionTranscripts({ + sessionId: previousSessionEntry.sessionId, + storePath, + sessionFile: previousSessionEntry.sessionFile, + agentId, + reason: "reset", + }); + } } const sessionCtx: TemplateContext = { @@ -571,7 +581,7 @@ export async function initSessionState(params: { }; // Run session plugin hooks (fire-and-forget) - const hookRunner = getGlobalHookRunner(); + const hookRunner = readOnly ? null : getGlobalHookRunner(); if (hookRunner && isNewSession) { const effectiveSessionId = sessionId ?? "";