feat: add /btw side-turn MVP

Coauthored with Nova.

Co-authored-by: Nova <nova@openknot.ai>
This commit is contained in:
Val Alexander
2026-03-13 22:08:01 -05:00
parent ecedddae81
commit 5328399d75
9 changed files with 398 additions and 97 deletions

View File

@@ -144,6 +144,22 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/commands", textAlias: "/commands",
category: "status", 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({ defineChatCommand({
key: "skill", key: "skill",
nativeName: "skill", nativeName: "skill",

View File

@@ -36,6 +36,7 @@ describe("commands registry", () => {
it("exposes native specs", () => { it("exposes native specs", () => {
const specs = listNativeCommandSpecs(); 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 === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy(); expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
expect(specs.find((spec) => spec.name === "skill")).toBeTruthy(); expect(specs.find((spec) => spec.name === "skill")).toBeTruthy();
@@ -209,6 +210,20 @@ describe("commands registry", () => {
expect(modeArg?.choices).toEqual(["status", "on", "off"]); 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", () => { it("detects known text commands", () => {
const detection = getCommandDetection(); const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true); expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -192,6 +192,7 @@ export function buildEmbeddedRunBaseParams(params: {
thinkLevel: params.run.thinkLevel, thinkLevel: params.run.thinkLevel,
verboseLevel: params.run.verboseLevel, verboseLevel: params.run.verboseLevel,
reasoningLevel: params.run.reasoningLevel, reasoningLevel: params.run.reasoningLevel,
disableTools: params.run.disableTools,
execOverrides: params.run.execOverrides, execOverrides: params.run.execOverrides,
bashElevated: params.run.bashElevated, bashElevated: params.run.bashElevated,
timeoutMs: params.run.timeoutMs, timeoutMs: params.run.timeoutMs,

View File

@@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
import { runPreparedReply } from "./get-reply-run.js"; import { runPreparedReply } from "./get-reply-run.js";
@@ -79,6 +82,7 @@ vi.mock("./typing-mode.js", () => ({
resolveTypingMode: vi.fn().mockReturnValue("off"), resolveTypingMode: vi.fn().mockReturnValue("off"),
})); }));
import { resolveSessionFilePath } from "../../config/sessions.js";
import { runReplyAgent } from "./agent-runner.js"; import { runReplyAgent } from "./agent-runner.js";
import { routeReply } from "./route-reply.js"; import { routeReply } from "./route-reply.js";
import { drainFormattedSystemEvents } from "./session-updates.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. // Queue body (used by steer mode) must keep the full original text.
expect(call?.followupRun.prompt).toContain("low steer this conversation"); 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 });
}
});
}); });

View File

@@ -1,4 +1,7 @@
import crypto from "node:crypto"; 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 { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import { resolveFastModeState } from "../../agents/fast-mode.js"; import { resolveFastModeState } from "../../agents/fast-mode.js";
@@ -53,6 +56,30 @@ import { appendUntrustedContext } from "./untrusted-context.js";
type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"]; type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">; type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
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: { function buildResetSessionNoticeText(params: {
provider: string; provider: string;
@@ -177,6 +204,7 @@ type RunPreparedReplyParams = {
storePath?: string; storePath?: string;
workspaceDir: string; workspaceDir: string;
abortedLastRun: boolean; abortedLastRun: boolean;
ephemeralSideTurn?: EphemeralSideTurn;
}; };
export async function runPreparedReply( export async function runPreparedReply(
@@ -219,6 +247,7 @@ export async function runPreparedReply(
storePath, storePath,
workspaceDir, workspaceDir,
sessionStore, sessionStore,
ephemeralSideTurn,
} = params; } = params;
let { let {
sessionEntry, sessionEntry,
@@ -230,6 +259,9 @@ export async function runPreparedReply(
abortedLastRun, abortedLastRun,
} = params; } = params;
let currentSystemSent = systemSent; let currentSystemSent = systemSent;
const persistedSessionStore = ephemeralSideTurn ? undefined : sessionStore;
const persistedStorePath = ephemeralSideTurn ? undefined : storePath;
const abortedLastRunForRun = ephemeralSideTurn ? false : abortedLastRun;
const isFirstTurnInSession = isNewSession || !currentSystemSent; const isFirstTurnInSession = isNewSession || !currentSystemSent;
const isGroupChat = sessionCtx.ChatType === "group"; const isGroupChat = sessionCtx.ChatType === "group";
@@ -324,11 +356,11 @@ export async function runPreparedReply(
: "[User sent media without caption]"; : "[User sent media without caption]";
let prefixedBodyBase = await applySessionHints({ let prefixedBodyBase = await applySessionHints({
baseBody: effectiveBaseBody, baseBody: effectiveBaseBody,
abortedLastRun, abortedLastRun: abortedLastRunForRun,
sessionEntry, sessionEntry,
sessionStore, sessionStore: persistedSessionStore,
sessionKey, sessionKey,
storePath, storePath: persistedStorePath,
abortKey: command.abortKey, abortKey: command.abortKey,
}); });
const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "channel"; const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "channel";
@@ -367,9 +399,9 @@ export async function runPreparedReply(
: undefined; : undefined;
const skillResult = await ensureSkillSnapshot({ const skillResult = await ensureSkillSnapshot({
sessionEntry, sessionEntry,
sessionStore, sessionStore: persistedSessionStore,
sessionKey, sessionKey,
storePath, storePath: persistedStorePath,
sessionId, sessionId,
isFirstTurnInSession, isFirstTurnInSession,
workspaceDir, workspaceDir,
@@ -399,12 +431,18 @@ export async function runPreparedReply(
}; };
} }
resolvedThinkLevel = "high"; resolvedThinkLevel = "high";
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") { if (
!ephemeralSideTurn &&
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
sessionEntry.thinkingLevel = "high"; sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
if (storePath) { if (persistedStorePath) {
await updateSessionStore(storePath, (store) => { await updateSessionStore(persistedStorePath, (store) => {
store[sessionKey] = sessionEntry; store[sessionKey] = sessionEntry;
}); });
} }
@@ -424,40 +462,53 @@ export async function runPreparedReply(
defaultModel, defaultModel,
}); });
} }
const sessionIdFinal = sessionId ?? crypto.randomUUID(); const sideTurnSession = ephemeralSideTurn
const sessionFile = resolveSessionFilePath( ? await createEphemeralSideTurnSession({
sessionIdFinal, agentId,
sessionEntry, sessionEntry,
resolveSessionFilePathOptions({ agentId, storePath }), 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 // Use bodyWithEvents (events prepended, but no session hints / untrusted context) so
// deferred turns receive system events while keeping the same scope as effectiveBaseBody did. // deferred turns receive system events while keeping the same scope as effectiveBaseBody did.
const queueBodyBase = [threadContextNote, bodyWithEvents].filter(Boolean).join("\n\n"); const queueBodyBase = [threadContextNote, bodyWithEvents].filter(Boolean).join("\n\n");
const queuedBody = mediaNote const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim() ? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
: queueBodyBase; : queueBodyBase;
const resolvedQueue = resolveQueueSettings({ const inheritedQueue = resolveQueueSettings({
cfg, cfg,
channel: sessionCtx.Provider, channel: sessionCtx.Provider,
sessionEntry, sessionEntry,
inlineMode: perMessageQueueMode, inlineMode: perMessageQueueMode,
inlineOptions: perMessageQueueOptions, 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); const laneSize = getQueueSize(sessionLaneKey);
if (resolvedQueue.mode === "interrupt" && laneSize > 0) { if (resolvedQueue.mode === "interrupt" && laneSize > 0) {
const cleared = clearCommandLane(sessionLaneKey); const cleared = clearCommandLane(sessionLaneKey);
const aborted = abortEmbeddedPiRun(sessionIdFinal); const aborted = abortEmbeddedPiRun(sessionIdFinal);
logVerbose(`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`); logVerbose(`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`);
} }
const queueKey = sessionKey ?? sessionIdFinal;
const isActive = isEmbeddedPiRunActive(sessionIdFinal); const isActive = isEmbeddedPiRunActive(sessionIdFinal);
const isStreaming = isEmbeddedPiRunStreaming(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 = const shouldFollowup =
resolvedQueue.mode === "followup" || !ephemeralSideTurn &&
resolvedQueue.mode === "collect" || (resolvedQueue.mode === "followup" ||
resolvedQueue.mode === "steer-backlog"; resolvedQueue.mode === "collect" ||
resolvedQueue.mode === "steer-backlog");
const authProfileId = await resolveSessionAuthProfileOverride({ const authProfileId = await resolveSessionAuthProfileOverride({
cfg, cfg,
provider, provider,
@@ -519,6 +570,7 @@ export async function runPreparedReply(
verboseLevel: resolvedVerboseLevel, verboseLevel: resolvedVerboseLevel,
reasoningLevel: resolvedReasoningLevel, reasoningLevel: resolvedReasoningLevel,
elevatedLevel: resolvedElevatedLevel, elevatedLevel: resolvedElevatedLevel,
...(ephemeralSideTurn ? { disableTools: true } : {}),
execOverrides, execOverrides,
bashElevated: { bashElevated: {
enabled: elevatedEnabled, enabled: elevatedEnabled,
@@ -534,30 +586,36 @@ export async function runPreparedReply(
}, },
}; };
return runReplyAgent({ try {
commandBody: prefixedCommandBody, return await runReplyAgent({
followupRun, commandBody: prefixedCommandBody,
queueKey, followupRun,
resolvedQueue, queueKey,
shouldSteer, resolvedQueue,
shouldFollowup, shouldSteer,
isActive, shouldFollowup,
isStreaming, isActive,
opts, isStreaming,
typing, opts,
sessionEntry, typing,
sessionStore, sessionEntry,
sessionKey, sessionStore: persistedSessionStore,
storePath, sessionKey,
defaultModel, storePath: persistedStorePath,
agentCfgContextTokens: agentCfg?.contextTokens, defaultModel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off", agentCfgContextTokens: agentCfg?.contextTokens,
isNewSession, resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
blockStreamingEnabled, isNewSession,
blockReplyChunking, blockStreamingEnabled: ephemeralSideTurn ? false : blockStreamingEnabled,
resolvedBlockStreamingBreak, blockReplyChunking,
sessionCtx, resolvedBlockStreamingBreak,
shouldInjectGroupIntro, sessionCtx,
typingMode, shouldInjectGroupIntro,
}); typingMode,
});
} finally {
if (sideTurnSession) {
await fs.rm(sideTurnSession.tempDir, { recursive: true, force: true });
}
}
} }

View File

@@ -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) { function createContinueDirectivesResult(resetHookTriggered: boolean) {
return { return {
kind: "continue" as const, kind: "continue" as const,
@@ -150,4 +167,80 @@ describe("getReplyFromConfig reset-hook fallback", () => {
expect(mocks.emitResetCommandHooks).not.toHaveBeenCalled(); 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.",
});
});
}); });

View File

@@ -14,6 +14,7 @@ import { applyMediaUnderstanding } from "../../media-understanding/apply.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js";
import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveCommandAuthorization } from "../command-auth.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.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)); 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( export async function getReplyFromConfig(
ctx: MsgContext, ctx: MsgContext,
opts?: GetReplyOptions, opts?: GetReplyOptions,
@@ -124,6 +137,36 @@ export async function getReplyFromConfig(
opts?.onTypingController?.(typing); opts?.onTypingController?.(typing);
const finalized = finalizeInboundContext(ctx); 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 <question>" };
}
if (useBtwSideTurn) {
finalized.Body = btwQuestion;
finalized.BodyForAgent = btwQuestion;
finalized.RawBody = btwQuestion;
finalized.CommandBody = btwQuestion;
finalized.BodyForCommands = btwQuestion;
}
if (!isFastTestEnv) { if (!isFastTestEnv) {
await applyMediaUnderstanding({ await applyMediaUnderstanding({
@@ -143,16 +186,11 @@ export async function getReplyFromConfig(
isFastTestEnv, isFastTestEnv,
}); });
const commandAuthorized = finalized.CommandAuthorized;
resolveCommandAuthorization({
ctx: finalized,
cfg,
commandAuthorized,
});
const sessionState = await initSessionState({ const sessionState = await initSessionState({
ctx: finalized, ctx: finalized,
cfg, cfg,
commandAuthorized, commandAuthorized,
readOnly: useBtwSideTurn,
}); });
let { let {
sessionCtx, sessionCtx,
@@ -173,6 +211,11 @@ export async function getReplyFromConfig(
bodyStripped, bodyStripped,
} = sessionState; } = sessionState;
if (useBtwSideTurn && (isNewSession || !sessionEntry?.sessionFile?.trim())) {
typing.cleanup();
return { text: "❌ No active session found for /btw." };
}
await applyResetModelOverride({ await applyResetModelOverride({
cfg, cfg,
agentId, agentId,
@@ -400,5 +443,6 @@ export async function getReplyFromConfig(
storePath, storePath,
workspaceDir, workspaceDir,
abortedLastRun, abortedLastRun,
...(useBtwSideTurn ? { ephemeralSideTurn: { kind: "btw" as const } } : {}),
}); });
} }

View File

@@ -77,6 +77,7 @@ export type FollowupRun = {
}; };
timeoutMs: number; timeoutMs: number;
blockReplyBreak: "text_end" | "message_end"; blockReplyBreak: "text_end" | "message_end";
disableTools?: boolean;
ownerNumbers?: string[]; ownerNumbers?: string[];
inputProvenance?: InputProvenance; inputProvenance?: InputProvenance;
extraSystemPrompt?: string; extraSystemPrompt?: string;

View File

@@ -170,8 +170,10 @@ export async function initSessionState(params: {
ctx: MsgContext; ctx: MsgContext;
cfg: OpenClawConfig; cfg: OpenClawConfig;
commandAuthorized: boolean; commandAuthorized: boolean;
readOnly?: boolean;
}): Promise<SessionInitResult> { }): Promise<SessionInitResult> {
const { ctx, cfg, commandAuthorized } = params; const { ctx, cfg, commandAuthorized } = params;
const readOnly = params.readOnly === true;
// Native slash commands (Telegram/Discord/Slack) are delivered on a separate // Native slash commands (Telegram/Discord/Slack) are delivered on a separate
// "slash session" key, but should mutate the target chat session. // "slash session" key, but should mutate the target chat session.
const targetSessionKey = const targetSessionKey =
@@ -496,18 +498,24 @@ export async function initSessionState(params: {
const fallbackSessionFile = !sessionEntry.sessionFile const fallbackSessionFile = !sessionEntry.sessionFile
? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId) ? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId)
: undefined; : undefined;
const resolvedSessionFile = await resolveAndPersistSessionFile({ if (readOnly) {
sessionId: sessionEntry.sessionId, if (!sessionEntry.sessionFile && fallbackSessionFile) {
sessionKey, sessionEntry.sessionFile = fallbackSessionFile;
sessionStore, }
storePath, } else {
sessionEntry, const resolvedSessionFile = await resolveAndPersistSessionFile({
agentId, sessionId: sessionEntry.sessionId,
sessionsDir: path.dirname(storePath), sessionKey,
fallbackSessionFile, sessionStore,
activeSessionKey: sessionKey, storePath,
}); sessionEntry,
sessionEntry = resolvedSessionFile.sessionEntry; agentId,
sessionsDir: path.dirname(storePath),
fallbackSessionFile,
activeSessionKey: sessionKey,
});
sessionEntry = resolvedSessionFile.sessionEntry;
}
if (isNewSession) { if (isNewSession) {
sessionEntry.compactionCount = 0; sessionEntry.compactionCount = 0;
sessionEntry.memoryFlushCompactionCount = undefined; sessionEntry.memoryFlushCompactionCount = undefined;
@@ -519,38 +527,40 @@ export async function initSessionState(params: {
sessionEntry.outputTokens = undefined; sessionEntry.outputTokens = undefined;
sessionEntry.contextTokens = undefined; sessionEntry.contextTokens = undefined;
} }
// Preserve per-session overrides while resetting compaction state on /new. if (!readOnly) {
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; // Preserve per-session overrides while resetting compaction state on /new.
await updateSessionStore( sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
storePath, await updateSessionStore(
(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, storePath,
sessionFile: previousSessionEntry.sessionFile, (store) => {
agentId, // Preserve per-session overrides while resetting compaction state on /new.
reason: "reset", 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 = { const sessionCtx: TemplateContext = {
@@ -571,7 +581,7 @@ export async function initSessionState(params: {
}; };
// Run session plugin hooks (fire-and-forget) // Run session plugin hooks (fire-and-forget)
const hookRunner = getGlobalHookRunner(); const hookRunner = readOnly ? null : getGlobalHookRunner();
if (hookRunner && isNewSession) { if (hookRunner && isNewSession) {
const effectiveSessionId = sessionId ?? ""; const effectiveSessionId = sessionId ?? "";