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",
|
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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ?? "";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user