mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 02:40:18 +00:00
feat: add /btw side-turn MVP
Coauthored with Nova. Co-authored-by: Nova <nova@openknot.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<OpenClawConfig["agents"]>["defaults"];
|
||||
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: {
|
||||
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,8 +462,17 @@ export async function runPreparedReply(
|
||||
defaultModel,
|
||||
});
|
||||
}
|
||||
const sessionIdFinal = sessionId ?? crypto.randomUUID();
|
||||
const sessionFile = resolveSessionFilePath(
|
||||
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 }),
|
||||
@@ -436,28 +483,32 @@ export async function runPreparedReply(
|
||||
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" ||
|
||||
!ephemeralSideTurn &&
|
||||
(resolvedQueue.mode === "followup" ||
|
||||
resolvedQueue.mode === "collect" ||
|
||||
resolvedQueue.mode === "steer-backlog";
|
||||
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,7 +586,8 @@ export async function runPreparedReply(
|
||||
},
|
||||
};
|
||||
|
||||
return runReplyAgent({
|
||||
try {
|
||||
return await runReplyAgent({
|
||||
commandBody: prefixedCommandBody,
|
||||
followupRun,
|
||||
queueKey,
|
||||
@@ -546,18 +599,23 @@ export async function runPreparedReply(
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionStore: persistedSessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
storePath: persistedStorePath,
|
||||
defaultModel,
|
||||
agentCfgContextTokens: agentCfg?.contextTokens,
|
||||
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
||||
isNewSession,
|
||||
blockStreamingEnabled,
|
||||
blockStreamingEnabled: ephemeralSideTurn ? false : blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
sessionCtx,
|
||||
shouldInjectGroupIntro,
|
||||
typingMode,
|
||||
});
|
||||
} finally {
|
||||
if (sideTurnSession) {
|
||||
await fs.rm(sideTurnSession.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <question>" };
|
||||
}
|
||||
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 } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export type FollowupRun = {
|
||||
};
|
||||
timeoutMs: number;
|
||||
blockReplyBreak: "text_end" | "message_end";
|
||||
disableTools?: boolean;
|
||||
ownerNumbers?: string[];
|
||||
inputProvenance?: InputProvenance;
|
||||
extraSystemPrompt?: string;
|
||||
|
||||
@@ -170,8 +170,10 @@ export async function initSessionState(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: OpenClawConfig;
|
||||
commandAuthorized: boolean;
|
||||
readOnly?: boolean;
|
||||
}): Promise<SessionInitResult> {
|
||||
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,6 +498,11 @@ export async function initSessionState(params: {
|
||||
const fallbackSessionFile = !sessionEntry.sessionFile
|
||||
? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId)
|
||||
: undefined;
|
||||
if (readOnly) {
|
||||
if (!sessionEntry.sessionFile && fallbackSessionFile) {
|
||||
sessionEntry.sessionFile = fallbackSessionFile;
|
||||
}
|
||||
} else {
|
||||
const resolvedSessionFile = await resolveAndPersistSessionFile({
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
@@ -508,6 +515,7 @@ export async function initSessionState(params: {
|
||||
activeSessionKey: sessionKey,
|
||||
});
|
||||
sessionEntry = resolvedSessionFile.sessionEntry;
|
||||
}
|
||||
if (isNewSession) {
|
||||
sessionEntry.compactionCount = 0;
|
||||
sessionEntry.memoryFlushCompactionCount = undefined;
|
||||
@@ -519,6 +527,7 @@ export async function initSessionState(params: {
|
||||
sessionEntry.outputTokens = undefined;
|
||||
sessionEntry.contextTokens = undefined;
|
||||
}
|
||||
if (!readOnly) {
|
||||
// Preserve per-session overrides while resetting compaction state on /new.
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
|
||||
await updateSessionStore(
|
||||
@@ -552,6 +561,7 @@ export async function initSessionState(params: {
|
||||
reason: "reset",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
@@ -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 ?? "";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user