mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
Allow busy TUI sessions to forward prompts into the configured queue while keeping queued-turn admission, cancellation, restart, and transcript ownership consistent across the TUI, Gateway, and followup queue. Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
5527 lines
176 KiB
TypeScript
5527 lines
176 KiB
TypeScript
// Tests follow-up runner delivery, transcript persistence, and no-reply contracts.
|
|
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { setCliSessionBinding } from "../../agents/cli-session.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { SessionEntry } from "../../config/sessions/types.js";
|
|
import {
|
|
createUserTurnTranscriptRecorder,
|
|
type PersistedUserTurnMessage,
|
|
} from "../../sessions/user-turn-transcript.js";
|
|
import type { FollowupRun, QueueSettings } from "./queue.js";
|
|
|
|
const runEmbeddedAgentMock = vi.fn();
|
|
const runCliAgentMock = vi.fn();
|
|
const runWithModelFallbackMock = vi.fn();
|
|
const compactEmbeddedAgentSessionMock = vi.fn();
|
|
const routeReplyMock = vi.fn();
|
|
const isRoutableChannelMock = vi.fn();
|
|
const runReplyPayloadSendingHookMock = vi.fn();
|
|
const runPreflightCompactionIfNeededMock = vi.fn();
|
|
const resolveCommandSecretRefsViaGatewayMock = vi.fn();
|
|
const resolveQueuedReplyExecutionConfigMock = vi.fn();
|
|
const resolveProviderFollowupFallbackRouteMock = vi.fn();
|
|
let resolveQueuedReplyExecutionConfigActual:
|
|
| (typeof import("./agent-runner-utils.js"))["resolveQueuedReplyExecutionConfig"]
|
|
| undefined;
|
|
let createFollowupRunner: typeof import("./followup-runner.js").createFollowupRunner;
|
|
let clearRuntimeConfigSnapshot: typeof import("../../config/config.js").clearRuntimeConfigSnapshot;
|
|
let loadSessionStore: typeof import("../../config/sessions/store.js").loadSessionStore;
|
|
let saveSessionStore: typeof import("../../config/sessions/store.js").saveSessionStore;
|
|
let clearSessionStoreCacheForTest: typeof import("../../config/sessions/store.js").clearSessionStoreCacheForTest;
|
|
let clearFollowupQueue: typeof import("./queue.js").clearFollowupQueue;
|
|
let enqueueFollowupRun: typeof import("./queue.js").enqueueFollowupRun;
|
|
let sessionRunAccounting: typeof import("./session-run-accounting.js");
|
|
let setRuntimeConfigSnapshot: typeof import("../../config/config.js").setRuntimeConfigSnapshot;
|
|
let createMockFollowupRun: typeof import("./test-helpers.js").createMockFollowupRun;
|
|
let createMockTypingController: typeof import("./test-helpers.js").createMockTypingController;
|
|
let createReplyOperationForTest: typeof import("./reply-run-registry.js").createReplyOperation;
|
|
let abortActiveReplyRunsForTest: typeof import("./reply-run-registry.js").abortActiveReplyRuns;
|
|
let replyRunRegistryForTest: typeof import("./reply-run-registry.js").replyRunRegistry;
|
|
let replyRunTestingForTest: typeof import("./reply-run-registry.js").testing;
|
|
let cliBackendsTestingForTest: typeof import("../../agents/cli-backends.js").testing;
|
|
let setReplyPayloadMetadataForTest: typeof import("../reply-payload.js").setReplyPayloadMetadata;
|
|
const FOLLOWUP_DEBUG = process.env.OPENCLAW_DEBUG_FOLLOWUP_RUNNER_TEST === "1";
|
|
const FOLLOWUP_TEST_QUEUES = new Map<
|
|
string,
|
|
{
|
|
items: FollowupRun[];
|
|
lastRun?: FollowupRun["run"];
|
|
}
|
|
>();
|
|
const FOLLOWUP_TEST_SESSION_STORES = new Map<string, Record<string, SessionEntry>>();
|
|
const FOLLOWUP_TEST_SESSION_STORE_PATHS = new Set<string>();
|
|
|
|
function debugFollowupTest(message: string): void {
|
|
if (!FOLLOWUP_DEBUG) {
|
|
return;
|
|
}
|
|
process.stderr.write(`[followup-runner.test] ${message}\n`);
|
|
}
|
|
|
|
function joinPromptSections(...sections: Array<string | undefined>): string {
|
|
const promptSections: string[] = [];
|
|
for (const section of sections) {
|
|
if (section) {
|
|
promptSections.push(section);
|
|
}
|
|
}
|
|
return promptSections.join("\n\n");
|
|
}
|
|
|
|
function createTestUserTurnRecorder(message: PersistedUserTurnMessage) {
|
|
return createUserTurnTranscriptRecorder({
|
|
message,
|
|
target: { transcriptPath: "/tmp/session.jsonl" },
|
|
updateMode: "none",
|
|
});
|
|
}
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`expected ${label} to be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function requireMockCallArg(
|
|
mock: { mock: { calls: unknown[][] } },
|
|
index: number,
|
|
): Record<string, unknown> {
|
|
const call = mock.mock.calls[index];
|
|
if (!call) {
|
|
throw new Error(`expected mock call ${index}`);
|
|
}
|
|
return requireRecord(call[0], `mock call ${index} arg`);
|
|
}
|
|
|
|
function requireLastMockCallArg(
|
|
mock: { mock: { calls: unknown[][] } },
|
|
label: string,
|
|
): Record<string, unknown> {
|
|
const calls = mock.mock.calls;
|
|
const call = calls[calls.length - 1];
|
|
if (!call) {
|
|
throw new Error(`expected ${label} mock call`);
|
|
}
|
|
return requireRecord(call[0], `${label} mock call arg`);
|
|
}
|
|
|
|
function expectBlockReplyText(onBlockReply: { mock: { calls: unknown[][] } }, text: string): void {
|
|
expect(
|
|
onBlockReply.mock.calls.some(
|
|
(call) => requireRecord(call[0], "block reply payload").text === text,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
|
|
function expectNoBlockReplyText(
|
|
onBlockReply: { mock: { calls: unknown[][] } },
|
|
text: string,
|
|
): void {
|
|
expect(
|
|
onBlockReply.mock.calls.some(
|
|
(call) => requireRecord(call[0], "block reply payload").text === text,
|
|
),
|
|
).toBe(false);
|
|
}
|
|
|
|
function expectNoBlockReplyTextIncludes(
|
|
onBlockReply: { mock: { calls: unknown[][] } },
|
|
fragment: string,
|
|
): void {
|
|
expect(
|
|
onBlockReply.mock.calls.some((call) =>
|
|
String(requireRecord(call[0], "block reply payload").text).includes(fragment),
|
|
),
|
|
).toBe(false);
|
|
}
|
|
|
|
function registerFollowupTestSessionStore(
|
|
storePath: string,
|
|
sessionStore: Record<string, SessionEntry>,
|
|
): void {
|
|
fsSync.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
fsSync.writeFileSync(storePath, JSON.stringify(sessionStore));
|
|
FOLLOWUP_TEST_SESSION_STORES.set(storePath, sessionStore);
|
|
FOLLOWUP_TEST_SESSION_STORE_PATHS.add(storePath);
|
|
}
|
|
|
|
async function incrementRunCompactionCountForFollowupTest(
|
|
params: Parameters<typeof import("./session-run-accounting.js").incrementRunCompactionCount>[0],
|
|
): Promise<number | undefined> {
|
|
const {
|
|
sessionStore,
|
|
sessionKey,
|
|
sessionEntry,
|
|
amount = 1,
|
|
newSessionId,
|
|
lastCallUsage,
|
|
} = params;
|
|
if (!sessionStore || !sessionKey) {
|
|
return undefined;
|
|
}
|
|
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
|
if (!entry) {
|
|
return undefined;
|
|
}
|
|
|
|
const nextCount = Math.max(0, entry.compactionCount ?? 0) + Math.max(0, amount);
|
|
const nextEntry: SessionEntry = {
|
|
...entry,
|
|
compactionCount: nextCount,
|
|
updatedAt: Date.now(),
|
|
};
|
|
if (newSessionId && newSessionId !== entry.sessionId) {
|
|
nextEntry.sessionId = newSessionId;
|
|
if (entry.sessionFile?.trim()) {
|
|
nextEntry.sessionFile = path.join(path.dirname(entry.sessionFile), `${newSessionId}.jsonl`);
|
|
}
|
|
}
|
|
const promptTokens =
|
|
(lastCallUsage?.input ?? 0) +
|
|
(lastCallUsage?.cacheRead ?? 0) +
|
|
(lastCallUsage?.cacheWrite ?? 0);
|
|
if (promptTokens > 0) {
|
|
nextEntry.totalTokens = promptTokens;
|
|
nextEntry.totalTokensFresh = true;
|
|
nextEntry.inputTokens = undefined;
|
|
nextEntry.outputTokens = undefined;
|
|
nextEntry.cacheRead = undefined;
|
|
nextEntry.cacheWrite = undefined;
|
|
}
|
|
|
|
sessionStore[sessionKey] = nextEntry;
|
|
if (sessionEntry) {
|
|
Object.assign(sessionEntry, nextEntry);
|
|
}
|
|
return nextCount;
|
|
}
|
|
|
|
function getFollowupTestQueue(key: string): {
|
|
items: FollowupRun[];
|
|
lastRun?: FollowupRun["run"];
|
|
} {
|
|
const cleaned = key.trim();
|
|
const existing = FOLLOWUP_TEST_QUEUES.get(cleaned);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const created = {
|
|
items: [] as FollowupRun[],
|
|
lastRun: undefined as FollowupRun["run"] | undefined,
|
|
};
|
|
FOLLOWUP_TEST_QUEUES.set(cleaned, created);
|
|
return created;
|
|
}
|
|
|
|
function clearFollowupQueueForFollowupTest(key: string): number {
|
|
const cleaned = key.trim();
|
|
const queue = FOLLOWUP_TEST_QUEUES.get(cleaned);
|
|
if (!queue) {
|
|
return 0;
|
|
}
|
|
const cleared = queue.items.length;
|
|
FOLLOWUP_TEST_QUEUES.delete(cleaned);
|
|
return cleared;
|
|
}
|
|
|
|
function enqueueFollowupRunForFollowupTest(key: string, run: FollowupRun): boolean {
|
|
const queue = getFollowupTestQueue(key);
|
|
queue.items.push(run);
|
|
queue.lastRun = run.run;
|
|
return true;
|
|
}
|
|
|
|
function refreshQueuedFollowupSessionForFollowupTest(params: {
|
|
key: string;
|
|
previousSessionId?: string;
|
|
nextSessionId?: string;
|
|
nextSessionFile?: string;
|
|
nextProvider?: string;
|
|
nextModel?: string;
|
|
nextAuthProfileId?: string;
|
|
nextAuthProfileIdSource?: "auto" | "user";
|
|
}): void {
|
|
const cleaned = params.key.trim();
|
|
if (!cleaned) {
|
|
return;
|
|
}
|
|
const queue = FOLLOWUP_TEST_QUEUES.get(cleaned);
|
|
if (!queue) {
|
|
return;
|
|
}
|
|
const shouldRewriteSession =
|
|
Boolean(params.previousSessionId) &&
|
|
Boolean(params.nextSessionId) &&
|
|
params.previousSessionId !== params.nextSessionId;
|
|
const shouldRewriteSelection =
|
|
typeof params.nextProvider === "string" ||
|
|
typeof params.nextModel === "string" ||
|
|
Object.hasOwn(params, "nextAuthProfileId") ||
|
|
Object.hasOwn(params, "nextAuthProfileIdSource");
|
|
if (!shouldRewriteSession && !shouldRewriteSelection) {
|
|
return;
|
|
}
|
|
const rewrite = (run?: FollowupRun["run"]) => {
|
|
if (!run) {
|
|
return;
|
|
}
|
|
if (shouldRewriteSession && run.sessionId === params.previousSessionId) {
|
|
run.sessionId = params.nextSessionId!;
|
|
if (params.nextSessionFile?.trim()) {
|
|
run.sessionFile = params.nextSessionFile;
|
|
}
|
|
}
|
|
if (shouldRewriteSelection) {
|
|
if (typeof params.nextProvider === "string") {
|
|
run.provider = params.nextProvider;
|
|
}
|
|
if (typeof params.nextModel === "string") {
|
|
run.model = params.nextModel;
|
|
}
|
|
if (Object.hasOwn(params, "nextAuthProfileId")) {
|
|
run.authProfileId = params.nextAuthProfileId?.trim() || undefined;
|
|
}
|
|
if (Object.hasOwn(params, "nextAuthProfileIdSource")) {
|
|
run.authProfileIdSource = run.authProfileId ? params.nextAuthProfileIdSource : undefined;
|
|
}
|
|
}
|
|
};
|
|
rewrite(queue.lastRun);
|
|
for (const item of queue.items) {
|
|
rewrite(item.run);
|
|
}
|
|
}
|
|
|
|
async function persistRunSessionUsageForFollowupTest(
|
|
params: Parameters<typeof import("./session-run-accounting.js").persistRunSessionUsage>[0],
|
|
): Promise<void> {
|
|
const { storePath, sessionKey } = params;
|
|
if (!storePath || !sessionKey) {
|
|
return;
|
|
}
|
|
const registeredStore = FOLLOWUP_TEST_SESSION_STORES.get(storePath);
|
|
const store = registeredStore ?? loadSessionStore(storePath, { skipCache: true });
|
|
const entry = store[sessionKey];
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
const preserveSessionModelState =
|
|
params.isHeartbeat === true ||
|
|
params.preserveRuntimeModel === true ||
|
|
params.preserveUserFacingSessionModelState === true;
|
|
const preserveUserFacingRunState = params.preserveUserFacingSessionModelState === true;
|
|
const nextEntry: SessionEntry = {
|
|
...entry,
|
|
updatedAt: Date.now(),
|
|
modelProvider: preserveSessionModelState
|
|
? entry.modelProvider
|
|
: (params.providerUsed ?? entry.modelProvider),
|
|
model: preserveSessionModelState ? entry.model : (params.modelUsed ?? entry.model),
|
|
contextTokens: preserveSessionModelState
|
|
? entry.contextTokens
|
|
: (params.contextTokensUsed ?? entry.contextTokens),
|
|
systemPromptReport: preserveUserFacingRunState
|
|
? entry.systemPromptReport
|
|
: (params.systemPromptReport ?? entry.systemPromptReport),
|
|
};
|
|
if (params.usage && !preserveUserFacingRunState) {
|
|
nextEntry.inputTokens = params.usage.input ?? 0;
|
|
nextEntry.outputTokens = params.usage.output ?? 0;
|
|
const cacheUsage = params.lastCallUsage ?? params.usage;
|
|
nextEntry.cacheRead = cacheUsage?.cacheRead ?? 0;
|
|
nextEntry.cacheWrite = cacheUsage?.cacheWrite ?? 0;
|
|
}
|
|
if (!preserveUserFacingRunState) {
|
|
const promptTokens =
|
|
params.promptTokens ??
|
|
(params.lastCallUsage?.input ?? params.usage?.input ?? 0) +
|
|
(params.lastCallUsage?.cacheRead ?? params.usage?.cacheRead ?? 0) +
|
|
(params.lastCallUsage?.cacheWrite ?? params.usage?.cacheWrite ?? 0);
|
|
nextEntry.totalTokens = promptTokens > 0 ? promptTokens : undefined;
|
|
nextEntry.totalTokensFresh = promptTokens > 0;
|
|
}
|
|
if (params.cliSessionBinding && params.providerUsed && !preserveUserFacingRunState) {
|
|
setCliSessionBinding(nextEntry, params.providerUsed, params.cliSessionBinding);
|
|
}
|
|
store[sessionKey] = nextEntry;
|
|
if (registeredStore) {
|
|
return;
|
|
}
|
|
await saveSessionStore(storePath, store);
|
|
}
|
|
|
|
async function loadFreshFollowupRunnerModuleForTest() {
|
|
vi.resetModules();
|
|
vi.doUnmock("../../config/config.js");
|
|
vi.doMock("../../agents/model-fallback.js", () => ({
|
|
runWithModelFallback: (params: unknown) => runWithModelFallbackMock(params),
|
|
}));
|
|
vi.doMock("../../agents/session-write-lock.js", () => ({
|
|
acquireSessionWriteLock: vi.fn(async () => ({
|
|
release: async () => {},
|
|
})),
|
|
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 1),
|
|
}));
|
|
vi.doMock("../../agents/embedded-agent.js", () => ({
|
|
abortEmbeddedAgentRun: vi.fn(async () => false),
|
|
compactEmbeddedAgentSession: (params: unknown) => compactEmbeddedAgentSessionMock(params),
|
|
isEmbeddedAgentRunActive: vi.fn(() => false),
|
|
isEmbeddedAgentRunStreaming: vi.fn(() => false),
|
|
queueEmbeddedAgentMessage: vi.fn(async () => undefined),
|
|
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
|
runEmbeddedAgent: (params: unknown) => runEmbeddedAgentMock(params),
|
|
waitForEmbeddedAgentRunEnd: vi.fn(async () => undefined),
|
|
}));
|
|
vi.doMock("../../agents/cli-runner.js", () => ({
|
|
runCliAgent: (params: unknown) => runCliAgentMock(params),
|
|
}));
|
|
vi.doMock("./queue.js", () => ({
|
|
clearFollowupQueue: clearFollowupQueueForFollowupTest,
|
|
completeFollowupRunLifecycle: (run: Pick<FollowupRun, "queuedLifecycle">) =>
|
|
run.queuedLifecycle?.onComplete?.(),
|
|
enqueueFollowupRun: enqueueFollowupRunForFollowupTest,
|
|
isFollowupRunAborted: (run: Pick<FollowupRun, "abortSignal" | "queueAbortSignal">) =>
|
|
run.abortSignal?.aborted === true || run.queueAbortSignal?.aborted === true,
|
|
refreshQueuedFollowupSession: refreshQueuedFollowupSessionForFollowupTest,
|
|
}));
|
|
vi.doMock("./session-run-accounting.js", () => ({
|
|
persistRunSessionUsage: persistRunSessionUsageForFollowupTest,
|
|
incrementRunCompactionCount: incrementRunCompactionCountForFollowupTest,
|
|
}));
|
|
vi.doMock("./agent-runner-memory.js", () => ({
|
|
runMemoryFlushIfNeeded: async (params: { sessionEntry?: SessionEntry }) => params.sessionEntry,
|
|
runPreflightCompactionIfNeeded: (...args: unknown[]) =>
|
|
runPreflightCompactionIfNeededMock(...args),
|
|
}));
|
|
vi.doMock("./route-reply.js", () => ({
|
|
isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args),
|
|
routeReply: (...args: unknown[]) => routeReplyMock(...args),
|
|
}));
|
|
vi.doMock("./reply-payload-sending-hook.js", () => ({
|
|
runReplyPayloadSendingHook: (...args: unknown[]) => runReplyPayloadSendingHookMock(...args),
|
|
}));
|
|
vi.doMock("../../plugins/provider-runtime.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../../plugins/provider-runtime.js")>(
|
|
"../../plugins/provider-runtime.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
resolveProviderFollowupFallbackRoute: (...args: unknown[]) =>
|
|
resolveProviderFollowupFallbackRouteMock(...args),
|
|
};
|
|
});
|
|
vi.doMock("./agent-runner-utils.js", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("./agent-runner-utils.js")>("./agent-runner-utils.js");
|
|
resolveQueuedReplyExecutionConfigActual = actual.resolveQueuedReplyExecutionConfig;
|
|
resolveQueuedReplyExecutionConfigMock.mockImplementation(
|
|
async (...args: Parameters<typeof actual.resolveQueuedReplyExecutionConfig>) =>
|
|
await actual.resolveQueuedReplyExecutionConfig(...args),
|
|
);
|
|
return {
|
|
...actual,
|
|
resolveQueuedReplyExecutionConfig: (
|
|
...args: Parameters<typeof actual.resolveQueuedReplyExecutionConfig>
|
|
) => resolveQueuedReplyExecutionConfigMock(...args),
|
|
};
|
|
});
|
|
vi.doMock("../../cli/command-secret-gateway.js", () => ({
|
|
resolveCommandSecretRefsViaGateway: (...args: unknown[]) =>
|
|
resolveCommandSecretRefsViaGatewayMock(...args),
|
|
}));
|
|
vi.doMock("../../cli/command-secret-targets.js", () => ({
|
|
getAgentRuntimeCommandSecretTargetIds: () => new Set(["skills.entries."]),
|
|
getScopedChannelsCommandSecretTargets: ({
|
|
channel,
|
|
accountId,
|
|
}: {
|
|
channel?: string;
|
|
accountId?: string;
|
|
}) => {
|
|
const normalizedChannel = channel?.trim() ?? "";
|
|
if (!normalizedChannel) {
|
|
return { targetIds: new Set<string>() };
|
|
}
|
|
const targetIds = new Set<string>([`channels.${normalizedChannel}.token`]);
|
|
const normalizedAccountId = accountId?.trim() ?? "";
|
|
if (!normalizedAccountId) {
|
|
return { targetIds };
|
|
}
|
|
return {
|
|
targetIds,
|
|
allowedPaths: new Set<string>([
|
|
`channels.${normalizedChannel}.token`,
|
|
`channels.${normalizedChannel}.accounts.${normalizedAccountId}.token`,
|
|
]),
|
|
};
|
|
},
|
|
}));
|
|
({ testing: cliBackendsTestingForTest } = await import("../../agents/cli-backends.js"));
|
|
setFastFollowupCliBackendDeps();
|
|
({ createFollowupRunner } = await import("./followup-runner.js"));
|
|
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
|
|
await import("../../config/config.js"));
|
|
({ clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } =
|
|
await import("../../config/sessions/store.js"));
|
|
({ clearFollowupQueue, enqueueFollowupRun } = await import("./queue.js"));
|
|
sessionRunAccounting = await import("./session-run-accounting.js");
|
|
({ createMockFollowupRun, createMockTypingController } = await import("./test-helpers.js"));
|
|
({
|
|
abortActiveReplyRuns: abortActiveReplyRunsForTest,
|
|
createReplyOperation: createReplyOperationForTest,
|
|
replyRunRegistry: replyRunRegistryForTest,
|
|
testing: replyRunTestingForTest,
|
|
} = await import("./reply-run-registry.js"));
|
|
({ setReplyPayloadMetadata: setReplyPayloadMetadataForTest } =
|
|
await import("../reply-payload.js"));
|
|
}
|
|
|
|
function setFastFollowupCliBackendDeps(): void {
|
|
cliBackendsTestingForTest.setDepsForTest({
|
|
resolvePluginSetupRegistry: () => ({
|
|
providers: [],
|
|
cliBackends: [],
|
|
configMigrations: [],
|
|
autoEnableProbes: [],
|
|
diagnostics: [],
|
|
}),
|
|
resolveRuntimeCliBackends: () => [
|
|
{
|
|
id: "claude-cli",
|
|
pluginId: "claude-cli",
|
|
modelProvider: "anthropic",
|
|
config: { command: "claude" },
|
|
bundleMcp: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
const ROUTABLE_TEST_CHANNELS = new Set([
|
|
"telegram",
|
|
"slack",
|
|
"discord",
|
|
"signal",
|
|
"imessage",
|
|
"whatsapp",
|
|
"feishu",
|
|
]);
|
|
|
|
beforeAll(async () => {
|
|
await loadFreshFollowupRunnerModuleForTest();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
setFastFollowupCliBackendDeps();
|
|
replyRunTestingForTest?.resetReplyRunRegistry();
|
|
clearRuntimeConfigSnapshot?.();
|
|
runEmbeddedAgentMock.mockReset();
|
|
runCliAgentMock.mockReset();
|
|
runWithModelFallbackMock.mockReset();
|
|
runWithModelFallbackMock.mockImplementation(
|
|
async (params: {
|
|
provider: string;
|
|
model: string;
|
|
run: (
|
|
provider: string,
|
|
model: string,
|
|
options?: { allowTransientCooldownProbe?: boolean },
|
|
) => Promise<unknown>;
|
|
}) => ({
|
|
result: await params.run(params.provider, params.model),
|
|
provider: params.provider,
|
|
model: params.model,
|
|
}),
|
|
);
|
|
compactEmbeddedAgentSessionMock.mockReset();
|
|
runPreflightCompactionIfNeededMock.mockReset();
|
|
resolveCommandSecretRefsViaGatewayMock.mockReset();
|
|
runReplyPayloadSendingHookMock.mockReset();
|
|
runReplyPayloadSendingHookMock.mockImplementation(
|
|
async (params: { payload: unknown }) => params.payload,
|
|
);
|
|
resolveQueuedReplyExecutionConfigMock.mockReset();
|
|
resolveProviderFollowupFallbackRouteMock.mockReset();
|
|
resolveProviderFollowupFallbackRouteMock.mockReturnValue(undefined);
|
|
const resolveQueuedReplyExecutionConfig = resolveQueuedReplyExecutionConfigActual;
|
|
if (!resolveQueuedReplyExecutionConfig) {
|
|
throw new Error("resolveQueuedReplyExecutionConfig mock not initialized");
|
|
}
|
|
resolveQueuedReplyExecutionConfigMock.mockImplementation(
|
|
async (...args: Parameters<typeof resolveQueuedReplyExecutionConfig>) =>
|
|
await resolveQueuedReplyExecutionConfig(...args),
|
|
);
|
|
runPreflightCompactionIfNeededMock.mockImplementation(
|
|
async (params: { sessionEntry?: SessionEntry }) => params.sessionEntry,
|
|
);
|
|
resolveCommandSecretRefsViaGatewayMock.mockImplementation(async ({ config }) => ({
|
|
resolvedConfig: config,
|
|
diagnostics: [],
|
|
targetStatesByPath: {},
|
|
hadUnresolvedTargets: false,
|
|
}));
|
|
routeReplyMock.mockReset();
|
|
routeReplyMock.mockResolvedValue({ ok: true });
|
|
isRoutableChannelMock.mockReset();
|
|
isRoutableChannelMock.mockImplementation((ch: string | undefined) =>
|
|
Boolean(ch?.trim() && ROUTABLE_TEST_CHANNELS.has(ch.trim().toLowerCase())),
|
|
);
|
|
clearFollowupQueue("main");
|
|
FOLLOWUP_TEST_QUEUES.clear();
|
|
FOLLOWUP_TEST_SESSION_STORES.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cliBackendsTestingForTest?.resetDepsForTest();
|
|
replyRunTestingForTest?.resetReplyRunRegistry();
|
|
clearRuntimeConfigSnapshot?.();
|
|
clearFollowupQueue("main");
|
|
FOLLOWUP_TEST_QUEUES.clear();
|
|
FOLLOWUP_TEST_SESSION_STORES.clear();
|
|
for (const storePath of FOLLOWUP_TEST_SESSION_STORE_PATHS) {
|
|
fsSync.rmSync(storePath, { force: true });
|
|
}
|
|
FOLLOWUP_TEST_SESSION_STORE_PATHS.clear();
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
clearSessionStoreCacheForTest();
|
|
if (!FOLLOWUP_DEBUG) {
|
|
return;
|
|
}
|
|
const processWithDebugHandles = process as NodeJS.Process & {
|
|
_getActiveHandles?: () => unknown[];
|
|
_getActiveRequests?: () => unknown[];
|
|
};
|
|
const handles = processWithDebugHandles["_getActiveHandles"]?.().map(
|
|
(handle) => handle?.constructor?.name ?? typeof handle,
|
|
);
|
|
debugFollowupTest(`active handles: ${JSON.stringify(handles ?? [])}`);
|
|
const requests = processWithDebugHandles["_getActiveRequests"]?.().map(
|
|
(request) => request?.constructor?.name ?? typeof request,
|
|
);
|
|
debugFollowupTest(`active requests: ${JSON.stringify(requests ?? [])}`);
|
|
});
|
|
|
|
const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
|
|
createMockFollowupRun({ run: { messageProvider } });
|
|
|
|
function createQueuedRun(
|
|
overrides: Partial<Omit<FollowupRun, "run">> & { run?: Partial<FollowupRun["run"]> } = {},
|
|
): FollowupRun {
|
|
return createMockFollowupRun(overrides);
|
|
}
|
|
|
|
describe("createFollowupRunner reply-lane admission", () => {
|
|
it("notifies queued owners after admission and before model execution", async () => {
|
|
const events: string[] = [];
|
|
runEmbeddedAgentMock.mockImplementationOnce(async () => {
|
|
events.push("run");
|
|
return { payloads: [], meta: {} };
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
queuedLifecycle: {
|
|
onAdmitted: () => events.push("admitted"),
|
|
onComplete: () => events.push("complete"),
|
|
},
|
|
run: { provider: "anthropic", model: "claude" },
|
|
}),
|
|
);
|
|
|
|
expect(events).toEqual(["admitted", "run", "complete"]);
|
|
});
|
|
|
|
it("passes prepared media user turns to embedded runtime dispatch", async () => {
|
|
const preparedUserTurnMessage = {
|
|
role: "user",
|
|
content: "describe this",
|
|
MediaPath: "/tmp/image.png",
|
|
MediaType: "image/png",
|
|
} as never;
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
userTurnTranscriptRecorder: createTestUserTurnRecorder(preparedUserTurnMessage),
|
|
run: {
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
cwd: "/tmp/task-repo",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).toHaveBeenCalledOnce();
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.cwd).toBe("/tmp/task-repo");
|
|
const recorder = requireRecord(call.userTurnTranscriptRecorder, "embedded user turn recorder");
|
|
expect(recorder.message).toBe(preparedUserTurnMessage);
|
|
});
|
|
|
|
it("runs queued followups with the session id returned by admission", async () => {
|
|
const active = createReplyOperationForTest({
|
|
sessionKey: "main",
|
|
sessionId: "pre-compact-session",
|
|
resetTriggered: false,
|
|
});
|
|
active.setPhase("preflight_compacting");
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: { agentMeta: { provider: "anthropic", model: "claude" } },
|
|
});
|
|
const sessionStore = {
|
|
main: {
|
|
sessionId: "pre-compact-session",
|
|
sessionFile: "/tmp/pre-compact.jsonl",
|
|
updatedAt: Date.now(),
|
|
},
|
|
};
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry: sessionStore.main,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
const pending = runner(
|
|
createQueuedRun({
|
|
run: {
|
|
sessionId: "queued-stale-session",
|
|
sessionKey: "main",
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
},
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
active.updateSessionId("post-compact-session");
|
|
sessionStore.main = {
|
|
sessionId: "post-compact-session",
|
|
sessionFile: "/tmp/post-compact.jsonl",
|
|
updatedAt: Date.now(),
|
|
};
|
|
active.complete();
|
|
await pending;
|
|
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.sessionId).toBe("post-compact-session");
|
|
expect(call.sessionFile).toBe("/tmp/post-compact.jsonl");
|
|
});
|
|
|
|
it("uses an admission session hint while refreshing the queued session file", async () => {
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: { agentMeta: { provider: "anthropic", model: "claude" } },
|
|
});
|
|
const sessionStore = {
|
|
main: {
|
|
sessionId: "rotated-session",
|
|
sessionFile: "/tmp/rotated.jsonl",
|
|
updatedAt: Date.now(),
|
|
},
|
|
};
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry: sessionStore.main,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
admissionSessionId: "rotated-session",
|
|
run: {
|
|
sessionId: "queued-stale-session",
|
|
sessionFile: "/tmp/stale.jsonl",
|
|
sessionKey: "main",
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.sessionId).toBe("rotated-session");
|
|
expect(call.sessionFile).toBe("/tmp/rotated.jsonl");
|
|
});
|
|
|
|
it("registers the admitted session id when the local session store is stale", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
realAgentEvents.resetAgentRunContextForTest();
|
|
const active = createReplyOperationForTest({
|
|
sessionKey: "main",
|
|
sessionId: "pre-compact-session",
|
|
resetTriggered: false,
|
|
});
|
|
active.setPhase("preflight_compacting");
|
|
let observedRunId: string | undefined;
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (params: { runId: string; sessionId?: string }) => {
|
|
observedRunId = params.runId;
|
|
expect(params.sessionId).toBe("post-compact-session");
|
|
return {
|
|
payloads: [],
|
|
meta: { agentMeta: { provider: "anthropic", model: "claude" } },
|
|
};
|
|
},
|
|
);
|
|
const sessionStore = {
|
|
main: {
|
|
sessionId: "pre-compact-session",
|
|
sessionFile: "/tmp/pre-compact.jsonl",
|
|
updatedAt: Date.now(),
|
|
},
|
|
};
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry: sessionStore.main,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
const pending = runner(
|
|
createQueuedRun({
|
|
run: {
|
|
sessionId: "queued-stale-session",
|
|
sessionKey: "main",
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
},
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
active.updateSessionId("post-compact-session");
|
|
active.complete();
|
|
await pending;
|
|
|
|
expect(observedRunId).toBeDefined();
|
|
expect(realAgentEvents.getAgentRunContext(observedRunId ?? "")?.sessionId).toBe(
|
|
"post-compact-session",
|
|
);
|
|
realAgentEvents.resetAgentRunContextForTest();
|
|
});
|
|
|
|
it("routes preflight compaction failures before starting queued followup runs", async () => {
|
|
runPreflightCompactionIfNeededMock.mockRejectedValueOnce(
|
|
new Error("Preflight compaction required but failed: auth profile mismatch"),
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
originatingChatType: "group",
|
|
run: {
|
|
messageProvider: "discord",
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
verboseLevel: "off",
|
|
sessionKey: "main",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(routeReplyMock).toHaveBeenCalledOnce();
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "discord",
|
|
to: "channel:C1",
|
|
accountId: "acct-1",
|
|
threadId: "thread-1",
|
|
payload: expect.objectContaining({
|
|
text: expect.stringContaining("auto-compaction could not recover"),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("suppresses preflight compaction failure notices for queued room events", async () => {
|
|
runPreflightCompactionIfNeededMock.mockRejectedValueOnce(
|
|
new Error("Preflight compaction required but failed: auth profile mismatch"),
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
originatingChatType: "group",
|
|
run: {
|
|
messageProvider: "discord",
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
verboseLevel: "off",
|
|
sessionKey: "main",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves non-compaction preflight failures for queued followup runs", async () => {
|
|
runPreflightCompactionIfNeededMock.mockRejectedValueOnce(new Error("session load failed"));
|
|
const onComplete = vi.fn();
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await expect(
|
|
runner(
|
|
createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
sessionKey: "main",
|
|
},
|
|
queuedLifecycle: { onComplete },
|
|
}),
|
|
),
|
|
).rejects.toThrow("session load failed");
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
async function normalizeComparablePath(filePath: string): Promise<string> {
|
|
const parent = await fs.realpath(path.dirname(filePath)).catch(() => path.dirname(filePath));
|
|
return path.join(parent, path.basename(filePath));
|
|
}
|
|
|
|
function mockCompactionRun(params: {
|
|
willRetry: boolean;
|
|
result: {
|
|
payloads: Array<{ text: string }>;
|
|
meta: Record<string, unknown>;
|
|
};
|
|
}) {
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
|
}) => {
|
|
args.onAgentEvent?.({
|
|
stream: "compaction",
|
|
data: { phase: "end", willRetry: params.willRetry, completed: true },
|
|
});
|
|
return params.result;
|
|
},
|
|
);
|
|
}
|
|
|
|
function createAsyncReplySpy() {
|
|
return vi.fn(async () => {});
|
|
}
|
|
|
|
describe("createFollowupRunner auto fallback primary probes", () => {
|
|
it("clears queued auto fallback pins after a successful primary probe", async () => {
|
|
const sessionKey = "probe-clear";
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session-1",
|
|
updatedAt: Date.now(),
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-5.4",
|
|
modelOverrideSource: "auto",
|
|
modelOverrideFallbackOriginProvider: "anthropic",
|
|
modelOverrideFallbackOriginModel: "claude",
|
|
};
|
|
const sessionStore = { [sessionKey]: sessionEntry };
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: { agentMeta: { provider: "anthropic", model: "claude" } },
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
sessionKey,
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
autoFallbackPrimaryProbe: {
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
fallbackProvider: "openai",
|
|
fallbackModel: "gpt-5.4",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.provider).toBe("anthropic");
|
|
expect(call.model).toBe("claude");
|
|
expect(sessionEntry.providerOverride).toBeUndefined();
|
|
expect(sessionEntry.modelOverride).toBeUndefined();
|
|
expect(sessionEntry.modelOverrideSource).toBeUndefined();
|
|
expect(sessionEntry.modelOverrideFallbackOriginProvider).toBeUndefined();
|
|
expect(sessionEntry.modelOverrideFallbackOriginModel).toBeUndefined();
|
|
});
|
|
|
|
it("rechecks queued probe throttle and keeps fallback auth when probe is not due", async () => {
|
|
const sessionKey = "probe-skip";
|
|
const probe = {
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
fallbackProvider: "openai",
|
|
fallbackModel: "gpt-5.4",
|
|
fallbackAuthProfileId: "openai:fallback",
|
|
fallbackAuthProfileIdSource: "auto" as const,
|
|
};
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session-1",
|
|
updatedAt: Date.now(),
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-5.4",
|
|
modelOverrideSource: "auto",
|
|
modelOverrideFallbackOriginProvider: "anthropic",
|
|
modelOverrideFallbackOriginModel: "claude",
|
|
authProfileOverride: "openai:fallback",
|
|
authProfileOverrideSource: "auto",
|
|
};
|
|
const sessionStore = { [sessionKey]: sessionEntry };
|
|
const { markAutoFallbackPrimaryProbe } = await import("../../agents/agent-scope.js");
|
|
markAutoFallbackPrimaryProbe({ probe, sessionKey });
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: { agentMeta: { provider: "openai", model: "gpt-5.4" } },
|
|
});
|
|
runPreflightCompactionIfNeededMock.mockImplementationOnce(
|
|
async (params: { followupRun: FollowupRun; sessionEntry?: SessionEntry }) => {
|
|
expect(params.followupRun.run.provider).toBe("openai");
|
|
expect(params.followupRun.run.model).toBe("gpt-5.4");
|
|
expect(params.followupRun.run.autoFallbackPrimaryProbe).toBeUndefined();
|
|
return params.sessionEntry;
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
sessionKey,
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
authProfileId: "anthropic:primary",
|
|
authProfileIdSource: "auto",
|
|
autoFallbackPrimaryProbe: probe,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.provider).toBe("openai");
|
|
expect(call.model).toBe("gpt-5.4");
|
|
expect(call.authProfileId).toBe("openai:fallback");
|
|
expect(call.authProfileIdSource).toBe("auto");
|
|
expect(sessionEntry.providerOverride).toBe("openai");
|
|
expect(sessionEntry.modelOverride).toBe("gpt-5.4");
|
|
expect(sessionEntry.modelOverrideSource).toBe("auto");
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner runtime config", () => {
|
|
it("routes queued followups through CLI runtime dispatch when the model selects a CLI backend", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session-cli-followup",
|
|
updatedAt: Date.now(),
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: "cli-session-1",
|
|
},
|
|
},
|
|
};
|
|
const sessionStore = { main: sessionEntry };
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "telegram:-100123:topic:42",
|
|
originatingThreadId: "42",
|
|
originatingReplyToId: "reply-42",
|
|
messageId: "queued-message-1",
|
|
run: {
|
|
config: runtimeConfig,
|
|
sessionId: "session-cli-followup",
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
messageProvider: "telegram",
|
|
senderId: "sender-42",
|
|
senderIsOwner: true,
|
|
cwd: "/tmp/task-repo",
|
|
inputProvenance: {
|
|
kind: "internal_system",
|
|
sourceChannel: "telegram",
|
|
sourceTool: "restart-sentinel",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
|
const call = requireLastMockCallArg(runCliAgentMock, "run cli agent");
|
|
expect(call.provider).toBe("claude-cli");
|
|
expect(call.model).toBe("claude-opus-4-7");
|
|
expect(call.config).toBe(runtimeConfig);
|
|
expect(call.cliSessionId).toBe("cli-session-1");
|
|
expect(call.messageChannel).toBe("telegram");
|
|
expect(call.currentChannelId).toBe("telegram:-100123:topic:42");
|
|
expect(call.currentThreadTs).toBe("42");
|
|
expect(call.currentMessageId).toBe("reply-42");
|
|
expect(call.senderId).toBe("sender-42");
|
|
expect(call.senderIsOwner).toBe(true);
|
|
expect(call).toMatchObject({
|
|
sessionId: "session-cli-followup",
|
|
sessionKey: "main",
|
|
agentId: "agent",
|
|
workspaceDir: "/tmp",
|
|
cwd: "/tmp/task-repo",
|
|
config: runtimeConfig,
|
|
suppressNextUserMessagePersistence: false,
|
|
});
|
|
expect(call.onUserMessagePersisted).toEqual(expect.any(Function));
|
|
});
|
|
|
|
it("reuses CLI session bindings for queued room-event followups", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session-cli-room-event",
|
|
updatedAt: Date.now(),
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: "cli-session-1",
|
|
},
|
|
},
|
|
};
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
cliSessionBinding: {
|
|
sessionId: "cli-session-1",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore: { main: sessionEntry },
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
currentInboundAudio: true,
|
|
currentInboundContext: { text: "[OpenClaw room event]" },
|
|
run: {
|
|
config: runtimeConfig,
|
|
sessionId: "session-cli-room-event",
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
suppressNextUserMessagePersistence: true,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
allowEmptyAssistantReplyAsSilent: true,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(runCliAgentMock).toHaveBeenCalledOnce();
|
|
const call = requireLastMockCallArg(runCliAgentMock, "run cli agent");
|
|
expect(call.currentInboundEventKind).toBe("room_event");
|
|
expect(call.persistAssistantTranscript).toBe(false);
|
|
expect(call.currentInboundAudio).toBe(true);
|
|
expect(call.suppressNextUserMessagePersistence).toBe(true);
|
|
expect(call.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(call.allowEmptyAssistantReplyAsSilent).toBe(true);
|
|
expect(call.cliSessionId).toBe("cli-session-1");
|
|
expect(call.cliSessionBinding).toEqual({ sessionId: "cli-session-1" });
|
|
});
|
|
|
|
it("stores queued room-event CLI sessions created from the first ambient run", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const storePath = "/tmp/openclaw-followup-room-event-cli.json";
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session-cli-room-event",
|
|
updatedAt: Date.now(),
|
|
};
|
|
const sessionStore = { main: sessionEntry };
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
sessionId: "cli-session-1",
|
|
cliSessionBinding: {
|
|
sessionId: "cli-session-1",
|
|
authProfileId: "profile",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
currentInboundContext: { text: "[OpenClaw room event]" },
|
|
run: {
|
|
config: runtimeConfig,
|
|
sessionId: "session-cli-room-event",
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
suppressNextUserMessagePersistence: true,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(runCliAgentMock).toHaveBeenCalledOnce();
|
|
const call = requireLastMockCallArg(runCliAgentMock, "run cli agent");
|
|
expect(call.currentInboundEventKind).toBe("room_event");
|
|
expect(call.cliSessionId).toBeUndefined();
|
|
expect(sessionStore.main.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "cli-session-1",
|
|
authProfileId: "profile",
|
|
});
|
|
});
|
|
|
|
it("does not replace queued room-event CLI session bindings when reuse fails", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session-cli-room-event",
|
|
updatedAt: Date.now(),
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: "cli-session-1",
|
|
},
|
|
},
|
|
};
|
|
const sessionStore = { main: sessionEntry };
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
sessionId: "transient-cli-session",
|
|
cliSessionBinding: {
|
|
sessionId: "transient-cli-session",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
currentInboundContext: { text: "[OpenClaw room event]" },
|
|
run: {
|
|
config: runtimeConfig,
|
|
sessionId: "session-cli-room-event",
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
suppressNextUserMessagePersistence: true,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(runCliAgentMock).toHaveBeenCalledOnce();
|
|
const call = requireLastMockCallArg(runCliAgentMock, "run cli agent");
|
|
expect(call.currentInboundEventKind).toBe("room_event");
|
|
expect(call.cliSessionId).toBe("cli-session-1");
|
|
expect(call.cliSessionBinding).toEqual({ sessionId: "cli-session-1" });
|
|
expect(sessionStore.main.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
});
|
|
|
|
it("passes prepared media user turns to CLI runtime dispatch", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const preparedUserTurnMessage = {
|
|
role: "user",
|
|
content: "describe this",
|
|
MediaPath: "/tmp/image.png",
|
|
MediaType: "image/png",
|
|
} as never;
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "done" }],
|
|
meta: {},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
storePath: "/tmp/sessions.json",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
userTurnTranscriptRecorder: createTestUserTurnRecorder(preparedUserTurnMessage),
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runCliAgentMock).toHaveBeenCalledOnce();
|
|
const mediaCall = requireLastMockCallArg(runCliAgentMock, "run cli agent");
|
|
expect(mediaCall.persistAssistantTranscript).toBe(true);
|
|
expect(mediaCall.storePath).toBe("/tmp/sessions.json");
|
|
const recorder = requireRecord(mediaCall.userTurnTranscriptRecorder, "cli user turn recorder");
|
|
expect(recorder.message).toBe(preparedUserTurnMessage);
|
|
});
|
|
|
|
it("disables routed delivery mirrors for CLI-owned followup payloads", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [
|
|
setReplyPayloadMetadataForTest(
|
|
{ text: "persisted CLI followup" },
|
|
{ assistantTranscriptOwned: true },
|
|
),
|
|
],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "telegram:-100123",
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runCliAgentMock).toHaveBeenCalledOnce();
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payload: { text: "persisted CLI followup" },
|
|
mirror: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not deliver durable reasoning for a queued CLI followup when reasoning payloads are disabled", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [
|
|
{ text: "internal reasoning", isReasoning: true },
|
|
{ text: "final answer" },
|
|
],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
opts: { reasoningPayloadsEnabled: false },
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "telegram:-100123",
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payload: { text: "final answer" },
|
|
}),
|
|
);
|
|
expect(
|
|
routeReplyMock.mock.calls.some((call) => {
|
|
const payload = requireRecord(
|
|
requireRecord(call[0], "route reply params").payload,
|
|
"payload",
|
|
);
|
|
return payload.isReasoning === true;
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
// Resolver-level gate, not an end-to-end delivery proof: route-reply.js is
|
|
// mocked above (routeReplyMock), but resolveFollowupDeliveryPayloads is the
|
|
// REAL implementation, so this still proves the gate itself fires correctly
|
|
// for a queued CLI followup — the runner only forwards a reasoning payload
|
|
// to routing when opts.reasoningPayloadsEnabled is true. Whether the
|
|
// real routeReply then delivers it is a separate, pre-existing question:
|
|
// routeReply unconditionally suppresses isReasoning payloads on the
|
|
// origin-routing branch (route-reply.ts:131, shouldSuppressReasoningPayload,
|
|
// predates this change, shared with the embedded runner) and that
|
|
// suppression is intentionally out of scope here — see route-reply.test.ts.
|
|
it("passes the durable reasoning payload through to routing for a queued CLI followup when reasoning payloads are enabled", async () => {
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
runCliAgentMock.mockResolvedValueOnce({
|
|
payloads: [
|
|
{ text: "internal reasoning", isReasoning: true },
|
|
{ text: "final answer" },
|
|
],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
opts: { reasoningPayloadsEnabled: true },
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "telegram:-100123",
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Proves the resolver kept the reasoning payload (it survived
|
|
// resolveFollowupDeliveryPayloads) and the runner routed it — not that a
|
|
// real channel received it (routeReply is mocked; see comment above).
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payload: { text: "internal reasoning", isReasoning: true },
|
|
}),
|
|
);
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payload: { text: "final answer" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps queued CLI tool progress quiet when verbose progress is disabled", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const onToolStart = vi.fn(async () => {});
|
|
runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId,
|
|
stream: "tool",
|
|
data: { phase: "start", name: "web_search", args: { query: "hidden" } },
|
|
});
|
|
return {
|
|
payloads: [{ text: "final" }],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onToolStart },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
messageProvider: "telegram",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "off",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("bridges queued CLI inter-tool commentary into onItemEvent for live preview", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const onItemEvent = vi.fn(async () => {});
|
|
runCliAgentMock.mockImplementationOnce(
|
|
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
|
expect(params.emitCommentaryText).toBe(true);
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId,
|
|
stream: "item",
|
|
data: {
|
|
kind: "preamble",
|
|
itemId: "commentary-1",
|
|
progressText: "Let me check the files.",
|
|
},
|
|
});
|
|
return {
|
|
payloads: [{ text: "final" }],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onItemEvent, commentaryProgressEnabled: true },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
messageProvider: "telegram",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "off",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onItemEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: "preamble",
|
|
progressText: "Let me check the files.",
|
|
itemId: "commentary-1",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("defers queued CLI attempt terminal lifecycle events until fallback settles", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const lifecyclePhases: string[] = [];
|
|
const unsubscribe = realAgentEvents.onAgentEvent((evt) => {
|
|
if (evt.stream !== "lifecycle") {
|
|
return;
|
|
}
|
|
const phase = typeof evt.data.phase === "string" ? evt.data.phase : undefined;
|
|
if (phase) {
|
|
lifecyclePhases.push(phase);
|
|
}
|
|
});
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
|
|
await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("cli failed");
|
|
return {
|
|
result: await params.run("openai", "gpt-5.4"),
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
};
|
|
},
|
|
);
|
|
runCliAgentMock.mockRejectedValueOnce(new Error("cli failed"));
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (params: {
|
|
runId: string;
|
|
deferTerminalLifecycle?: boolean;
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
expect(params.deferTerminalLifecycle).toBe(true);
|
|
const startedAt = Date.now();
|
|
const startEvent = {
|
|
stream: "lifecycle",
|
|
data: { phase: "start", startedAt },
|
|
};
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId,
|
|
...startEvent,
|
|
});
|
|
await params.onAgentEvent?.(startEvent);
|
|
const finishingEvent = {
|
|
stream: "lifecycle",
|
|
data: { phase: "finishing", endedAt: Date.now() },
|
|
};
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId,
|
|
...finishingEvent,
|
|
});
|
|
await params.onAgentEvent?.(finishingEvent);
|
|
return {
|
|
payloads: [{ text: "fallback ok" }],
|
|
meta: {},
|
|
};
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
try {
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "chat-1",
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
messageProvider: "telegram",
|
|
},
|
|
}),
|
|
);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
|
|
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
|
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1);
|
|
const embeddedCall = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(embeddedCall.suppressAssistantErrorPersistence).toBe(false);
|
|
expect(lifecyclePhases).toEqual(["start", "start", "finishing", "end"]);
|
|
});
|
|
|
|
it("delivers an exhausted embedded followup as a failed lifecycle", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const lifecycleEvents: Array<Record<string, unknown>> = [];
|
|
const unsubscribe = realAgentEvents.onAgentEvent((evt) => {
|
|
if (evt.stream === "lifecycle") {
|
|
lifecycleEvents.push(evt.data);
|
|
}
|
|
});
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
|
outcome: "exhausted",
|
|
result: await params.run("anthropic", "claude-opus-4-7"),
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
}),
|
|
);
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (params: {
|
|
deferTerminalLifecycle?: boolean;
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
expect(params.deferTerminalLifecycle).toBe(true);
|
|
await params.onAgentEvent?.({
|
|
stream: "lifecycle",
|
|
data: { phase: "start", startedAt: 1_000 },
|
|
});
|
|
await params.onAgentEvent?.({
|
|
stream: "lifecycle",
|
|
data: { phase: "finishing", endedAt: 1_500 },
|
|
});
|
|
return {
|
|
payloads: [{ text: "Terminal tool summary", isError: true }],
|
|
meta: {
|
|
error: {
|
|
kind: "incomplete_turn",
|
|
message: "raw exhausted provider detail should stay private",
|
|
},
|
|
},
|
|
};
|
|
},
|
|
);
|
|
let operationResultDuringCompletion:
|
|
| import("./reply-run-registry.js").ReplyOperation["result"]
|
|
| undefined;
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
try {
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "chat-1",
|
|
queuedLifecycle: {
|
|
onComplete: () => {
|
|
operationResultDuringCompletion = replyRunRegistryForTest.get("main")?.result;
|
|
},
|
|
},
|
|
run: {
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
messageProvider: "telegram",
|
|
},
|
|
}),
|
|
);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({ text: "Terminal tool summary", isError: true }),
|
|
}),
|
|
);
|
|
expect(operationResultDuringCompletion).toMatchObject({
|
|
kind: "failed",
|
|
code: "run_failed",
|
|
});
|
|
expect(lifecycleEvents).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
phase: "error",
|
|
startedAt: 1_000,
|
|
fallbackExhaustedFailure: true,
|
|
}),
|
|
]),
|
|
);
|
|
expect(lifecycleEvents.some((event) => event.phase === "end")).toBe(false);
|
|
expect(JSON.stringify(lifecycleEvents)).not.toContain("raw exhausted provider detail");
|
|
});
|
|
|
|
it("delivers a completed non-fallbackable error followup as a failed lifecycle", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const lifecycleEvents: Array<Record<string, unknown>> = [];
|
|
const unsubscribe = realAgentEvents.onAgentEvent((evt) => {
|
|
if (evt.stream === "lifecycle") {
|
|
lifecycleEvents.push(evt.data);
|
|
}
|
|
});
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
|
outcome: "completed",
|
|
result: await params.run("anthropic", "claude-opus-4-7"),
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
}),
|
|
);
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (params: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await params.onAgentEvent?.({
|
|
stream: "lifecycle",
|
|
data: {
|
|
phase: "finishing",
|
|
error: "Command may have changed state",
|
|
replayInvalid: true,
|
|
},
|
|
});
|
|
return {
|
|
payloads: [{ text: "Command may have changed state", isError: true }],
|
|
meta: {
|
|
replayInvalid: true,
|
|
error: {
|
|
kind: "incomplete_turn",
|
|
message: "raw provider detail should stay private",
|
|
fallbackSafe: false,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
);
|
|
let operationResultDuringCompletion:
|
|
| import("./reply-run-registry.js").ReplyOperation["result"]
|
|
| undefined;
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
try {
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "chat-1",
|
|
queuedLifecycle: {
|
|
onComplete: () => {
|
|
operationResultDuringCompletion = replyRunRegistryForTest.get("main")?.result;
|
|
},
|
|
},
|
|
run: {
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
messageProvider: "telegram",
|
|
},
|
|
}),
|
|
);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
text: "Command may have changed state",
|
|
isError: true,
|
|
}),
|
|
}),
|
|
);
|
|
expect(operationResultDuringCompletion).toMatchObject({
|
|
kind: "failed",
|
|
code: "run_failed",
|
|
});
|
|
expect(lifecycleEvents).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
phase: "error",
|
|
error: "Command may have changed state",
|
|
replayInvalid: true,
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
lifecycleEvents.some(
|
|
(event) => event.phase === "end" || event.fallbackExhaustedFailure === true,
|
|
),
|
|
).toBe(false);
|
|
expect(JSON.stringify(lifecycleEvents)).not.toContain("raw provider detail");
|
|
});
|
|
|
|
it("suppresses deferred CLI success after restart cancellation", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const lifecycleEvents: Array<Record<string, unknown>> = [];
|
|
const unsubscribe = realAgentEvents.onAgentEvent((evt) => {
|
|
if (evt.stream === "lifecycle") {
|
|
lifecycleEvents.push(evt.data);
|
|
}
|
|
});
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
let resolveCli: (() => void) | undefined;
|
|
runCliAgentMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveCli = () =>
|
|
resolve({
|
|
payloads: [{ text: "completed after restart" }],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
try {
|
|
const pending = runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "chat-1",
|
|
run: {
|
|
config: runtimeConfig,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
messageProvider: "telegram",
|
|
},
|
|
}),
|
|
);
|
|
await vi.waitFor(() => {
|
|
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(abortActiveReplyRunsForTest({ mode: "all" })).toBe(true);
|
|
resolveCli?.();
|
|
await pending;
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
|
|
expect(lifecycleEvents).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
phase: "end",
|
|
aborted: true,
|
|
stopReason: "restart",
|
|
}),
|
|
]),
|
|
);
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the active runtime snapshot for queued embedded followup runs", async () => {
|
|
const sourceConfig: OpenClawConfig = {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
apiKey: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENAI_API_KEY",
|
|
},
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const runtimeConfig: OpenClawConfig = {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
apiKey: "resolved-runtime-key",
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
config: sourceConfig,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.config).toBe(runtimeConfig);
|
|
});
|
|
|
|
it("skips aborted queued room-event followups", async () => {
|
|
const abortController = new AbortController();
|
|
abortController.abort();
|
|
const onBlockReply = vi.fn(async () => {});
|
|
const typing = createMockTypingController();
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing,
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
abortSignal: abortController.signal,
|
|
run: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).not.toHaveBeenCalled();
|
|
expect(typing.markRunComplete).toHaveBeenCalledTimes(1);
|
|
expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes the admitted reply abort signal into followup fallback and agent runs", async () => {
|
|
const abortController = new AbortController();
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
currentInboundAudio: true,
|
|
abortSignal: abortController.signal,
|
|
run: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const fallbackCall = requireLastMockCallArg(
|
|
runWithModelFallbackMock,
|
|
"run with model fallback",
|
|
);
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(fallbackCall.abortSignal).toBeInstanceOf(AbortSignal);
|
|
expect(fallbackCall.abortSignal).not.toBe(abortController.signal);
|
|
expect(fallbackCall.sessionId).toBe("session");
|
|
expect(call.abortSignal).toBe(fallbackCall.abortSignal);
|
|
expect(call.currentInboundAudio).toBe(true);
|
|
});
|
|
|
|
it("does not inherit source abort signals for queued user followups", async () => {
|
|
const sourceAbortController = new AbortController();
|
|
sourceAbortController.abort();
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
opts: { abortSignal: sourceAbortController.signal },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "user_request",
|
|
run: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const fallbackCall = requireLastMockCallArg(
|
|
runWithModelFallbackMock,
|
|
"run with model fallback",
|
|
);
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(fallbackCall.abortSignal).toBeInstanceOf(AbortSignal);
|
|
expect(fallbackCall.abortSignal).not.toBe(sourceAbortController.signal);
|
|
expect(call.abortSignal).toBe(fallbackCall.abortSignal);
|
|
});
|
|
|
|
it("suppresses a settled followup result after an accepted user abort", async () => {
|
|
let releaseFallback: () => void = () => undefined;
|
|
let releaseProgressRoute: () => void = () => undefined;
|
|
let markCandidateSettled: () => void = () => undefined;
|
|
const candidateSettled = new Promise<void>((resolve) => {
|
|
markCandidateSettled = resolve;
|
|
});
|
|
const fallbackRelease = new Promise<void>((resolve) => {
|
|
releaseFallback = resolve;
|
|
});
|
|
const progressRouteStarted = new Promise<void>((resolve) => {
|
|
routeReplyMock.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ ok: true }>((release) => {
|
|
releaseProgressRoute = () => release({ ok: true });
|
|
resolve();
|
|
}),
|
|
);
|
|
});
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: { onToolResult?: (payload: { text: string }) => Promise<void> }) => {
|
|
void args.onToolResult?.({ text: "queued progress" });
|
|
return {
|
|
payloads: [{ text: "late followup" }],
|
|
meta: {},
|
|
};
|
|
},
|
|
);
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: {
|
|
run: (
|
|
provider: string,
|
|
model: string,
|
|
options?: { isFinalFallbackAttempt?: boolean },
|
|
) => Promise<{
|
|
payloads: Array<{ text: string }>;
|
|
meta: object;
|
|
}>;
|
|
}) => {
|
|
const result = await params.run("openai", "gpt-5.4", {
|
|
isFinalFallbackAttempt: false,
|
|
});
|
|
markCandidateSettled();
|
|
await fallbackRelease;
|
|
return {
|
|
result,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
};
|
|
},
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
|
|
const pending = runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "chat-1",
|
|
run: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
messageProvider: "telegram",
|
|
verboseLevel: "on",
|
|
},
|
|
}),
|
|
);
|
|
await candidateSettled;
|
|
await progressRouteStarted;
|
|
expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({
|
|
isFinalFallbackAttempt: false,
|
|
});
|
|
expect(replyRunRegistryForTest.abort("main")).toBe(true);
|
|
releaseFallback();
|
|
let settled = false;
|
|
void pending.then(() => {
|
|
settled = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(settled).toBe(false);
|
|
releaseProgressRoute();
|
|
await pending;
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
|
expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({
|
|
text: "queued progress",
|
|
});
|
|
});
|
|
|
|
it("keeps a direct cancellation error from becoming a followup failure", async () => {
|
|
let rejectAttempt: (error: Error) => void = () => undefined;
|
|
let markAttemptStarted: () => void = () => undefined;
|
|
const attemptStarted = new Promise<void>((resolve) => {
|
|
markAttemptStarted = resolve;
|
|
});
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise((_resolve, reject) => {
|
|
rejectAttempt = reject;
|
|
markAttemptStarted();
|
|
}),
|
|
);
|
|
let operationResultDuringCompletion:
|
|
| import("./reply-run-registry.js").ReplyOperation["result"]
|
|
| undefined;
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionKey: "main",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
|
|
const pending = runner(
|
|
createQueuedRun({
|
|
originatingChannel: "telegram",
|
|
originatingTo: "chat-1",
|
|
queuedLifecycle: {
|
|
onComplete: () => {
|
|
operationResultDuringCompletion = replyRunRegistryForTest.get("main")?.result;
|
|
},
|
|
},
|
|
run: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
messageProvider: "telegram",
|
|
},
|
|
}),
|
|
);
|
|
await attemptStarted;
|
|
expect(replyRunRegistryForTest.abort("main")).toBe(true);
|
|
rejectAttempt(Object.assign(new Error("agent run aborted"), { name: "AbortError" }));
|
|
await pending;
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(operationResultDuringCompletion).toEqual({
|
|
kind: "aborted",
|
|
code: "aborted_by_user",
|
|
});
|
|
});
|
|
|
|
it("keeps queued delivery correlations active during followup agent runs", async () => {
|
|
const events: string[] = [];
|
|
runEmbeddedAgentMock.mockImplementationOnce(async () => {
|
|
events.push("run");
|
|
return {
|
|
payloads: [],
|
|
meta: {},
|
|
};
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
deliveryCorrelations: [
|
|
{
|
|
begin: () => {
|
|
events.push("begin");
|
|
return () => {
|
|
events.push("end");
|
|
};
|
|
},
|
|
},
|
|
],
|
|
run: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(events).toEqual(["begin", "run", "end"]);
|
|
});
|
|
|
|
it("resolves queued embedded followups before preflight helpers read config", async () => {
|
|
const sourceConfig: OpenClawConfig = {
|
|
skills: {
|
|
entries: {
|
|
whisper: {
|
|
apiKey: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENAI_API_KEY",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const runtimeConfig: OpenClawConfig = {
|
|
skills: {
|
|
entries: {
|
|
whisper: {
|
|
apiKey: "resolved-runtime-key",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
resolveCommandSecretRefsViaGatewayMock.mockResolvedValueOnce({
|
|
resolvedConfig: runtimeConfig,
|
|
diagnostics: [],
|
|
targetStatesByPath: { "skills.entries.whisper.apiKey": "resolved_local" },
|
|
hadUnresolvedTargets: false,
|
|
});
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
const queued = createQueuedRun({
|
|
run: {
|
|
config: sourceConfig,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(queued.run.config).toBe(runtimeConfig);
|
|
expect(requireMockCallArg(runPreflightCompactionIfNeededMock, 0).cfg).toBe(runtimeConfig);
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.config).toBe(runtimeConfig);
|
|
});
|
|
|
|
it("passes queued origin scope into queued execution-config resolution", async () => {
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const sourceConfig: OpenClawConfig = {};
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
});
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingAccountId: "work",
|
|
run: {
|
|
config: sourceConfig,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
messageProvider: "discord",
|
|
agentAccountId: "bot-account",
|
|
},
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(sourceConfig, {
|
|
originatingChannel: "discord",
|
|
messageProvider: "discord",
|
|
originatingAccountId: "work",
|
|
agentAccountId: "bot-account",
|
|
});
|
|
});
|
|
|
|
it("passes queued images into queued embedded followup runs", async () => {
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const images = [{ type: "image" as const, data: "base64-cat", mimeType: "image/png" }];
|
|
const imageOrder = ["inline" as const];
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.4",
|
|
opts: {
|
|
images: [{ type: "image", data: "fallback", mimeType: "image/png" }],
|
|
imageOrder: ["inline"],
|
|
},
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
images,
|
|
imageOrder,
|
|
}),
|
|
);
|
|
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.images).toBe(images);
|
|
expect(call.imageOrder).toBe(imageOrder);
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner progress forwarding", () => {
|
|
it("records queued thread id on follow-up reply operations", async () => {
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "slack",
|
|
originatingTo: "user:U1",
|
|
originatingThreadId: "501.000",
|
|
run: {
|
|
messageProvider: "slack",
|
|
},
|
|
});
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: { replyOperation?: { routeThreadId?: string | number } }) => {
|
|
expect(args.replyOperation?.routeThreadId).toBe("501.000");
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("forwards queued follow-up tool progress and verbose tool result payloads", async () => {
|
|
const onToolStart = vi.fn(async () => {});
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
onToolResult?: (payload: { text: string }) => Promise<void>;
|
|
shouldEmitToolResult?: () => boolean;
|
|
shouldEmitToolOutput?: () => boolean;
|
|
toolProgressDetail?: "explain" | "raw";
|
|
}) => {
|
|
expect(args.shouldEmitToolResult?.()).toBe(true);
|
|
expect(args.shouldEmitToolOutput?.()).toBe(false);
|
|
expect(args.toolProgressDetail).toBe("raw");
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: {
|
|
itemId: "tool:queued-progress",
|
|
toolCallId: "queued-progress",
|
|
phase: "start",
|
|
name: "exec",
|
|
args: { command: "echo queued-progress" },
|
|
},
|
|
});
|
|
await args.onToolResult?.({ text: "🛠️ Exec: echo queued-progress" });
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onToolStart },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
toolProgressDetail: "raw",
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onToolStart).toHaveBeenCalledWith({
|
|
itemId: "tool:queued-progress",
|
|
toolCallId: "queued-progress",
|
|
name: "exec",
|
|
phase: "start",
|
|
args: { command: "echo queued-progress" },
|
|
detailMode: "raw",
|
|
});
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "discord",
|
|
to: "channel:C1",
|
|
accountId: "acct-1",
|
|
threadId: "thread-1",
|
|
mirror: false,
|
|
replyKind: "tool",
|
|
payload: expect.objectContaining({ text: "🛠️ Exec: echo queued-progress" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps queued room-event verbose tool summaries suppressed", async () => {
|
|
const queued = createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onToolResult?: (payload: { text: string }) => Promise<void>;
|
|
shouldEmitToolResult?: () => boolean;
|
|
}) => {
|
|
expect(args.shouldEmitToolResult?.()).toBe(true);
|
|
await args.onToolResult?.({ text: "🛠️ Exec: echo ambient-progress" });
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers queued fast auto progress for non-room-event message-tool-only turns", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(1_000);
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
runCliAgentMock.mockImplementationOnce((params: { runId?: string }) => {
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId ?? "run-fast-followup",
|
|
stream: "tool",
|
|
data: { phase: "start", name: "bash", toolCallId: "call-1" },
|
|
});
|
|
vi.setSystemTime(7_100);
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId ?? "run-fast-followup",
|
|
stream: "tool",
|
|
data: { phase: "result", name: "bash", toolCallId: "call-1" },
|
|
});
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "user_request",
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
config: runtimeConfig,
|
|
messageProvider: "discord",
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
fastMode: "auto",
|
|
fastModeOverride: true,
|
|
fastModeAutoOnSeconds: 5,
|
|
fastModeAutoOnSecondsOverride: true,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "discord",
|
|
to: "channel:C1",
|
|
accountId: "acct-1",
|
|
threadId: "thread-1",
|
|
mirror: false,
|
|
replyKind: "tool",
|
|
payload: expect.objectContaining({
|
|
text: "💨Fast: auto-off(6s>=5s)",
|
|
channelData: { openclawProgressKind: "fast-mode-auto" },
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("suppresses queued fast auto progress for room-event message-tool-only turns", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(1_000);
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
const runtimeConfig: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
runCliAgentMock.mockImplementationOnce((params: { runId?: string }) => {
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId ?? "run-fast-followup",
|
|
stream: "tool",
|
|
data: { phase: "start", name: "bash", toolCallId: "call-1" },
|
|
});
|
|
vi.setSystemTime(7_100);
|
|
realAgentEvents.emitAgentEvent({
|
|
runId: params.runId ?? "run-fast-followup",
|
|
stream: "tool",
|
|
data: { phase: "result", name: "bash", toolCallId: "call-1" },
|
|
});
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
config: runtimeConfig,
|
|
messageProvider: "discord",
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
fastMode: "auto",
|
|
fastModeOverride: true,
|
|
fastModeAutoOnSeconds: 5,
|
|
fastModeAutoOnSecondsOverride: true,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("drains fire-and-forget queued tool progress before final delivery", async () => {
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
let releaseProgressRoute: (() => void) | undefined;
|
|
const progressRouteStarted = new Promise<void>((resolve) => {
|
|
routeReplyMock.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ ok: true }>((release) => {
|
|
releaseProgressRoute = () => {
|
|
release({ ok: true });
|
|
};
|
|
resolve();
|
|
}),
|
|
);
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: { onToolResult?: (payload: { text: string }) => Promise<void> }) => {
|
|
void args.onToolResult?.({ text: "🛠️ Exec: echo queued-progress" });
|
|
return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
const runPromise = runner(queued);
|
|
await progressRouteStarted;
|
|
await Promise.resolve();
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
|
expect(requireMockCallArg(routeReplyMock, 0).payload).toEqual(
|
|
expect.objectContaining({ text: "🛠️ Exec: echo queued-progress" }),
|
|
);
|
|
expect(requireMockCallArg(routeReplyMock, 0).mirror).toBe(false);
|
|
|
|
releaseProgressRoute?.();
|
|
await runPromise;
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(2);
|
|
expect(requireMockCallArg(routeReplyMock, 1).payload).toEqual(
|
|
expect.objectContaining({ text: "final reply" }),
|
|
);
|
|
expect(requireMockCallArg(routeReplyMock, 1).mirror).toBeUndefined();
|
|
});
|
|
|
|
it("preserves queued verbose progress when default tool progress is suppressed", async () => {
|
|
const onToolStart = vi.fn(async () => {});
|
|
const onItemEvent = vi.fn(async () => {});
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
onToolResult?: (payload: { text: string }) => Promise<void>;
|
|
shouldEmitToolResult?: () => boolean;
|
|
shouldEmitToolOutput?: () => boolean;
|
|
}) => {
|
|
expect(args.shouldEmitToolResult?.()).toBe(true);
|
|
expect(args.shouldEmitToolOutput?.()).toBe(false);
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: {
|
|
phase: "start",
|
|
name: "exec",
|
|
args: { command: "echo queued-suppressed-preview" },
|
|
},
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "item",
|
|
data: {
|
|
itemId: "command:queued-suppressed-preview",
|
|
toolCallId: "queued-suppressed-preview",
|
|
kind: "command",
|
|
name: "exec",
|
|
phase: "update",
|
|
status: "running",
|
|
progressText: "queued output",
|
|
},
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: { phase: "chunk", output: "queued output" },
|
|
});
|
|
await args.onToolResult?.({ text: "🛠️ Exec: echo queued-suppressed-preview" });
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: {
|
|
suppressDefaultToolProgressMessages: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
onCommandOutput,
|
|
},
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
toolProgressDetail: "raw",
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onToolStart).toHaveBeenCalledWith({
|
|
name: "exec",
|
|
phase: "start",
|
|
args: { command: "echo queued-suppressed-preview" },
|
|
detailMode: "raw",
|
|
});
|
|
expect(onItemEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
itemId: "command:queued-suppressed-preview",
|
|
toolCallId: "queued-suppressed-preview",
|
|
kind: "command",
|
|
name: "exec",
|
|
phase: "update",
|
|
}),
|
|
);
|
|
expect(onCommandOutput).toHaveBeenCalledWith(
|
|
expect.objectContaining({ phase: "chunk", output: "queued output" }),
|
|
);
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
|
expect(routeReplyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "discord",
|
|
to: "channel:C1",
|
|
accountId: "acct-1",
|
|
threadId: "thread-1",
|
|
mirror: false,
|
|
payload: expect.objectContaining({ text: "🛠️ Exec: echo queued-suppressed-preview" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("forwards queued Codex command tool results as command output completion", async () => {
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: {
|
|
phase: "result",
|
|
itemId: "command:queued-exec",
|
|
toolCallId: "queued-exec",
|
|
name: "exec",
|
|
status: "completed",
|
|
result: {
|
|
exitCode: 0,
|
|
durationMs: 24,
|
|
},
|
|
},
|
|
});
|
|
return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onCommandOutput },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onCommandOutput).toHaveBeenCalledWith({
|
|
itemId: "command:queued-exec",
|
|
phase: "end",
|
|
title: undefined,
|
|
toolCallId: "queued-exec",
|
|
name: "exec",
|
|
output: undefined,
|
|
status: "completed",
|
|
exitCode: 0,
|
|
durationMs: 24,
|
|
cwd: undefined,
|
|
});
|
|
});
|
|
|
|
it("marks queued Codex command tool result errors as failed command output", async () => {
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: {
|
|
phase: "result",
|
|
itemId: "command:queued-exec",
|
|
toolCallId: "queued-exec",
|
|
name: "exec",
|
|
isError: true,
|
|
result: {
|
|
content: [{ type: "text", text: "command failed" }],
|
|
},
|
|
},
|
|
});
|
|
return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onCommandOutput },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onCommandOutput).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
itemId: "command:queued-exec",
|
|
phase: "end",
|
|
toolCallId: "queued-exec",
|
|
name: "exec",
|
|
status: "failed",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not synthesize queued command output from bare exec tool results", async () => {
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "acct-1",
|
|
originatingThreadId: "thread-1",
|
|
run: {
|
|
messageProvider: "discord",
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: {
|
|
phase: "result",
|
|
name: "exec",
|
|
toolCallId: "queued-exec",
|
|
isError: false,
|
|
},
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: {
|
|
itemId: "command:queued-exec",
|
|
phase: "end",
|
|
title: "command ls",
|
|
toolCallId: "queued-exec",
|
|
name: "exec",
|
|
status: "completed",
|
|
exitCode: 0,
|
|
},
|
|
});
|
|
return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onCommandOutput },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onCommandOutput).toHaveBeenCalledTimes(1);
|
|
expect(onCommandOutput).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
itemId: "command:queued-exec",
|
|
phase: "end",
|
|
status: "completed",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("suppresses queued follow-up progress when verbose progress is disabled", async () => {
|
|
const storePath = path.join(
|
|
await fs.mkdtemp(path.join(tmpdir(), "openclaw-followup-progress-off-")),
|
|
"sessions.json",
|
|
);
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = { main: sessionEntry };
|
|
const onToolStart = vi.fn(async () => {});
|
|
const onItemEvent = vi.fn(async () => {});
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
const onCompactionStart = vi.fn(async () => {});
|
|
const onCompactionEnd = vi.fn(async () => {});
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
shouldEmitToolResult?: () => boolean;
|
|
shouldEmitToolOutput?: () => boolean;
|
|
}) => {
|
|
expect(args.shouldEmitToolResult?.()).toBe(false);
|
|
expect(args.shouldEmitToolOutput?.()).toBe(false);
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: { phase: "start", name: "exec", args: { command: "echo hidden" } },
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "item",
|
|
data: { phase: "start", itemId: "item-1", title: "hidden item" },
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: { phase: "chunk", output: "hidden output" },
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "compaction",
|
|
data: { phase: "end", completed: true },
|
|
});
|
|
return { payloads: [{ text: "final" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onToolStart, onItemEvent, onCommandOutput, onCompactionStart, onCompactionEnd },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "off",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
expect(onItemEvent).not.toHaveBeenCalled();
|
|
expect(onCommandOutput).not.toHaveBeenCalled();
|
|
expect(onCompactionStart).not.toHaveBeenCalled();
|
|
expect(onCompactionEnd).not.toHaveBeenCalled();
|
|
expect(sessionStore.main.compactionCount).toBe(1);
|
|
});
|
|
|
|
it("forwards opted-in queued tool lifecycle feedback while verbose progress is disabled", async () => {
|
|
const onToolStart = vi.fn(async () => {});
|
|
const onItemEvent = vi.fn(async () => {});
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: { phase: "start", name: "exec", args: { command: "echo hidden" } },
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "item",
|
|
data: { phase: "start", itemId: "item-1", title: "hidden item" },
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: { phase: "chunk", output: "hidden output" },
|
|
});
|
|
return { payloads: [{ text: "final" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: {
|
|
allowToolLifecycleWhenProgressHidden: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
onCommandOutput,
|
|
},
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "off",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onToolStart).toHaveBeenCalledWith({
|
|
name: "exec",
|
|
phase: "start",
|
|
args: { command: "echo hidden" },
|
|
detailMode: undefined,
|
|
});
|
|
expect(onItemEvent).not.toHaveBeenCalled();
|
|
expect(onCommandOutput).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps internal tool lifecycle events out of queued channel progress", async () => {
|
|
const onToolStart = vi.fn(async () => {});
|
|
const onItemEvent = vi.fn(async () => {});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: {
|
|
phase: "start",
|
|
name: "wait",
|
|
hideFromChannelProgress: true,
|
|
},
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "item",
|
|
data: {
|
|
phase: "start",
|
|
itemId: "tool:wait-1",
|
|
title: "wait",
|
|
hideFromChannelProgress: true,
|
|
},
|
|
});
|
|
return { payloads: [{ text: "final" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: {
|
|
allowToolLifecycleWhenProgressHidden: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
},
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "off",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
expect(onItemEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps queued follow-up progress quiet when verbose state is missing", async () => {
|
|
const onToolStart = vi.fn(async () => {});
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
onToolResult?: (payload: { text: string }) => Promise<void>;
|
|
shouldEmitToolResult?: () => boolean;
|
|
shouldEmitToolOutput?: () => boolean;
|
|
}) => {
|
|
expect(args.shouldEmitToolResult?.()).toBe(false);
|
|
expect(args.shouldEmitToolOutput?.()).toBe(false);
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: { phase: "start", name: "exec", args: { command: "echo hidden" } },
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: { phase: "chunk", output: "hidden output" },
|
|
});
|
|
await args.onToolResult?.({ text: "🛠️ Exec: echo hidden" });
|
|
return { payloads: [{ text: "final" }], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { suppressDefaultToolProgressMessages: false, onToolStart, onCommandOutput },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: undefined,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
expect(onCommandOutput).not.toHaveBeenCalled();
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not reuse dispatch-scoped tool-error suppression across queued follow-ups", async () => {
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
|
|
runEmbeddedAgentMock
|
|
.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
suppressToolErrorWarnings?: boolean | (() => boolean | undefined);
|
|
}) => {
|
|
const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined;
|
|
expect(shouldSuppress()).toBeUndefined();
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: {
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
},
|
|
});
|
|
expect(shouldSuppress()).toBe(true);
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
)
|
|
.mockImplementationOnce(
|
|
async (args: { suppressToolErrorWarnings?: boolean | (() => boolean | undefined) }) => {
|
|
const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined;
|
|
expect(shouldSuppress()).toBe(false);
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onCommandOutput, shouldSuppressToolErrorWarnings: () => true },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "on",
|
|
},
|
|
}),
|
|
);
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "off",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onCommandOutput).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps queued full-verbose tool-error fallbacks available after failed progress", async () => {
|
|
const onCommandOutput = vi.fn(async () => {});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
suppressToolErrorWarnings?: boolean | (() => boolean | undefined);
|
|
}) => {
|
|
const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined;
|
|
expect(shouldSuppress()).toBeUndefined();
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: {
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
},
|
|
});
|
|
expect(shouldSuppress()).toBeUndefined();
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onCommandOutput },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "full",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onCommandOutput).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps queued tool-error fallbacks when failed progress has no callback", async () => {
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
suppressToolErrorWarnings?: boolean | (() => boolean | undefined);
|
|
}) => {
|
|
const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined;
|
|
expect(shouldSuppress()).toBeUndefined();
|
|
await args.onAgentEvent?.({
|
|
stream: "command_output",
|
|
data: {
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
},
|
|
});
|
|
expect(shouldSuppress()).toBeUndefined();
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "on",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses current session verbose state for queued follow-up progress", async () => {
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
verboseLevel: "off",
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = { main: sessionEntry };
|
|
const onToolStart = vi.fn(async () => {});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
shouldEmitToolResult?: () => boolean;
|
|
}) => {
|
|
expect(args.shouldEmitToolResult?.()).toBe(false);
|
|
await args.onAgentEvent?.({
|
|
stream: "tool",
|
|
data: { phase: "start", name: "exec", args: { command: "echo hidden" } },
|
|
});
|
|
return { payloads: [], meta: { agentMeta: {} } };
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onToolStart },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "claude",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
messageProvider: "discord",
|
|
sessionKey: "main",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
verboseLevel: "on",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner compaction", () => {
|
|
it("adds verbose auto-compaction notice and tracks count", async () => {
|
|
const storePath = path.join(
|
|
await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-")),
|
|
"sessions.json",
|
|
);
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
main: sessionEntry,
|
|
};
|
|
const onBlockReply = vi.fn(async () => {});
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
|
|
mockCompactionRun({
|
|
willRetry: true,
|
|
result: { payloads: [{ text: "final" }], meta: {} },
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
const queued = createQueuedRun({
|
|
run: {
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
|
const firstCall = (onBlockReply.mock.calls as unknown as Array<Array<{ text?: string }>>)[0];
|
|
expect(firstCall?.[0]?.text).toContain("Auto-compaction complete");
|
|
expect(sessionStore.main.compactionCount).toBe(1);
|
|
});
|
|
|
|
it("suppresses queued auto-compaction notice when verbose is turned off", async () => {
|
|
const storePath = path.join(
|
|
await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-quiet-")),
|
|
"sessions.json",
|
|
);
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
verboseLevel: "off",
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
main: sessionEntry,
|
|
};
|
|
const onBlockReply = vi.fn(async () => {});
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
|
|
mockCompactionRun({
|
|
willRetry: true,
|
|
result: { payloads: [{ text: "final" }], meta: {} },
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
const queued = createQueuedRun({
|
|
run: {
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectNoBlockReplyTextIncludes(onBlockReply, "Auto-compaction complete");
|
|
expect(sessionStore.main.compactionCount).toBe(1);
|
|
});
|
|
|
|
it("tracks auto-compaction from embedded result metadata even when no compaction event is emitted", async () => {
|
|
const storePath = path.join(
|
|
await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-meta-")),
|
|
"sessions.json",
|
|
);
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
sessionFile: path.join(path.dirname(storePath), "session.jsonl"),
|
|
updatedAt: Date.now(),
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
main: sessionEntry,
|
|
};
|
|
const onBlockReply = vi.fn(async () => {});
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "final" }],
|
|
meta: {
|
|
agentMeta: {
|
|
sessionId: "session-rotated",
|
|
compactionCount: 2,
|
|
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
const queued = createQueuedRun({
|
|
run: {
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
|
const firstCall = (onBlockReply.mock.calls as unknown as Array<Array<{ text?: string }>>)[0];
|
|
expect(firstCall?.[0]?.text).toContain("Auto-compaction complete");
|
|
expect(sessionStore.main.compactionCount).toBe(2);
|
|
expect(sessionStore.main.sessionId).toBe("session-rotated");
|
|
expect(await normalizeComparablePath(sessionStore.main.sessionFile ?? "")).toBe(
|
|
await normalizeComparablePath(path.join(path.dirname(storePath), "session-rotated.jsonl")),
|
|
);
|
|
});
|
|
|
|
it("refreshes queued followup runs to the rotated transcript", async () => {
|
|
const storePath = path.join(
|
|
await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-queue-")),
|
|
"sessions.json",
|
|
);
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
sessionFile: path.join(path.dirname(storePath), "session.jsonl"),
|
|
updatedAt: Date.now(),
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
main: sessionEntry,
|
|
};
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "final" }],
|
|
meta: {
|
|
agentMeta: {
|
|
sessionId: "session-rotated",
|
|
compactionCount: 1,
|
|
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: vi.fn(async () => {}) },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
const queuedNext = createQueuedRun({
|
|
prompt: "next",
|
|
run: {
|
|
sessionId: "session",
|
|
sessionFile: path.join(path.dirname(storePath), "session.jsonl"),
|
|
},
|
|
});
|
|
const queueSettings: QueueSettings = { mode: "followup" };
|
|
enqueueFollowupRun("main", queuedNext, queueSettings);
|
|
|
|
const current = createQueuedRun({
|
|
run: {
|
|
verboseLevel: "on",
|
|
sessionId: "session",
|
|
sessionFile: path.join(path.dirname(storePath), "session.jsonl"),
|
|
},
|
|
});
|
|
|
|
await runner(current);
|
|
|
|
expect(queuedNext.run.sessionId).toBe("session-rotated");
|
|
expect(await normalizeComparablePath(queuedNext.run.sessionFile)).toBe(
|
|
await normalizeComparablePath(path.join(path.dirname(storePath), "session-rotated.jsonl")),
|
|
);
|
|
});
|
|
|
|
it("does not count failed compaction end events in followup runs", async () => {
|
|
const storePath = path.join(
|
|
await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-failed-")),
|
|
"sessions.json",
|
|
);
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
main: sessionEntry,
|
|
};
|
|
const onBlockReply = vi.fn(async () => {});
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
const queued = createQueuedRun({
|
|
run: {
|
|
verboseLevel: "on",
|
|
},
|
|
});
|
|
|
|
runEmbeddedAgentMock.mockImplementationOnce(async (args) => {
|
|
args.onAgentEvent?.({
|
|
stream: "compaction",
|
|
data: { phase: "end", willRetry: false, completed: false },
|
|
});
|
|
return {
|
|
payloads: [{ text: "final" }],
|
|
meta: {
|
|
agentMeta: {
|
|
compactionCount: 0,
|
|
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
const firstCall = (onBlockReply.mock.calls as unknown as Array<Array<{ text?: string }>>)[0];
|
|
expect(firstCall?.[0]?.text).toBe("final");
|
|
expect(sessionStore.main.compactionCount).toBeUndefined();
|
|
});
|
|
|
|
it("injects the post-compaction refresh prompt before followup runs after preflight compaction", async () => {
|
|
const workspaceDir = await fs.mkdtemp(path.join(tmpdir(), "openclaw-preflight-followup-"));
|
|
const storePath = path.join(workspaceDir, "sessions.json");
|
|
const transcriptPath = path.join(workspaceDir, "session.jsonl");
|
|
await fs.writeFile(
|
|
transcriptPath,
|
|
`${JSON.stringify({
|
|
message: {
|
|
role: "user",
|
|
content: "x".repeat(320_000),
|
|
timestamp: Date.now(),
|
|
},
|
|
})}\n`,
|
|
"utf-8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(workspaceDir, "AGENTS.md"),
|
|
[
|
|
"## Session Startup",
|
|
"Read AGENTS.md before replying.",
|
|
"",
|
|
"## Red Lines",
|
|
"Never skip safety checks.",
|
|
].join("\n"),
|
|
"utf-8",
|
|
);
|
|
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
sessionFile: transcriptPath,
|
|
totalTokens: 10,
|
|
totalTokensFresh: false,
|
|
compactionCount: 1,
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
main: sessionEntry,
|
|
};
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
|
|
|
|
compactEmbeddedAgentSessionMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
summary: "compacted",
|
|
firstKeptEntryId: "first-kept",
|
|
tokensBefore: 90_000,
|
|
tokensAfter: 8_000,
|
|
},
|
|
});
|
|
runPreflightCompactionIfNeededMock.mockImplementationOnce(
|
|
async (params: {
|
|
followupRun: FollowupRun;
|
|
sessionEntry?: SessionEntry;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
sessionKey?: string;
|
|
storePath?: string;
|
|
}) => {
|
|
await compactEmbeddedAgentSessionMock({
|
|
sessionFile: transcriptPath,
|
|
workspaceDir,
|
|
});
|
|
params.followupRun.run.extraSystemPrompt = joinPromptSections(
|
|
params.followupRun.run.extraSystemPrompt,
|
|
"Post-compaction context refresh",
|
|
"Read AGENTS.md before replying.",
|
|
);
|
|
const updatedEntry =
|
|
params.sessionEntry ??
|
|
(params.sessionKey && params.sessionStore
|
|
? params.sessionStore[params.sessionKey]
|
|
: undefined);
|
|
if (updatedEntry) {
|
|
updatedEntry.compactionCount = 2;
|
|
updatedEntry.updatedAt = Date.now();
|
|
if (params.sessionKey && params.sessionStore) {
|
|
params.sessionStore[params.sessionKey] = updatedEntry;
|
|
}
|
|
if (params.storePath && params.sessionKey) {
|
|
const registeredStore = FOLLOWUP_TEST_SESSION_STORES.get(params.storePath);
|
|
if (registeredStore) {
|
|
registeredStore[params.sessionKey] = updatedEntry;
|
|
} else {
|
|
const store = loadSessionStore(params.storePath, { skipCache: true });
|
|
store[params.sessionKey] = updatedEntry;
|
|
await saveSessionStore(params.storePath, store);
|
|
}
|
|
}
|
|
}
|
|
return updatedEntry;
|
|
},
|
|
);
|
|
|
|
const embeddedCalls: Array<{ extraSystemPrompt?: string }> = [];
|
|
runEmbeddedAgentMock.mockImplementationOnce(async (params: { extraSystemPrompt?: string }) => {
|
|
embeddedCalls.push({ extraSystemPrompt: params.extraSystemPrompt });
|
|
return {
|
|
payloads: [{ text: "final" }],
|
|
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
|
};
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: vi.fn(async () => {}) },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
storePath,
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
agentCfgContextTokens: 100_000,
|
|
});
|
|
|
|
const queued = createQueuedRun({
|
|
run: {
|
|
sessionFile: transcriptPath,
|
|
workspaceDir,
|
|
},
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledOnce();
|
|
expect(embeddedCalls[0]?.extraSystemPrompt).toContain("Post-compaction context refresh");
|
|
expect(embeddedCalls[0]?.extraSystemPrompt).toContain("Read AGENTS.md before replying.");
|
|
expect(sessionStore.main?.compactionCount).toBe(2);
|
|
expect(requireMockCallArg(persistSpy, 0).preserveFreshTotalTokensOnStaleUsage).toBe(true);
|
|
persistSpy.mockRestore();
|
|
});
|
|
|
|
it("registers the post-preflight session id for lifecycle event stamping", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
realAgentEvents.resetAgentRunContextForTest();
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "old-session",
|
|
updatedAt: Date.now(),
|
|
sessionFile: "/tmp/old-session.jsonl",
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
main: sessionEntry,
|
|
};
|
|
runPreflightCompactionIfNeededMock.mockImplementationOnce(
|
|
async (params: {
|
|
followupRun: FollowupRun;
|
|
sessionEntry?: SessionEntry;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
sessionKey?: string;
|
|
}) => {
|
|
const updatedEntry: SessionEntry = {
|
|
...(params.sessionEntry ?? sessionEntry),
|
|
sessionId: "new-session",
|
|
sessionFile: "/tmp/new-session.jsonl",
|
|
updatedAt: Date.now(),
|
|
};
|
|
params.followupRun.run.sessionId = updatedEntry.sessionId;
|
|
params.followupRun.run.sessionFile = "/tmp/new-session.jsonl";
|
|
if (params.sessionKey && params.sessionStore) {
|
|
params.sessionStore[params.sessionKey] = updatedEntry;
|
|
}
|
|
return updatedEntry;
|
|
},
|
|
);
|
|
|
|
let observedRunId: string | undefined;
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (params: { runId: string; sessionId?: string }) => {
|
|
observedRunId = params.runId;
|
|
expect(params.sessionId).toBe("new-session");
|
|
return {
|
|
payloads: [{ text: "final" }],
|
|
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
|
};
|
|
},
|
|
);
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
await runner(createQueuedRun());
|
|
|
|
expect(observedRunId).toBeDefined();
|
|
expect(realAgentEvents.getAgentRunContext(observedRunId ?? "")?.sessionId).toBe("new-session");
|
|
realAgentEvents.resetAgentRunContextForTest();
|
|
});
|
|
|
|
it("captures follow-up lifecycle ownership before asynchronous preflight", async () => {
|
|
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
|
"../../infra/agent-events.js",
|
|
);
|
|
realAgentEvents.resetAgentRunContextForTest();
|
|
const initialGeneration = realAgentEvents.getAgentEventLifecycleGeneration();
|
|
let releasePreflight: (() => void) | undefined;
|
|
runPreflightCompactionIfNeededMock.mockImplementationOnce(
|
|
async (params: { sessionEntry?: SessionEntry }) => {
|
|
await new Promise<void>((resolve) => {
|
|
releasePreflight = resolve;
|
|
});
|
|
return params.sessionEntry;
|
|
},
|
|
);
|
|
let observedLifecycleGeneration: string | undefined;
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (params: { lifecycleGeneration?: string }) => {
|
|
observedLifecycleGeneration = params.lifecycleGeneration;
|
|
if (params.lifecycleGeneration !== realAgentEvents.getAgentEventLifecycleGeneration()) {
|
|
const error = new Error("Agent run belongs to a stale gateway lifecycle");
|
|
error.name = "AbortError";
|
|
throw error;
|
|
}
|
|
return {
|
|
payloads: [{ text: "final" }],
|
|
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
|
};
|
|
},
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry: {
|
|
sessionId: "preflight-session",
|
|
updatedAt: Date.now(),
|
|
},
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude",
|
|
});
|
|
|
|
try {
|
|
const pending = runner(
|
|
createQueuedRun({
|
|
run: {
|
|
sessionId: "preflight-session",
|
|
sessionKey: "main",
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
},
|
|
}),
|
|
);
|
|
await vi.waitFor(() => {
|
|
expect(runPreflightCompactionIfNeededMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
const [registeredRun] = realAgentEvents.listAgentRunsForSession({
|
|
sessionKey: "main",
|
|
sessionId: "preflight-session",
|
|
});
|
|
expect(registeredRun).toEqual(
|
|
expect.objectContaining({
|
|
lifecycleGeneration: initialGeneration,
|
|
}),
|
|
);
|
|
|
|
realAgentEvents.rotateAgentEventLifecycleGeneration();
|
|
releasePreflight?.();
|
|
await pending;
|
|
|
|
expect(observedLifecycleGeneration).toBe(initialGeneration);
|
|
expect(realAgentEvents.getAgentRunContext(registeredRun?.runId ?? "")).toBeUndefined();
|
|
} finally {
|
|
realAgentEvents.resetAgentRunContextForTest();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner bootstrap warning dedupe", () => {
|
|
it("passes stored warning signature history to embedded followup runs", async () => {
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
systemPromptReport: {
|
|
source: "run",
|
|
generatedAt: Date.now(),
|
|
systemPrompt: {
|
|
chars: 1,
|
|
projectContextChars: 0,
|
|
nonProjectContextChars: 1,
|
|
},
|
|
injectedWorkspaceFiles: [],
|
|
skills: {
|
|
promptChars: 0,
|
|
entries: [],
|
|
},
|
|
tools: {
|
|
listChars: 0,
|
|
schemaChars: 0,
|
|
entries: [],
|
|
},
|
|
bootstrapTruncation: {
|
|
warningMode: "once",
|
|
warningShown: true,
|
|
promptWarningSignature: "sig-b",
|
|
warningSignaturesSeen: ["sig-a", "sig-b"],
|
|
truncatedFiles: 1,
|
|
nearLimitFiles: 0,
|
|
totalNearLimit: false,
|
|
},
|
|
},
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = { main: sessionEntry };
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: vi.fn(async () => {}) },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: "main",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
await runner(baseQueuedRun());
|
|
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.allowGatewaySubagentBinding).toBe(true);
|
|
expect(call.bootstrapPromptWarningSignaturesSeen).toEqual(["sig-a", "sig-b"]);
|
|
expect(call.bootstrapPromptWarningSignature).toBe("sig-b");
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner messaging delivery and dedupe", () => {
|
|
function createMessagingDedupeRunner(
|
|
onBlockReply: (payload: unknown) => Promise<void>,
|
|
overrides: Partial<{
|
|
sessionEntry: SessionEntry;
|
|
sessionStore: Record<string, SessionEntry>;
|
|
sessionKey: string;
|
|
storePath: string;
|
|
}> = {},
|
|
) {
|
|
if (overrides.storePath && overrides.sessionStore) {
|
|
registerFollowupTestSessionStore(overrides.storePath, overrides.sessionStore);
|
|
}
|
|
return createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
sessionEntry: overrides.sessionEntry,
|
|
sessionStore: overrides.sessionStore,
|
|
sessionKey: overrides.sessionKey,
|
|
storePath: overrides.storePath,
|
|
});
|
|
}
|
|
|
|
async function runMessagingCase(params: {
|
|
agentResult: Record<string, unknown>;
|
|
queued?: FollowupRun;
|
|
runnerOverrides?: Partial<{
|
|
sessionEntry: SessionEntry;
|
|
sessionStore: Record<string, SessionEntry>;
|
|
sessionKey: string;
|
|
storePath: string;
|
|
}>;
|
|
}) {
|
|
const onBlockReply = createAsyncReplySpy();
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
meta: {},
|
|
...params.agentResult,
|
|
});
|
|
const runner = createMessagingDedupeRunner(onBlockReply, params.runnerOverrides);
|
|
await runner(params.queued ?? baseQueuedRun());
|
|
return { onBlockReply };
|
|
}
|
|
|
|
function makeTextReplyDedupeResult(overrides?: Record<string, unknown>) {
|
|
return {
|
|
payloads: [{ text: "hello world!" }],
|
|
messagingToolSentTexts: ["different message"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("persists usage even when replies are suppressed", async () => {
|
|
const storePath = "/tmp/openclaw-followup-usage.json";
|
|
const sessionKey = "main";
|
|
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
|
|
persistSpy.mockImplementationOnce(async (params) => {
|
|
const nextEntry: SessionEntry = {
|
|
...sessionStore[sessionKey],
|
|
updatedAt: Date.now(),
|
|
totalTokens: params.lastCallUsage?.input,
|
|
totalTokensFresh: true,
|
|
model: params.modelUsed,
|
|
modelProvider: params.providerUsed,
|
|
inputTokens: params.usage?.input,
|
|
outputTokens: params.usage?.output,
|
|
};
|
|
sessionStore[sessionKey] = nextEntry;
|
|
Object.assign(sessionEntry, nextEntry);
|
|
});
|
|
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: {
|
|
...makeTextReplyDedupeResult({ messagingToolSentTexts: ["hello world!"] }),
|
|
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 1_000, output: 50 },
|
|
lastCallUsage: { input: 400, output: 20 },
|
|
model: "claude-opus-4-6",
|
|
provider: "anthropic",
|
|
},
|
|
},
|
|
},
|
|
runnerOverrides: {
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
},
|
|
queued: baseQueuedRun("slack"),
|
|
});
|
|
|
|
expect(onBlockReply).not.toHaveBeenCalled();
|
|
const persistCall = requireMockCallArg(persistSpy, 0);
|
|
expect(persistCall.storePath).toBe(storePath);
|
|
expect(persistCall.sessionKey).toBe(sessionKey);
|
|
expect(persistCall.modelUsed).toBe("claude-opus-4-6");
|
|
expect(persistCall.providerUsed).toBe("anthropic");
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(400);
|
|
expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
|
|
// Accumulated usage is still stored for usage/cost tracking.
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBe(1_000);
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBe(50);
|
|
persistSpy.mockRestore();
|
|
});
|
|
|
|
it("passes queued config into usage persistence during drained followups", async () => {
|
|
const storePath = "/tmp/openclaw-followup-usage-cfg.json";
|
|
const sessionKey = "main";
|
|
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
|
|
const cfg = {
|
|
messages: {
|
|
responsePrefix: "agent",
|
|
},
|
|
};
|
|
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "hello world!" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 10, output: 5 },
|
|
lastCallUsage: { input: 6, output: 3 },
|
|
model: "claude-opus-4-6",
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: createAsyncReplySpy() },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
});
|
|
|
|
await expect(
|
|
runner(
|
|
createQueuedRun({
|
|
run: {
|
|
config: cfg,
|
|
},
|
|
}),
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
const persistCall = requireMockCallArg(persistSpy, 0);
|
|
expect(persistCall.storePath).toBe(storePath);
|
|
expect(persistCall.sessionKey).toBe(sessionKey);
|
|
expect(persistCall.cfg).toBe(cfg);
|
|
persistSpy.mockRestore();
|
|
});
|
|
|
|
it("appends configured responseUsage footers during followup delivery", async () => {
|
|
const sessionKey = "main";
|
|
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
|
const cfg = {
|
|
messages: {
|
|
responseUsage: "tokens",
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: {
|
|
payloads: [{ text: "hello world!" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 1_000, output: 50 },
|
|
model: "claude-opus-4-6",
|
|
provider: "anthropic",
|
|
},
|
|
},
|
|
},
|
|
runnerOverrides: {
|
|
sessionEntry,
|
|
sessionStore: { [sessionKey]: sessionEntry },
|
|
sessionKey,
|
|
},
|
|
queued: createQueuedRun({
|
|
run: {
|
|
config: cfg,
|
|
messageProvider: "discord",
|
|
sessionKey,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const payload = requireMockCallArg(onBlockReply, 0);
|
|
expect(payload.text).toContain("hello world!");
|
|
expect(payload.text).toContain("Usage:");
|
|
expect(payload.text).toContain("out");
|
|
});
|
|
|
|
it("renders full responseUsage followup footers without exposing the session key", async () => {
|
|
const sessionKey = "discord:channel:user";
|
|
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
|
const cfg = {
|
|
messages: {
|
|
responseUsage: "full",
|
|
usageTemplate: {
|
|
output: {
|
|
default: [
|
|
{
|
|
text: "model={model.display_name} tokens={usage.input_tokens|num}/{usage.output_tokens|num}",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: {
|
|
payloads: [{ text: "hello world!" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 1_000, output: 50 },
|
|
model: "claude-opus-4-6",
|
|
provider: "anthropic",
|
|
},
|
|
},
|
|
},
|
|
runnerOverrides: {
|
|
sessionEntry,
|
|
sessionStore: { [sessionKey]: sessionEntry },
|
|
sessionKey,
|
|
},
|
|
queued: createQueuedRun({
|
|
run: {
|
|
config: cfg,
|
|
messageProvider: "discord",
|
|
sessionKey,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const payload = requireMockCallArg(onBlockReply, 0);
|
|
expect(payload.text).toContain("hello world!");
|
|
expect(payload.text).toContain("model=claude-opus-4-6 tokens=1.0k/50");
|
|
expect(payload.text).not.toContain(sessionKey);
|
|
});
|
|
|
|
it("keeps explicit responseUsage off during followup delivery", async () => {
|
|
const sessionKey = "main";
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
responseUsage: "off",
|
|
};
|
|
const cfg = {
|
|
messages: {
|
|
responseUsage: "tokens",
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: {
|
|
payloads: [{ text: "hello world!" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 1_000, output: 50 },
|
|
model: "claude-opus-4-6",
|
|
provider: "anthropic",
|
|
},
|
|
},
|
|
},
|
|
runnerOverrides: {
|
|
sessionEntry,
|
|
sessionStore: { [sessionKey]: sessionEntry },
|
|
sessionKey,
|
|
},
|
|
queued: createQueuedRun({
|
|
run: {
|
|
config: cfg,
|
|
messageProvider: "discord",
|
|
sessionKey,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const payload = requireMockCallArg(onBlockReply, 0);
|
|
expect(payload.text).toBe("hello world!");
|
|
});
|
|
|
|
it("uses providerUsed for snapshot freshness when agent metadata overrides the run provider", async () => {
|
|
const storePath = "/tmp/openclaw-followup-usage-provider.json";
|
|
const sessionKey = "main";
|
|
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "hello world!" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 10, output: 5 },
|
|
lastCallUsage: { input: 6, output: 3 },
|
|
model: "claude-opus-4-6",
|
|
provider: "anthropic",
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: createAsyncReplySpy() },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
});
|
|
|
|
await expect(
|
|
runner(
|
|
createQueuedRun({
|
|
run: {
|
|
provider: "openai",
|
|
config: {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
anthropic: { command: "anthropic" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
},
|
|
}),
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(requireMockCallArg(persistSpy, 0).providerUsed).toBe("anthropic");
|
|
expect(requireMockCallArg(persistSpy, 0).usageIsContextSnapshot).toBeUndefined();
|
|
persistSpy.mockRestore();
|
|
});
|
|
|
|
it("preserves user-facing session model state for queued internal announce fallback", async () => {
|
|
const storePath = "/tmp/openclaw-followup-internal-announce-usage.json";
|
|
const sessionKey = "main";
|
|
const sessionEntry: SessionEntry = {
|
|
sessionId: "session",
|
|
updatedAt: Date.now(),
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
contextTokens: 200_000,
|
|
inputTokens: 1_234,
|
|
outputTokens: 56,
|
|
cacheRead: 7,
|
|
cacheWrite: 8,
|
|
totalTokens: 1_305,
|
|
totalTokensFresh: true,
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
|
registerFollowupTestSessionStore(storePath, sessionStore);
|
|
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "internal announce complete" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 39_908, output: 122 },
|
|
lastCallUsage: { input: 39_908, output: 122 },
|
|
model: "gemini-2.5-flash",
|
|
provider: "google",
|
|
},
|
|
},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: createAsyncReplySpy() },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.5",
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
});
|
|
|
|
await expect(
|
|
runner(
|
|
createQueuedRun({
|
|
run: {
|
|
inputProvenance: {
|
|
kind: "inter_session",
|
|
sourceSessionKey: "agent:codex:subagent:c34fca91",
|
|
sourceChannel: "__internal__",
|
|
sourceTool: "subagent_announce",
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
const persistCall = requireMockCallArg(persistSpy, 0);
|
|
expect(persistCall.preserveUserFacingSessionModelState).toBe(true);
|
|
expect(sessionStore[sessionKey]?.modelProvider).toBe("openai");
|
|
expect(sessionStore[sessionKey]?.model).toBe("gpt-5.5");
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(200_000);
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBe(1_234);
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBe(56);
|
|
expect(sessionStore[sessionKey]?.cacheRead).toBe(7);
|
|
expect(sessionStore[sessionKey]?.cacheWrite).toBe(8);
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(1_305);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
persistSpy.mockRestore();
|
|
});
|
|
|
|
it("does not send cross-channel payload content to dispatcher when origin routing fails", async () => {
|
|
routeReplyMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "forced route failure",
|
|
});
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }, { text: "second payload" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(2);
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
const reply = requireMockCallArg(onBlockReply, 0);
|
|
expect(reply.isError).toBe(true);
|
|
expect(String(reply.text)).toContain("could not deliver it to the originating channel");
|
|
expectNoBlockReplyText(onBlockReply, "hello world!");
|
|
expectNoBlockReplyText(onBlockReply, "second payload");
|
|
});
|
|
|
|
it("suppresses cross-channel route-failure notices for room events", async () => {
|
|
routeReplyMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "forced route failure",
|
|
});
|
|
const queued = baseQueuedRun("webchat");
|
|
queued.currentInboundEventKind = "room_event";
|
|
queued.originatingChannel = "discord";
|
|
queued.originatingTo = "channel:C1";
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }, { text: "second payload" }] },
|
|
queued,
|
|
});
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(2);
|
|
expect(onBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not emit cross-channel route-failure notice when a later payload routes", async () => {
|
|
routeReplyMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: "transient route failure",
|
|
})
|
|
.mockResolvedValueOnce({ ok: true });
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }, { text: "second payload" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(2);
|
|
expectNoBlockReplyTextIncludes(onBlockReply, "could not deliver it to the originating channel");
|
|
});
|
|
|
|
it("leaves same-channel route-failure fallback hooks to downstream delivery", async () => {
|
|
routeReplyMock.mockResolvedValue({
|
|
ok: false,
|
|
error: "forced route failure",
|
|
});
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...baseQueuedRun("discord"),
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
|
expect(runReplyPayloadSendingHookMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectBlockReplyText(onBlockReply, "hello world!");
|
|
});
|
|
|
|
it("uses dispatcher when origin routing metadata is incomplete", async () => {
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: undefined,
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectBlockReplyText(onBlockReply, "hello world!");
|
|
});
|
|
|
|
it("leaves dispatcher followup hooks to downstream delivery", async () => {
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: undefined,
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(runReplyPayloadSendingHookMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectBlockReplyText(onBlockReply, "hello world!");
|
|
});
|
|
|
|
it("does not run dispatcher followup hooks before downstream delivery", async () => {
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: undefined,
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(runReplyPayloadSendingHookMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectBlockReplyText(onBlockReply, "hello world!");
|
|
});
|
|
|
|
it("keeps message-tool-only queued followup finals private", async () => {
|
|
const queued = baseQueuedRun("discord");
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...queued,
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
run: {
|
|
...queued.run,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
} as FollowupRun,
|
|
});
|
|
|
|
const runArg = requireMockCallArg(runEmbeddedAgentMock, 0);
|
|
expect(runArg.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(runArg.forceMessageTool).toBe(true);
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets provider followup route hooks force dispatcher delivery", async () => {
|
|
resolveProviderFollowupFallbackRouteMock.mockReturnValue({
|
|
route: "dispatcher",
|
|
reason: "operator-visible review copy",
|
|
});
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectBlockReplyText(onBlockReply, "hello world!");
|
|
const routeArg = requireMockCallArg(resolveProviderFollowupFallbackRouteMock, 0);
|
|
expect(routeArg.provider).toBe("anthropic");
|
|
const context = requireRecord(routeArg.context, "provider fallback context");
|
|
expect(context.provider).toBe("anthropic");
|
|
expect(context.modelId).toBe("claude");
|
|
expect(context.originRoutable).toBe(true);
|
|
expect(context.dispatcherAvailable).toBe(true);
|
|
expect(requireRecord(context.payload, "provider fallback payload").text).toBe("hello world!");
|
|
});
|
|
|
|
it("lets provider followup route hooks drop payloads explicitly", async () => {
|
|
resolveProviderFollowupFallbackRouteMock.mockReturnValue({
|
|
route: "drop",
|
|
reason: "already delivered out of band",
|
|
});
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => {
|
|
const typing = createMockTypingController();
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing,
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined }));
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(typing.markRunComplete).toHaveBeenCalledTimes(1);
|
|
expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses JSON NO_REPLY followups without origin or dispatcher delivery", async () => {
|
|
const typing = createMockTypingController();
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.jsonSilentText }],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing,
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined }));
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(typing.markRunComplete).toHaveBeenCalledTimes(1);
|
|
expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps NO_REPLY followups with media deliverable", async () => {
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: {
|
|
payloads: [
|
|
{
|
|
text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText,
|
|
mediaUrl: "file:///tmp/followup.png",
|
|
},
|
|
],
|
|
},
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: undefined,
|
|
originatingTo: undefined,
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
const reply = requireMockCallArg(onBlockReply, 0);
|
|
expect(reply.text).toBe(DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText);
|
|
expect(reply.mediaUrl).toBe("file:///tmp/followup.png");
|
|
});
|
|
|
|
it("falls back to dispatcher when successful output has no complete origin route", async () => {
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.originChannel,
|
|
originatingTo: undefined,
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectBlockReplyText(onBlockReply, DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText);
|
|
});
|
|
|
|
it("falls back to dispatcher when same-channel origin routing fails", async () => {
|
|
routeReplyMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: "outbound adapter unavailable",
|
|
});
|
|
const queued = baseQueuedRun(" Feishu ");
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...queued,
|
|
originatingChannel: "FEISHU",
|
|
originatingTo: "ou_abc123",
|
|
run: {
|
|
...queued.run,
|
|
agentAccountId: undefined,
|
|
},
|
|
} as FollowupRun,
|
|
});
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectBlockReplyText(onBlockReply, "hello world!");
|
|
});
|
|
|
|
it("routes followups with originating account/thread metadata", async () => {
|
|
const { onBlockReply } = await runMessagingCase({
|
|
agentResult: { payloads: [{ text: "hello world!" }] },
|
|
queued: {
|
|
...baseQueuedRun("webchat"),
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "work",
|
|
originatingThreadId: "1739142736.000100",
|
|
} as FollowupRun,
|
|
});
|
|
|
|
const routeArg = requireMockCallArg(routeReplyMock, 0);
|
|
expect(routeArg.channel).toBe("discord");
|
|
expect(routeArg.to).toBe("channel:C1");
|
|
expect(routeArg.accountId).toBe("work");
|
|
expect(routeArg.threadId).toBe("1739142736.000100");
|
|
expect(routeArg.replyKind).toBe("final");
|
|
expect(routeArg.runId).toEqual(expect.any(String));
|
|
expect(onBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes queued compaction notices through the durable origin path", async () => {
|
|
runPreflightCompactionIfNeededMock.mockImplementationOnce(
|
|
async (params: {
|
|
onCompactionNotice?: (phase: "start" | "end") => Promise<void> | void;
|
|
sessionEntry?: SessionEntry;
|
|
}) => {
|
|
await params.onCompactionNotice?.("start");
|
|
await params.onCompactionNotice?.("end");
|
|
return params.sessionEntry;
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.5",
|
|
});
|
|
const queued = createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingAccountId: "work",
|
|
originatingThreadId: "1739142736.000100",
|
|
messageId: "current-msg-1",
|
|
originatingReplyToId: "quoted-parent-1",
|
|
run: {
|
|
config: {
|
|
channels: { discord: { replyToMode: "all" } },
|
|
agents: { defaults: { compaction: { notifyUser: true } } },
|
|
},
|
|
messageProvider: "discord",
|
|
},
|
|
});
|
|
|
|
await runner(queued);
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(2);
|
|
const startRoute = requireMockCallArg(routeReplyMock, 0);
|
|
const endRoute = requireMockCallArg(routeReplyMock, 1);
|
|
expect(startRoute).toMatchObject({
|
|
channel: "discord",
|
|
to: "channel:C1",
|
|
accountId: "work",
|
|
threadId: "1739142736.000100",
|
|
replyKind: "block",
|
|
mirror: false,
|
|
});
|
|
expect(requireRecord(startRoute.payload, "start payload")).toMatchObject({
|
|
text: "🧹 Compacting context...",
|
|
replyToId: "current-msg-1",
|
|
replyToCurrent: true,
|
|
isCompactionNotice: true,
|
|
});
|
|
expect(endRoute.replyKind).toBe("block");
|
|
expect(endRoute.mirror).toBe(false);
|
|
expect(requireRecord(endRoute.payload, "end payload")).toMatchObject({
|
|
text: "🧹 Compaction complete",
|
|
replyToId: "current-msg-1",
|
|
replyToCurrent: true,
|
|
isCompactionNotice: true,
|
|
});
|
|
});
|
|
|
|
it("suppresses queued compaction notices for room events", async () => {
|
|
runPreflightCompactionIfNeededMock.mockImplementationOnce(
|
|
async (params: {
|
|
onCompactionNotice?: (phase: "start" | "end") => Promise<void> | void;
|
|
sessionEntry?: SessionEntry;
|
|
}) => {
|
|
await params.onCompactionNotice?.("start");
|
|
await params.onCompactionNotice?.("end");
|
|
return params.sessionEntry;
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.5",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
currentInboundEventKind: "room_event",
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
messageId: "current-msg-1",
|
|
run: {
|
|
config: {
|
|
channels: { discord: { replyToMode: "all" } },
|
|
agents: { defaults: { compaction: { notifyUser: true } } },
|
|
},
|
|
messageProvider: "discord",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes queued compaction hook messages alongside notifyUser notices (#90185)", async () => {
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({
|
|
stream: "compaction",
|
|
data: { phase: "start", messages: ["Hook before"] },
|
|
});
|
|
await args.onAgentEvent?.({
|
|
stream: "compaction",
|
|
data: { phase: "end", completed: true, messages: ["Hook after"] },
|
|
});
|
|
return { payloads: [], meta: {} };
|
|
},
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.5",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
messageId: "current-msg-1",
|
|
run: {
|
|
config: {
|
|
channels: { discord: { replyToMode: "all" } },
|
|
agents: { defaults: { compaction: { notifyUser: true } } },
|
|
},
|
|
messageProvider: "discord",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(4);
|
|
expect(
|
|
requireRecord(requireMockCallArg(routeReplyMock, 0).payload, "hook start"),
|
|
).toMatchObject({
|
|
text: "Hook before",
|
|
replyToId: "current-msg-1",
|
|
replyToCurrent: true,
|
|
isCompactionNotice: true,
|
|
});
|
|
expect(
|
|
requireRecord(requireMockCallArg(routeReplyMock, 1).payload, "notice start"),
|
|
).toMatchObject({
|
|
text: "🧹 Compacting context...",
|
|
replyToId: "current-msg-1",
|
|
replyToCurrent: true,
|
|
isCompactionNotice: true,
|
|
});
|
|
expect(requireRecord(requireMockCallArg(routeReplyMock, 2).payload, "hook end")).toMatchObject({
|
|
text: "Hook after",
|
|
replyToId: "current-msg-1",
|
|
replyToCurrent: true,
|
|
isCompactionNotice: true,
|
|
});
|
|
expect(
|
|
requireRecord(requireMockCallArg(routeReplyMock, 3).payload, "notice end"),
|
|
).toMatchObject({
|
|
text: "🧹 Compaction complete",
|
|
replyToId: "current-msg-1",
|
|
replyToCurrent: true,
|
|
isCompactionNotice: true,
|
|
});
|
|
});
|
|
|
|
it("applies reply-to mode filtering to queued compaction notices", async () => {
|
|
runPreflightCompactionIfNeededMock.mockImplementationOnce(
|
|
async (params: {
|
|
onCompactionNotice?: (phase: "start") => Promise<void> | void;
|
|
sessionEntry?: SessionEntry;
|
|
}) => {
|
|
await params.onCompactionNotice?.("start");
|
|
return params.sessionEntry;
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.5",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingReplyToId: "reply-msg-1",
|
|
run: {
|
|
config: {
|
|
channels: { discord: { replyToMode: "off" } },
|
|
agents: { defaults: { compaction: { notifyUser: true } } },
|
|
},
|
|
messageProvider: "discord",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(routeReplyMock).toHaveBeenCalledTimes(1);
|
|
const payload = requireRecord(requireMockCallArg(routeReplyMock, 0).payload, "notice payload");
|
|
expect(payload).toMatchObject({
|
|
text: "🧹 Compacting context...",
|
|
isCompactionNotice: true,
|
|
});
|
|
expect(payload.replyToId).toBeUndefined();
|
|
});
|
|
|
|
it("plans queued compaction notices with the active fallback candidate", async () => {
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: {
|
|
run: (provider: string, model: string) => Promise<{ payloads: unknown[]; meta: object }>;
|
|
}) => ({
|
|
result: await params.run("google", "gemini-2.5-flash"),
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
}),
|
|
);
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } });
|
|
return { payloads: [], meta: {} };
|
|
},
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.5",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
run: {
|
|
config: { agents: { defaults: { compaction: { notifyUser: true } } } },
|
|
provider: "anthropic",
|
|
model: "claude",
|
|
messageProvider: "discord",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const routeArg = requireMockCallArg(resolveProviderFollowupFallbackRouteMock, 0);
|
|
expect(routeArg.provider).toBe("google");
|
|
const context = requireRecord(routeArg.context, "provider fallback context");
|
|
expect(context.provider).toBe("google");
|
|
expect(context.modelId).toBe("gemini-2.5-flash");
|
|
expect(requireRecord(context.payload, "provider fallback payload")).toMatchObject({
|
|
text: "🧹 Compacting context...",
|
|
isCompactionNotice: true,
|
|
});
|
|
});
|
|
|
|
it("suppresses queued compaction completion notices while compaction will retry", async () => {
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
|
|
}) => {
|
|
await args.onAgentEvent?.({
|
|
stream: "compaction",
|
|
data: {
|
|
phase: "end",
|
|
completed: true,
|
|
willRetry: true,
|
|
messages: ["compaction hook says done"],
|
|
},
|
|
});
|
|
return { payloads: [], meta: {} };
|
|
},
|
|
);
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "openai/gpt-5.5",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
originatingReplyToId: "reply-msg-1",
|
|
run: {
|
|
config: {
|
|
channels: { discord: { replyToMode: "all" } },
|
|
agents: { defaults: { compaction: { notifyUser: true } } },
|
|
},
|
|
messageProvider: "discord",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(routeReplyMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner typing cleanup", () => {
|
|
async function runTypingCase(agentResult: Record<string, unknown>) {
|
|
const typing = createMockTypingController();
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
meta: {},
|
|
...agentResult,
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: createAsyncReplySpy() },
|
|
typing,
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
await runner(baseQueuedRun());
|
|
return typing;
|
|
}
|
|
|
|
function expectTypingCleanup(typing: ReturnType<typeof createMockTypingController>) {
|
|
expect(typing.markRunComplete).toHaveBeenCalledTimes(1);
|
|
expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1);
|
|
}
|
|
|
|
it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
|
|
const typing = await runTypingCase({ payloads: [{ text: "NO_REPLY" }] });
|
|
expectTypingCleanup(typing);
|
|
});
|
|
|
|
it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => {
|
|
const typing = await runTypingCase({ payloads: [] });
|
|
expectTypingCleanup(typing);
|
|
});
|
|
|
|
it("calls both markRunComplete and markDispatchIdle on agent error", async () => {
|
|
const typing = createMockTypingController();
|
|
runEmbeddedAgentMock.mockRejectedValueOnce(new Error("agent exploded"));
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply: vi.fn(async () => {}) },
|
|
typing,
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
await runner(baseQueuedRun());
|
|
|
|
expectTypingCleanup(typing);
|
|
});
|
|
|
|
it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => {
|
|
const typing = createMockTypingController();
|
|
const onBlockReply = vi.fn(async () => {});
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "hello world!" }],
|
|
meta: {},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing,
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
|
|
await runner(baseQueuedRun());
|
|
|
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
|
expectTypingCleanup(typing);
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner agentDir forwarding", () => {
|
|
it("passes queued run agentDir to runEmbeddedAgent", async () => {
|
|
runEmbeddedAgentMock.mockClear();
|
|
const onBlockReply = vi.fn(async () => {});
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "hello world!" }],
|
|
messagingToolSentTexts: ["different message"],
|
|
meta: {},
|
|
});
|
|
const runner = createFollowupRunner({
|
|
opts: { onBlockReply },
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-6",
|
|
});
|
|
const agentDir = path.join("/tmp", "agent-dir");
|
|
const queued = createQueuedRun();
|
|
await runner({
|
|
...queued,
|
|
run: {
|
|
...queued.run,
|
|
agentDir,
|
|
},
|
|
});
|
|
|
|
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1);
|
|
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
|
|
expect(call.agentDir).toBe(agentDir);
|
|
});
|
|
});
|
|
|
|
describe("createFollowupRunner queued user message idempotency across fallback", () => {
|
|
it("suppresses queued user message persistence after first fallback candidate persists it", async () => {
|
|
runEmbeddedAgentMock.mockClear();
|
|
runWithModelFallbackMock.mockReset();
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
|
|
await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("upstream 500");
|
|
return {
|
|
result: await params.run("openai", "gpt-5.4"),
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
};
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onUserMessagePersisted?: (message: {
|
|
role: "user";
|
|
content: Array<{ type: "text"; text: string }>;
|
|
}) => void;
|
|
}) => {
|
|
args.onUserMessagePersisted?.({
|
|
role: "user",
|
|
content: [{ type: "text", text: "queued message" }],
|
|
});
|
|
throw new Error("upstream 500");
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "ok" }],
|
|
meta: {},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
suppressNextUserMessagePersistence: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2);
|
|
const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0);
|
|
const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1);
|
|
expect(firstAttempt.suppressNextUserMessagePersistence).toBe(false);
|
|
expect(secondAttempt.suppressNextUserMessagePersistence).toBe(true);
|
|
});
|
|
|
|
it("only persists assistant error stub on the first fallback candidate", async () => {
|
|
runEmbeddedAgentMock.mockClear();
|
|
runWithModelFallbackMock.mockReset();
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
|
|
await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("upstream 500");
|
|
await expect(params.run("anthropic", "claude-opus-4-6")).rejects.toThrow("upstream 500");
|
|
return {
|
|
result: await params.run("openai", "gpt-5.4"),
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
};
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockImplementationOnce(
|
|
async (args: {
|
|
onAssistantErrorMessagePersisted?: (message: {
|
|
role: "assistant";
|
|
content: string;
|
|
stopReason: "error";
|
|
}) => void;
|
|
}) => {
|
|
args.onAssistantErrorMessagePersisted?.({
|
|
role: "assistant",
|
|
content: "[assistant turn failed before producing content]",
|
|
stopReason: "error",
|
|
});
|
|
throw new Error("upstream 500");
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockRejectedValueOnce(new Error("upstream 500"));
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "ok" }],
|
|
meta: {},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(3);
|
|
const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0);
|
|
const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1);
|
|
const thirdAttempt = requireMockCallArg(runEmbeddedAgentMock, 2);
|
|
expect(firstAttempt.suppressAssistantErrorPersistence).toBe(false);
|
|
expect(secondAttempt.suppressAssistantErrorPersistence).toBe(true);
|
|
expect(thirdAttempt.suppressAssistantErrorPersistence).toBe(true);
|
|
});
|
|
|
|
it("does not suppress when no fallback candidate persisted the queued message", async () => {
|
|
runEmbeddedAgentMock.mockClear();
|
|
runWithModelFallbackMock.mockReset();
|
|
runWithModelFallbackMock.mockImplementationOnce(
|
|
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
|
|
await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("upstream early");
|
|
return {
|
|
result: await params.run("openai", "gpt-5.4"),
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
};
|
|
},
|
|
);
|
|
runEmbeddedAgentMock.mockRejectedValueOnce(new Error("upstream early"));
|
|
runEmbeddedAgentMock.mockResolvedValueOnce({
|
|
payloads: [{ text: "ok" }],
|
|
meta: {},
|
|
});
|
|
|
|
const runner = createFollowupRunner({
|
|
typing: createMockTypingController(),
|
|
typingMode: "instant",
|
|
defaultModel: "anthropic/claude-opus-4-7",
|
|
});
|
|
|
|
await runner(
|
|
createQueuedRun({
|
|
run: {
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
suppressNextUserMessagePersistence: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2);
|
|
const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0);
|
|
const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1);
|
|
expect(firstAttempt.suppressNextUserMessagePersistence).toBe(false);
|
|
expect(secondAttempt.suppressNextUserMessagePersistence).toBe(false);
|
|
expect(secondAttempt.suppressAssistantErrorPersistence).toBe(false);
|
|
});
|
|
});
|