Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/auto-reply/reply/abort-cutoff.runtime.test.ts +53 -0
- src/auto-reply/reply/abort-cutoff.runtime.ts +36 -0
- src/auto-reply/reply/abort-cutoff.ts +107 -0
- src/auto-reply/reply/abort-operation.ts +389 -0
- src/auto-reply/reply/abort-primitives.ts +128 -0
- src/auto-reply/reply/abort.acp-boundary.test.ts +352 -0
- src/auto-reply/reply/abort.lazy.test.ts +19 -0
- src/auto-reply/reply/abort.runtime-types.ts +26 -0
- src/auto-reply/reply/abort.runtime.ts +2 -0
- src/auto-reply/reply/abort.target-owner.test.ts +383 -0
- src/auto-reply/reply/abort.test.ts +1559 -0
- src/auto-reply/reply/abort.ts +86 -0
- src/auto-reply/reply/acp-elicitation-handler-lazy.ts +15 -0
- src/auto-reply/reply/acp-elicitation-handler.test.ts +240 -0
- src/auto-reply/reply/acp-elicitation-handler.ts +110 -0
- src/auto-reply/reply/acp-elicitation.ts +136 -0
- src/auto-reply/reply/acp-projector.test.ts +688 -0
- src/auto-reply/reply/acp-projector.ts +510 -0
- src/auto-reply/reply/acp-reset-target.ts +163 -0
- src/auto-reply/reply/acp-stream-settings.test.ts +112 -0
- src/auto-reply/reply/acp-stream-settings.ts +134 -0
- src/auto-reply/reply/agent-event-bridge.test.ts +136 -0
- src/auto-reply/reply/agent-event-bridge.ts +79 -0
- src/auto-reply/reply/agent-lifecycle-terminal.test.ts +120 -0
- src/auto-reply/reply/agent-lifecycle-terminal.ts +192 -0
- src/auto-reply/reply/agent-runner-auth-profile.ts +76 -0
- src/auto-reply/reply/agent-runner-auto-fallback.test.ts +427 -0
- src/auto-reply/reply/agent-runner-auto-fallback.ts +203 -0
- src/auto-reply/reply/agent-runner-cli-candidate.ts +514 -0
- src/auto-reply/reply/agent-runner-cli-dispatch.test.ts +1051 -0
- src/auto-reply/reply/agent-runner-cli-dispatch.ts +705 -0
- src/auto-reply/reply/agent-runner-command-output.test.ts +109 -0
- src/auto-reply/reply/agent-runner-command-output.ts +104 -0
- src/auto-reply/reply/agent-runner-compaction-accounting.ts +58 -0
- src/auto-reply/reply/agent-runner-context-recovery.test.ts +142 -0
- src/auto-reply/reply/agent-runner-context-recovery.ts +183 -0
- src/auto-reply/reply/agent-runner-core.test.ts +231 -0
- src/auto-reply/reply/agent-runner-core.ts +510 -0
- src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +716 -0
- src/auto-reply/reply/agent-runner-embedded-candidate.ts +380 -0
- src/auto-reply/reply/agent-runner-error-handler.ts +294 -0
- src/auto-reply/reply/agent-runner-event-handler.ts +296 -0
- src/auto-reply/reply/agent-runner-execute.ts +559 -0
- src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts +546 -0
- src/auto-reply/reply/agent-runner-execution-cli-admission.test.ts +397 -0
- src/auto-reply/reply/agent-runner-execution-cli-auth.test.ts +172 -0
- src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts +304 -0
- src/auto-reply/reply/agent-runner-execution-cli-delegation.test.ts +79 -0
- src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts +897 -0
- src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts +947 -0
src/auto-reply/reply/abort-cutoff.runtime.test.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Covers abort-cutoff clearing against missing persisted rows.
|
| 2 |
+
import fs from "node:fs/promises";
|
| 3 |
+
import os from "node:os";
|
| 4 |
+
import path from "node:path";
|
| 5 |
+
import { describe, expect, it } from "vitest";
|
| 6 |
+
import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
|
| 7 |
+
import type { SessionEntry } from "../../config/sessions/types.js";
|
| 8 |
+
import { clearAbortCutoffInSessionRuntime } from "./abort-cutoff.runtime.js";
|
| 9 |
+
|
| 10 |
+
async function withTempStore<T>(run: (storePath: string) => Promise<T>): Promise<T> {
|
| 11 |
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-cutoff-"));
|
| 12 |
+
try {
|
| 13 |
+
return await run(path.join(dir, "sessions.json"));
|
| 14 |
+
} finally {
|
| 15 |
+
await fs.rm(dir, { recursive: true, force: true });
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
describe("clearAbortCutoffInSessionRuntime", () => {
|
| 20 |
+
it("recreates a complete persisted row when clearing abort cutoff state", async () => {
|
| 21 |
+
await withTempStore(async (storePath) => {
|
| 22 |
+
const sessionKey = "agent:main:explicit:cutoff-missing-row";
|
| 23 |
+
const entry: SessionEntry = {
|
| 24 |
+
sessionId: "cutoff-session",
|
| 25 |
+
updatedAt: 1,
|
| 26 |
+
modelProvider: "anthropic",
|
| 27 |
+
model: "claude-opus-4-6",
|
| 28 |
+
abortCutoffMessageSid: "msg-42",
|
| 29 |
+
abortCutoffTimestamp: 123,
|
| 30 |
+
};
|
| 31 |
+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
| 32 |
+
|
| 33 |
+
const cleared = await clearAbortCutoffInSessionRuntime({
|
| 34 |
+
sessionEntry: entry,
|
| 35 |
+
sessionStore,
|
| 36 |
+
sessionKey,
|
| 37 |
+
storePath,
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const persisted = loadSessionEntry({ storePath, sessionKey });
|
| 41 |
+
expect(cleared).toBe(true);
|
| 42 |
+
expect(sessionStore[sessionKey]?.sessionId).toBe("cutoff-session");
|
| 43 |
+
expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic");
|
| 44 |
+
expect(sessionStore[sessionKey]?.abortCutoffMessageSid).toBeUndefined();
|
| 45 |
+
expect(sessionStore[sessionKey]?.abortCutoffTimestamp).toBeUndefined();
|
| 46 |
+
expect(persisted?.sessionId).toBe("cutoff-session");
|
| 47 |
+
expect(persisted?.modelProvider).toBe("anthropic");
|
| 48 |
+
expect(persisted?.model).toBe("claude-opus-4-6");
|
| 49 |
+
expect(persisted?.abortCutoffMessageSid).toBeUndefined();
|
| 50 |
+
expect(persisted?.abortCutoffTimestamp).toBeUndefined();
|
| 51 |
+
});
|
| 52 |
+
});
|
| 53 |
+
});
|
src/auto-reply/reply/abort-cutoff.runtime.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Runtime persistence helper for clearing abort-cutoff state from sessions. */
|
| 2 |
+
import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js";
|
| 3 |
+
import type { SessionEntry } from "../../config/sessions/types.js";
|
| 4 |
+
import { applyAbortCutoffToSessionEntry, hasAbortCutoff } from "./abort-cutoff.js";
|
| 5 |
+
|
| 6 |
+
/** Clears abort cutoff state in memory and persisted session storage. */
|
| 7 |
+
export async function clearAbortCutoffInSessionRuntime(params: {
|
| 8 |
+
sessionEntry?: SessionEntry;
|
| 9 |
+
sessionStore?: Record<string, SessionEntry>;
|
| 10 |
+
sessionKey?: string;
|
| 11 |
+
storePath?: string;
|
| 12 |
+
}): Promise<boolean> {
|
| 13 |
+
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
|
| 14 |
+
if (!sessionEntry || !sessionStore || !sessionKey || !hasAbortCutoff(sessionEntry)) {
|
| 15 |
+
return false;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
applyAbortCutoffToSessionEntry(sessionEntry, undefined);
|
| 19 |
+
const updatedAt = Date.now();
|
| 20 |
+
sessionEntry.updatedAt = updatedAt;
|
| 21 |
+
sessionStore[sessionKey] = sessionEntry;
|
| 22 |
+
|
| 23 |
+
if (storePath) {
|
| 24 |
+
await patchSessionEntryCore(
|
| 25 |
+
{ storePath, sessionKey },
|
| 26 |
+
() => ({
|
| 27 |
+
abortCutoffMessageSid: undefined,
|
| 28 |
+
abortCutoffTimestamp: undefined,
|
| 29 |
+
updatedAt,
|
| 30 |
+
}),
|
| 31 |
+
{ fallbackEntry: sessionEntry },
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return true;
|
| 36 |
+
}
|
src/auto-reply/reply/abort-cutoff.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Resolves abort cutoff markers used to stop stale reply streams.
|
| 2 |
+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
| 3 |
+
import type { SessionEntry } from "../../config/sessions/types.js";
|
| 4 |
+
import type { MsgContext } from "../templating.js";
|
| 5 |
+
|
| 6 |
+
export type AbortCutoff = {
|
| 7 |
+
messageSid?: string;
|
| 8 |
+
timestamp?: number;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
type SessionAbortCutoffEntry = Pick<SessionEntry, "abortCutoffMessageSid" | "abortCutoffTimestamp">;
|
| 12 |
+
|
| 13 |
+
export function resolveAbortCutoffFromContext(ctx: MsgContext): AbortCutoff | undefined {
|
| 14 |
+
const messageSid =
|
| 15 |
+
normalizeOptionalString(ctx.MessageSidFull) ?? normalizeOptionalString(ctx.MessageSid);
|
| 16 |
+
const timestamp =
|
| 17 |
+
typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp) ? ctx.Timestamp : undefined;
|
| 18 |
+
if (!messageSid && timestamp === undefined) {
|
| 19 |
+
return undefined;
|
| 20 |
+
}
|
| 21 |
+
return { messageSid, timestamp };
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function readAbortCutoffFromSessionEntry(
|
| 25 |
+
entry: SessionAbortCutoffEntry | undefined,
|
| 26 |
+
): AbortCutoff | undefined {
|
| 27 |
+
if (!entry) {
|
| 28 |
+
return undefined;
|
| 29 |
+
}
|
| 30 |
+
const messageSid = normalizeOptionalString(entry.abortCutoffMessageSid);
|
| 31 |
+
const timestamp =
|
| 32 |
+
typeof entry.abortCutoffTimestamp === "number" && Number.isFinite(entry.abortCutoffTimestamp)
|
| 33 |
+
? entry.abortCutoffTimestamp
|
| 34 |
+
: undefined;
|
| 35 |
+
if (!messageSid && timestamp === undefined) {
|
| 36 |
+
return undefined;
|
| 37 |
+
}
|
| 38 |
+
return { messageSid, timestamp };
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function hasAbortCutoff(entry: SessionAbortCutoffEntry | undefined): boolean {
|
| 42 |
+
return readAbortCutoffFromSessionEntry(entry) !== undefined;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function applyAbortCutoffToSessionEntry(
|
| 46 |
+
entry: SessionAbortCutoffEntry,
|
| 47 |
+
cutoff: AbortCutoff | undefined,
|
| 48 |
+
): void {
|
| 49 |
+
entry.abortCutoffMessageSid = cutoff?.messageSid;
|
| 50 |
+
entry.abortCutoffTimestamp = cutoff?.timestamp;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function toNumericMessageSid(value: string | undefined): bigint | undefined {
|
| 54 |
+
const trimmed = normalizeOptionalString(value);
|
| 55 |
+
if (!trimmed || !/^\d+$/.test(trimmed)) {
|
| 56 |
+
return undefined;
|
| 57 |
+
}
|
| 58 |
+
try {
|
| 59 |
+
return BigInt(trimmed);
|
| 60 |
+
} catch {
|
| 61 |
+
return undefined;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function shouldSkipMessageByAbortCutoff(params: {
|
| 66 |
+
cutoffMessageSid?: string;
|
| 67 |
+
cutoffTimestamp?: number;
|
| 68 |
+
messageSid?: string;
|
| 69 |
+
timestamp?: number;
|
| 70 |
+
}): boolean {
|
| 71 |
+
const cutoffSid = normalizeOptionalString(params.cutoffMessageSid);
|
| 72 |
+
const currentSid = normalizeOptionalString(params.messageSid);
|
| 73 |
+
if (cutoffSid && currentSid) {
|
| 74 |
+
const cutoffNumeric = toNumericMessageSid(cutoffSid);
|
| 75 |
+
const currentNumeric = toNumericMessageSid(currentSid);
|
| 76 |
+
if (cutoffNumeric !== undefined && currentNumeric !== undefined) {
|
| 77 |
+
return currentNumeric <= cutoffNumeric;
|
| 78 |
+
}
|
| 79 |
+
if (currentSid === cutoffSid) {
|
| 80 |
+
return true;
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
if (
|
| 84 |
+
typeof params.cutoffTimestamp === "number" &&
|
| 85 |
+
Number.isFinite(params.cutoffTimestamp) &&
|
| 86 |
+
typeof params.timestamp === "number" &&
|
| 87 |
+
Number.isFinite(params.timestamp)
|
| 88 |
+
) {
|
| 89 |
+
return params.timestamp <= params.cutoffTimestamp;
|
| 90 |
+
}
|
| 91 |
+
return false;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export function shouldPersistAbortCutoff(params: {
|
| 95 |
+
commandSessionKey?: string;
|
| 96 |
+
targetSessionKey?: string;
|
| 97 |
+
}): boolean {
|
| 98 |
+
const commandSessionKey = normalizeOptionalString(params.commandSessionKey);
|
| 99 |
+
const targetSessionKey = normalizeOptionalString(params.targetSessionKey);
|
| 100 |
+
if (!commandSessionKey || !targetSessionKey) {
|
| 101 |
+
return true;
|
| 102 |
+
}
|
| 103 |
+
// Native targeted /stop can run from a slash/session-control key while the
|
| 104 |
+
// actual target session uses different message id/timestamp spaces.
|
| 105 |
+
// Persist cutoff only when command source and target are the same session.
|
| 106 |
+
return commandSessionKey === targetSessionKey;
|
| 107 |
+
}
|
src/auto-reply/reply/abort-operation.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Handles abort requests and active reply run cancellation.
|
| 2 |
+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
| 3 |
+
import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
|
| 4 |
+
import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-manager-api.js";
|
| 5 |
+
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
| 6 |
+
import { resolveActiveEmbeddedRunSessionId } from "../../agents/embedded-agent-runner/active-run-projections.js";
|
| 7 |
+
import { abortEmbeddedAgentRun } from "../../agents/embedded-agent-runner/runs.js";
|
| 8 |
+
import { killAllControlledSubagentRuns } from "../../agents/subagents/registry/subagent-control.js";
|
| 9 |
+
import { listSubagentRunsForController } from "../../agents/subagents/registry/subagent-registry-read.js";
|
| 10 |
+
import {
|
| 11 |
+
resolveInternalSessionKey,
|
| 12 |
+
resolveMainSessionAlias,
|
| 13 |
+
} from "../../agents/tools/sessions-helpers.js";
|
| 14 |
+
import { resolveSessionStorePathCore } from "../../config/sessions.js";
|
| 15 |
+
import {
|
| 16 |
+
loadSessionEntry,
|
| 17 |
+
markSessionAbortTarget,
|
| 18 |
+
resolveSessionAbortTarget,
|
| 19 |
+
type SessionAbortTargetContext,
|
| 20 |
+
type SessionAbortTargetIdentity,
|
| 21 |
+
type SessionAbortTargetResult,
|
| 22 |
+
} from "../../config/sessions/session-accessor.js";
|
| 23 |
+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
| 24 |
+
import { logVerbose } from "../../globals.js";
|
| 25 |
+
import { formatErrorMessage } from "../../infra/errors.js";
|
| 26 |
+
import { isAcpSessionKey, isSubagentSessionKey } from "../../routing/session-key.js";
|
| 27 |
+
import { resolveCommandAuthorization } from "../command-auth.js";
|
| 28 |
+
import type { FinalizedRuntimeMsgContext } from "../templating.js";
|
| 29 |
+
import {
|
| 30 |
+
type AbortCutoff,
|
| 31 |
+
resolveAbortCutoffFromContext,
|
| 32 |
+
shouldPersistAbortCutoff,
|
| 33 |
+
} from "./abort-cutoff.js";
|
| 34 |
+
import { setAbortMemory } from "./abort-primitives.js";
|
| 35 |
+
import type { FastAbortRequestParams, FastAbortResult, PreparedFastAbortRequest } from "./abort.js";
|
| 36 |
+
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
|
| 37 |
+
import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js";
|
| 38 |
+
import { clearSessionQueues } from "./queue.js";
|
| 39 |
+
import { replyRunRegistry } from "./reply-run-registry.js";
|
| 40 |
+
|
| 41 |
+
export function abortSessionRunTargetWithOutcome(params: { key?: string; sessionId?: string }): {
|
| 42 |
+
active: boolean;
|
| 43 |
+
aborted: boolean;
|
| 44 |
+
retirement?: Promise<void>;
|
| 45 |
+
} {
|
| 46 |
+
const sessionIds = new Set<string>();
|
| 47 |
+
const key = normalizeOptionalString(params.key);
|
| 48 |
+
let active = key ? replyRunRegistry.isActive(key) : false;
|
| 49 |
+
if (key) {
|
| 50 |
+
const activeSessionId = resolveActiveEmbeddedRunSessionId(key);
|
| 51 |
+
if (activeSessionId) {
|
| 52 |
+
active = true;
|
| 53 |
+
sessionIds.add(activeSessionId);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
const explicitSessionId = normalizeOptionalString(params.sessionId);
|
| 57 |
+
if (explicitSessionId) {
|
| 58 |
+
sessionIds.add(explicitSessionId);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
let aborted = key ? replyRunRegistry.abort(key) : false;
|
| 62 |
+
for (const sessionId of sessionIds) {
|
| 63 |
+
aborted = abortEmbeddedAgentRun(sessionId) || aborted;
|
| 64 |
+
}
|
| 65 |
+
// Stop owns these captured IDs; a later turn may rebind the session key.
|
| 66 |
+
const retirement =
|
| 67 |
+
!active || aborted
|
| 68 |
+
? Promise.all(
|
| 69 |
+
[...sessionIds].map((sessionId) =>
|
| 70 |
+
retireSessionMcpRuntime({
|
| 71 |
+
sessionId,
|
| 72 |
+
reason: "session-stop",
|
| 73 |
+
}),
|
| 74 |
+
),
|
| 75 |
+
).then(() => undefined)
|
| 76 |
+
: undefined;
|
| 77 |
+
return { active, aborted, retirement };
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function resolveStoredSessionId(params: {
|
| 81 |
+
cfg: OpenClawConfig;
|
| 82 |
+
sessionKey: string;
|
| 83 |
+
}): string | undefined {
|
| 84 |
+
const agentId = resolveSessionAgentId({
|
| 85 |
+
sessionKey: params.sessionKey,
|
| 86 |
+
config: params.cfg,
|
| 87 |
+
});
|
| 88 |
+
const storePath = resolveSessionStorePathCore(params.cfg.session?.store, { agentId });
|
| 89 |
+
try {
|
| 90 |
+
return loadSessionEntry({
|
| 91 |
+
agentId,
|
| 92 |
+
clone: false,
|
| 93 |
+
sessionKey: params.sessionKey,
|
| 94 |
+
storePath,
|
| 95 |
+
})?.sessionId;
|
| 96 |
+
} catch {
|
| 97 |
+
return undefined;
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function resolveBoundAcpAbortTargetSessionKey(params: {
|
| 102 |
+
ctx: FinalizedRuntimeMsgContext;
|
| 103 |
+
cfg: OpenClawConfig;
|
| 104 |
+
activeSessionKey: string;
|
| 105 |
+
}): string | undefined {
|
| 106 |
+
const bindingContext = resolveConversationBindingContextFromMessage({
|
| 107 |
+
cfg: params.cfg,
|
| 108 |
+
ctx: params.ctx,
|
| 109 |
+
});
|
| 110 |
+
if (!bindingContext) {
|
| 111 |
+
return undefined;
|
| 112 |
+
}
|
| 113 |
+
return resolveEffectiveResetTargetSessionKey({
|
| 114 |
+
cfg: params.cfg,
|
| 115 |
+
channel: bindingContext.channel,
|
| 116 |
+
accountId: bindingContext.accountId,
|
| 117 |
+
conversationId: bindingContext.conversationId,
|
| 118 |
+
parentConversationId: bindingContext.parentConversationId,
|
| 119 |
+
activeSessionKey: params.activeSessionKey,
|
| 120 |
+
skipConfiguredFallbackWhenActiveSessionNonAcp: false,
|
| 121 |
+
fallbackToActiveAcpWhenUnbound: false,
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function normalizeRequesterSessionKey(
|
| 126 |
+
cfg: OpenClawConfig,
|
| 127 |
+
key: string | undefined,
|
| 128 |
+
): string | undefined {
|
| 129 |
+
const cleaned = normalizeOptionalString(key);
|
| 130 |
+
if (!cleaned) {
|
| 131 |
+
return undefined;
|
| 132 |
+
}
|
| 133 |
+
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
| 134 |
+
return resolveInternalSessionKey({ key: cleaned, alias, mainKey });
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
export async function stopSubagentsForRequester(params: {
|
| 138 |
+
cfg: OpenClawConfig;
|
| 139 |
+
requesterSessionKey?: string;
|
| 140 |
+
requesterAgentId?: string;
|
| 141 |
+
beforeKill?: Parameters<typeof killAllControlledSubagentRuns>[0]["beforeKill"];
|
| 142 |
+
}): Promise<{ stopped: number; failed: number }> {
|
| 143 |
+
const requesterKey = normalizeRequesterSessionKey(params.cfg, params.requesterSessionKey);
|
| 144 |
+
if (!requesterKey) {
|
| 145 |
+
await params.beforeKill?.();
|
| 146 |
+
return { stopped: 0, failed: 0 };
|
| 147 |
+
}
|
| 148 |
+
const controllerAgentId = resolveSessionAgentId({
|
| 149 |
+
config: params.cfg,
|
| 150 |
+
sessionKey: requesterKey,
|
| 151 |
+
fallbackAgentId: params.requesterAgentId,
|
| 152 |
+
});
|
| 153 |
+
const result = await killAllControlledSubagentRuns({
|
| 154 |
+
cfg: params.cfg,
|
| 155 |
+
controller: {
|
| 156 |
+
controllerSessionKey: requesterKey,
|
| 157 |
+
controllerAgentId,
|
| 158 |
+
callerSessionKey: requesterKey,
|
| 159 |
+
callerIsSubagent: isSubagentSessionKey(requesterKey),
|
| 160 |
+
controlScope: "children",
|
| 161 |
+
},
|
| 162 |
+
runs: listSubagentRunsForController(requesterKey),
|
| 163 |
+
suppressTaskDelivery: true,
|
| 164 |
+
beforeKill: params.beforeKill,
|
| 165 |
+
});
|
| 166 |
+
if (result.status === "error") {
|
| 167 |
+
logVerbose(`abort: failed to stop subagents for ${requesterKey}: ${result.error}`);
|
| 168 |
+
}
|
| 169 |
+
if (result.killed > 0) {
|
| 170 |
+
logVerbose(`abort: stopped ${result.killed} subagent run(s) for ${requesterKey}`);
|
| 171 |
+
}
|
| 172 |
+
return { stopped: result.killed, failed: result.status === "error" ? result.failed : 0 };
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
export async function executeFastAbortRequest(
|
| 176 |
+
params: FastAbortRequestParams,
|
| 177 |
+
request: PreparedFastAbortRequest,
|
| 178 |
+
): Promise<FastAbortResult> {
|
| 179 |
+
const { ctx, cfg } = params;
|
| 180 |
+
const { commandSessionKey, targetKey, resolveTargetAgentId } = request;
|
| 181 |
+
|
| 182 |
+
const commandAuthorized = ctx.CommandAuthorized;
|
| 183 |
+
const auth = resolveCommandAuthorization({
|
| 184 |
+
ctx,
|
| 185 |
+
cfg,
|
| 186 |
+
commandAuthorized,
|
| 187 |
+
});
|
| 188 |
+
if (!auth.isAuthorizedSender) {
|
| 189 |
+
return { handled: false, aborted: false };
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
const agentId = resolveTargetAgentId();
|
| 193 |
+
const abortKey = targetKey ?? auth.from ?? auth.to;
|
| 194 |
+
const requesterSessionKey = targetKey ?? ctx.SessionKey ?? abortKey;
|
| 195 |
+
|
| 196 |
+
if (targetKey) {
|
| 197 |
+
const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId });
|
| 198 |
+
const abortCutoffForTarget = (target: SessionAbortTargetContext): AbortCutoff | undefined =>
|
| 199 |
+
shouldPersistAbortCutoff({
|
| 200 |
+
commandSessionKey,
|
| 201 |
+
targetSessionKey: target.sessionKey,
|
| 202 |
+
})
|
| 203 |
+
? resolveAbortCutoffFromContext(ctx)
|
| 204 |
+
: undefined;
|
| 205 |
+
let resolvedAbortTarget: SessionAbortTargetIdentity | null = null;
|
| 206 |
+
try {
|
| 207 |
+
resolvedAbortTarget = resolveSessionAbortTarget({
|
| 208 |
+
agentId,
|
| 209 |
+
sessionKey: targetKey,
|
| 210 |
+
storePath,
|
| 211 |
+
});
|
| 212 |
+
} catch (error) {
|
| 213 |
+
logVerbose(
|
| 214 |
+
`abort: failed to resolve abort metadata for ${targetKey}: ${formatErrorMessage(error)}`,
|
| 215 |
+
);
|
| 216 |
+
}
|
| 217 |
+
const resolvedTargetKey = resolvedAbortTarget?.sessionKey ?? targetKey;
|
| 218 |
+
const conversationBoundAcpTargetKey = commandSessionKey
|
| 219 |
+
? resolveBoundAcpAbortTargetSessionKey({
|
| 220 |
+
ctx,
|
| 221 |
+
cfg,
|
| 222 |
+
activeSessionKey: commandSessionKey,
|
| 223 |
+
})
|
| 224 |
+
: undefined;
|
| 225 |
+
const boundAcpTargetKey = !isAcpSessionKey(resolvedTargetKey)
|
| 226 |
+
? conversationBoundAcpTargetKey
|
| 227 |
+
: undefined;
|
| 228 |
+
const abortTargetKeys = [resolvedTargetKey];
|
| 229 |
+
if (boundAcpTargetKey && boundAcpTargetKey !== resolvedTargetKey) {
|
| 230 |
+
abortTargetKeys.push(boundAcpTargetKey);
|
| 231 |
+
}
|
| 232 |
+
let aborted = false;
|
| 233 |
+
let activeAbortRejected = false;
|
| 234 |
+
const acpCancellations: Promise<void>[] = [];
|
| 235 |
+
try {
|
| 236 |
+
const { stopped, failed } = await stopSubagentsForRequester({
|
| 237 |
+
cfg,
|
| 238 |
+
requesterSessionKey,
|
| 239 |
+
requesterAgentId: agentId,
|
| 240 |
+
beforeKill: () => {
|
| 241 |
+
if (params.isCommandTargetCurrent?.() === false) {
|
| 242 |
+
throw new Error("The selected session changed before it could be stopped.");
|
| 243 |
+
}
|
| 244 |
+
try {
|
| 245 |
+
const sourceAbortKey =
|
| 246 |
+
commandSessionKey &&
|
| 247 |
+
!abortTargetKeys.includes(commandSessionKey) &&
|
| 248 |
+
conversationBoundAcpTargetKey &&
|
| 249 |
+
abortTargetKeys.includes(conversationBoundAcpTargetKey)
|
| 250 |
+
? commandSessionKey
|
| 251 |
+
: undefined;
|
| 252 |
+
const sessionIdsByKey = new Map<string, string | undefined>(
|
| 253 |
+
abortTargetKeys.map((abortTargetKey) => [
|
| 254 |
+
abortTargetKey,
|
| 255 |
+
replyRunRegistry.resolveSessionId(abortTargetKey) ??
|
| 256 |
+
(abortTargetKey === resolvedTargetKey
|
| 257 |
+
? resolvedAbortTarget?.sessionId
|
| 258 |
+
: resolveStoredSessionId({ cfg, sessionKey: abortTargetKey })),
|
| 259 |
+
]),
|
| 260 |
+
);
|
| 261 |
+
for (const abortTargetKey of abortTargetKeys) {
|
| 262 |
+
const outcome = abortSessionRunTargetWithOutcome({
|
| 263 |
+
key: abortTargetKey,
|
| 264 |
+
sessionId: sessionIdsByKey.get(abortTargetKey),
|
| 265 |
+
});
|
| 266 |
+
if (outcome.retirement) {
|
| 267 |
+
acpCancellations.push(outcome.retirement);
|
| 268 |
+
}
|
| 269 |
+
activeAbortRejected ||= outcome.active && !outcome.aborted;
|
| 270 |
+
aborted = outcome.aborted || aborted;
|
| 271 |
+
}
|
| 272 |
+
const sourceSessionId = sourceAbortKey
|
| 273 |
+
? (replyRunRegistry.resolveSessionId(sourceAbortKey) ??
|
| 274 |
+
resolveStoredSessionId({ cfg, sessionKey: sourceAbortKey }))
|
| 275 |
+
: undefined;
|
| 276 |
+
if (sourceAbortKey) {
|
| 277 |
+
const outcome = abortSessionRunTargetWithOutcome({
|
| 278 |
+
key: sourceAbortKey,
|
| 279 |
+
sessionId: sourceSessionId,
|
| 280 |
+
});
|
| 281 |
+
if (outcome.retirement) {
|
| 282 |
+
acpCancellations.push(outcome.retirement);
|
| 283 |
+
}
|
| 284 |
+
activeAbortRejected ||= outcome.active && !outcome.aborted;
|
| 285 |
+
aborted = outcome.aborted || aborted;
|
| 286 |
+
}
|
| 287 |
+
const cleared = clearSessionQueues(
|
| 288 |
+
abortTargetKeys
|
| 289 |
+
.flatMap((abortTargetKey) => [abortTargetKey, sessionIdsByKey.get(abortTargetKey)])
|
| 290 |
+
.concat(sourceAbortKey, sourceSessionId),
|
| 291 |
+
);
|
| 292 |
+
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
| 293 |
+
logVerbose(
|
| 294 |
+
`abort: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
| 295 |
+
);
|
| 296 |
+
}
|
| 297 |
+
} finally {
|
| 298 |
+
// The tree already holds queued reservations. Initiate ACP without awaiting
|
| 299 |
+
// either backend so native cleanup cannot delay signal-less ACP steer turns.
|
| 300 |
+
const acpManager = getAcpSessionManager();
|
| 301 |
+
for (const acpTargetKey of abortTargetKeys) {
|
| 302 |
+
const resolution = acpManager.resolveSession({
|
| 303 |
+
cfg,
|
| 304 |
+
sessionKey: acpTargetKey,
|
| 305 |
+
agentId: acpTargetKey === resolvedTargetKey ? agentId : undefined,
|
| 306 |
+
});
|
| 307 |
+
if (resolution.kind === "none") {
|
| 308 |
+
continue;
|
| 309 |
+
}
|
| 310 |
+
acpCancellations.push(
|
| 311 |
+
acpManager
|
| 312 |
+
.cancelSession({
|
| 313 |
+
cfg,
|
| 314 |
+
sessionKey: resolution.sessionKey,
|
| 315 |
+
agentId: resolution.agentId,
|
| 316 |
+
reason: "fast-abort",
|
| 317 |
+
})
|
| 318 |
+
.catch((error: unknown) => {
|
| 319 |
+
logVerbose(
|
| 320 |
+
`abort: ACP cancel failed for ${acpTargetKey}: ${formatErrorMessage(error)}`,
|
| 321 |
+
);
|
| 322 |
+
}),
|
| 323 |
+
);
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
return true;
|
| 327 |
+
},
|
| 328 |
+
});
|
| 329 |
+
const rejectionReason = activeAbortRejected && !aborted ? "finalizing" : undefined;
|
| 330 |
+
if (!rejectionReason) {
|
| 331 |
+
let persistedAbortTarget: SessionAbortTargetResult | null = null;
|
| 332 |
+
try {
|
| 333 |
+
persistedAbortTarget = await markSessionAbortTarget({
|
| 334 |
+
isCurrent: params.isCommandTargetCurrent,
|
| 335 |
+
scope: {
|
| 336 |
+
agentId,
|
| 337 |
+
sessionKey: targetKey,
|
| 338 |
+
storePath,
|
| 339 |
+
},
|
| 340 |
+
resolveAbortCutoff: abortCutoffForTarget,
|
| 341 |
+
});
|
| 342 |
+
} catch (error) {
|
| 343 |
+
logVerbose(
|
| 344 |
+
`abort: failed to persist abort metadata for ${targetKey}: ${formatErrorMessage(error)}`,
|
| 345 |
+
);
|
| 346 |
+
}
|
| 347 |
+
if (persistedAbortTarget?.persisted === false) {
|
| 348 |
+
logVerbose(
|
| 349 |
+
`abort: failed to persist abort metadata for ${targetKey}: ${persistedAbortTarget.persistenceError ?? "unknown error"}`,
|
| 350 |
+
);
|
| 351 |
+
}
|
| 352 |
+
const abortMemoryKey =
|
| 353 |
+
persistedAbortTarget?.sessionKey ?? resolvedAbortTarget?.sessionKey ?? abortKey;
|
| 354 |
+
const hasAbortTargetEntry = Boolean(
|
| 355 |
+
persistedAbortTarget?.entry ?? resolvedAbortTarget?.entry,
|
| 356 |
+
);
|
| 357 |
+
if (
|
| 358 |
+
persistedAbortTarget?.persisted !== true &&
|
| 359 |
+
abortMemoryKey &&
|
| 360 |
+
!hasAbortTargetEntry &&
|
| 361 |
+
params.isCommandTargetCurrent?.() !== false
|
| 362 |
+
) {
|
| 363 |
+
setAbortMemory(abortMemoryKey, true);
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
return {
|
| 367 |
+
handled: true,
|
| 368 |
+
aborted,
|
| 369 |
+
...(rejectionReason ? { rejectionReason } : {}),
|
| 370 |
+
stoppedSubagents: stopped,
|
| 371 |
+
failedSubagents: failed,
|
| 372 |
+
};
|
| 373 |
+
} finally {
|
| 374 |
+
// Join even when native signaling or metadata exits exceptionally.
|
| 375 |
+
await Promise.all(acpCancellations);
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
if (abortKey) {
|
| 380 |
+
setAbortMemory(abortKey, true);
|
| 381 |
+
}
|
| 382 |
+
const { stopped, failed } = await stopSubagentsForRequester({ cfg, requesterSessionKey });
|
| 383 |
+
return {
|
| 384 |
+
handled: true,
|
| 385 |
+
aborted: false,
|
| 386 |
+
stoppedSubagents: stopped,
|
| 387 |
+
failedSubagents: failed,
|
| 388 |
+
};
|
| 389 |
+
}
|
src/auto-reply/reply/abort-primitives.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Normalizes abort command primitives before runtime cancellation.
|
| 2 |
+
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
| 3 |
+
import { resolveGlobalMap } from "../../shared/global-singleton.js";
|
| 4 |
+
import { normalizeCommandBody } from "../commands-registry-normalize.js";
|
| 5 |
+
import type { CommandNormalizeOptions } from "../commands-registry.types.js";
|
| 6 |
+
|
| 7 |
+
const ABORT_TRIGGERS = new Set([
|
| 8 |
+
"stop",
|
| 9 |
+
"esc",
|
| 10 |
+
"abort",
|
| 11 |
+
"exit",
|
| 12 |
+
"interrupt",
|
| 13 |
+
"detente",
|
| 14 |
+
"deten",
|
| 15 |
+
"detén",
|
| 16 |
+
"arrete",
|
| 17 |
+
"arrête",
|
| 18 |
+
"停止",
|
| 19 |
+
"停下来",
|
| 20 |
+
"暂停",
|
| 21 |
+
"やめて",
|
| 22 |
+
"止めて",
|
| 23 |
+
"रुको",
|
| 24 |
+
"توقف",
|
| 25 |
+
"стоп",
|
| 26 |
+
"остановись",
|
| 27 |
+
"останови",
|
| 28 |
+
"остановить",
|
| 29 |
+
"прекрати",
|
| 30 |
+
"halt",
|
| 31 |
+
"anhalten",
|
| 32 |
+
"aufhören",
|
| 33 |
+
"hoer auf",
|
| 34 |
+
"stopp",
|
| 35 |
+
"pare",
|
| 36 |
+
"stop openclaw",
|
| 37 |
+
"openclaw stop",
|
| 38 |
+
"stop action",
|
| 39 |
+
"stop current action",
|
| 40 |
+
"stop run",
|
| 41 |
+
"stop current run",
|
| 42 |
+
"stop agent",
|
| 43 |
+
"stop the agent",
|
| 44 |
+
"stop don't do anything",
|
| 45 |
+
"stop dont do anything",
|
| 46 |
+
"stop do not do anything",
|
| 47 |
+
"stop doing anything",
|
| 48 |
+
"do not do that",
|
| 49 |
+
"please stop",
|
| 50 |
+
"stop please",
|
| 51 |
+
]);
|
| 52 |
+
const ABORT_MEMORY = resolveGlobalMap<string, boolean>(
|
| 53 |
+
Symbol.for("openclaw.abortMemory"),
|
| 54 |
+
"close-and-restart",
|
| 55 |
+
);
|
| 56 |
+
const ABORT_MEMORY_MAX = 2000;
|
| 57 |
+
const TRAILING_ABORT_PUNCTUATION_RE = /[.!?!?…,,。;;::'"’”)\]}]+$/u;
|
| 58 |
+
|
| 59 |
+
function normalizeAbortTriggerText(text: string): string {
|
| 60 |
+
return normalizeLowercaseStringOrEmpty(text)
|
| 61 |
+
.replace(/[’`]/g, "'")
|
| 62 |
+
.replace(/\s+/g, " ")
|
| 63 |
+
.replace(TRAILING_ABORT_PUNCTUATION_RE, "")
|
| 64 |
+
.trim();
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function isAbortTrigger(text?: string): boolean {
|
| 68 |
+
if (!text) {
|
| 69 |
+
return false;
|
| 70 |
+
}
|
| 71 |
+
const normalized = normalizeAbortTriggerText(text);
|
| 72 |
+
return ABORT_TRIGGERS.has(normalized);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
|
| 76 |
+
if (!text) {
|
| 77 |
+
return false;
|
| 78 |
+
}
|
| 79 |
+
const normalized = normalizeCommandBody(text, options).trim();
|
| 80 |
+
if (!normalized) {
|
| 81 |
+
return false;
|
| 82 |
+
}
|
| 83 |
+
const normalizedLower = normalizeLowercaseStringOrEmpty(normalized);
|
| 84 |
+
return (
|
| 85 |
+
normalizedLower === "/stop" ||
|
| 86 |
+
normalizeAbortTriggerText(normalizedLower) === "/stop" ||
|
| 87 |
+
isAbortTrigger(normalizedLower)
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export function getAbortMemory(key: string): boolean | undefined {
|
| 92 |
+
const normalized = key.trim();
|
| 93 |
+
if (!normalized) {
|
| 94 |
+
return undefined;
|
| 95 |
+
}
|
| 96 |
+
return ABORT_MEMORY.get(normalized);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function pruneAbortMemory(): void {
|
| 100 |
+
if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) {
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX;
|
| 104 |
+
let removed = 0;
|
| 105 |
+
for (const entryKey of ABORT_MEMORY.keys()) {
|
| 106 |
+
ABORT_MEMORY.delete(entryKey);
|
| 107 |
+
removed += 1;
|
| 108 |
+
if (removed >= excess) {
|
| 109 |
+
break;
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
export function setAbortMemory(key: string, value: boolean): void {
|
| 115 |
+
const normalized = key.trim();
|
| 116 |
+
if (!normalized) {
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
if (!value) {
|
| 120 |
+
ABORT_MEMORY.delete(normalized);
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
if (ABORT_MEMORY.has(normalized)) {
|
| 124 |
+
ABORT_MEMORY.delete(normalized);
|
| 125 |
+
}
|
| 126 |
+
ABORT_MEMORY.set(normalized, true);
|
| 127 |
+
pruneAbortMemory();
|
| 128 |
+
}
|
src/auto-reply/reply/abort.acp-boundary.test.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Channel Stop initiates native and ACP cancellation independently of either drain. */
|
| 2 |
+
import type { AcpRuntime } from "@openclaw/acp-core/runtime/types";
|
| 3 |
+
import { expect, it, vi } from "vitest";
|
| 4 |
+
import { createDeferred } from "../../../test/helpers/promise.js";
|
| 5 |
+
import { getAcpSessionManager, testing as acpTesting } from "../../acp/control-plane/manager.js";
|
| 6 |
+
import { disposeAcpSessionManagerInstance } from "../../acp/control-plane/manager.lifecycle.js";
|
| 7 |
+
import {
|
| 8 |
+
registerAcpRuntimeBackend,
|
| 9 |
+
unregisterAcpRuntimeBackend,
|
| 10 |
+
} from "../../acp/runtime/registry.js";
|
| 11 |
+
import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js";
|
| 12 |
+
import {
|
| 13 |
+
createOperationalRunInstanceRef,
|
| 14 |
+
prepareAgentRunAdmission,
|
| 15 |
+
} from "../../agents/admitted-run-context.js";
|
| 16 |
+
import {
|
| 17 |
+
clearActiveEmbeddedRun,
|
| 18 |
+
setActiveEmbeddedRun,
|
| 19 |
+
} from "../../agents/embedded-agent-runner/runs.js";
|
| 20 |
+
import { createEmbeddedRunHandle } from "../../agents/embedded-agent-runner/runs.test-support.js";
|
| 21 |
+
import { registerSubagentRun } from "../../agents/subagents/registry/subagent-registry.js";
|
| 22 |
+
import { writeSubagentSessionEntry } from "../../agents/subagents/registry/subagent-registry.persistence.test-support.js";
|
| 23 |
+
import { getSubagentRunByChildSessionKey } from "../../agents/subagents/registry/subagent-registry.test-helpers.js";
|
| 24 |
+
import { enqueueSwarmRun, releaseSwarmRun } from "../../agents/subagents/swarm/swarm-scheduler.js";
|
| 25 |
+
import { getRuntimeConfig } from "../../config/config.js";
|
| 26 |
+
import { resolveSessionStorePathCore } from "../../config/sessions/paths.js";
|
| 27 |
+
import { loadExactSessionEntryReadOnly } from "../../config/sessions/session-accessor.js";
|
| 28 |
+
import { useChatAbortRegistryFixture } from "../../gateway/server-methods/chat.abort-registry.test-support.js";
|
| 29 |
+
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
| 30 |
+
import {
|
| 31 |
+
captureActivePluginRegistrySnapshot,
|
| 32 |
+
restoreActivePluginRegistrySnapshot,
|
| 33 |
+
setActivePluginRegistry,
|
| 34 |
+
} from "../../plugins/runtime.js";
|
| 35 |
+
import {
|
| 36 |
+
beginSessionWorkAdmission,
|
| 37 |
+
isSessionWorkAdmissionActive,
|
| 38 |
+
type SessionWorkAdmissionLease,
|
| 39 |
+
} from "../../sessions/session-lifecycle-admission.js";
|
| 40 |
+
import {
|
| 41 |
+
createChannelTestPluginBase,
|
| 42 |
+
createTestRegistry,
|
| 43 |
+
} from "../../test-utils/channel-plugins.js";
|
| 44 |
+
import { tryFastAbortFromMessage } from "./abort.js";
|
| 45 |
+
import { createReplyOperation } from "./reply-run-registry.js";
|
| 46 |
+
import { buildTestCtx } from "./test-ctx.js";
|
| 47 |
+
|
| 48 |
+
const fixture = useChatAbortRegistryFixture();
|
| 49 |
+
|
| 50 |
+
it.each(
|
| 51 |
+
["idle", "active", "native failure"].flatMap((scenario) =>
|
| 52 |
+
["resolve", "reject"].map((completion) => ({ scenario, completion })),
|
| 53 |
+
),
|
| 54 |
+
)(
|
| 55 |
+
"native and bound ACP cancellation initiate before either drain ($scenario, $completion)",
|
| 56 |
+
async ({ scenario, completion }) => {
|
| 57 |
+
const active = scenario !== "idle";
|
| 58 |
+
const nativeFailure = scenario === "native failure";
|
| 59 |
+
const nativeError = new Error("native cancellation failed");
|
| 60 |
+
const sourceKey = "agent:main:stop-test:direct:room";
|
| 61 |
+
const acpKey = "agent:main:acp:bound-stop";
|
| 62 |
+
const runningKey = "agent:main:subagent:running";
|
| 63 |
+
const queuedKey = "agent:main:subagent:queued";
|
| 64 |
+
for (const [runId, sessionKey] of [
|
| 65 |
+
["source", sourceKey],
|
| 66 |
+
["acp", acpKey],
|
| 67 |
+
["running", runningKey],
|
| 68 |
+
["queued", queuedKey],
|
| 69 |
+
] as const) {
|
| 70 |
+
await writeSubagentSessionEntry({
|
| 71 |
+
stateDir: fixture.stateDir,
|
| 72 |
+
agentId: "main",
|
| 73 |
+
sessionKey,
|
| 74 |
+
defaultSessionId: `${runId}-session`,
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
const cfg = getRuntimeConfig();
|
| 78 |
+
const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId: "main" });
|
| 79 |
+
const registry = captureActivePluginRegistrySnapshot();
|
| 80 |
+
setActivePluginRegistry(
|
| 81 |
+
createTestRegistry([
|
| 82 |
+
{
|
| 83 |
+
pluginId: "stop-test",
|
| 84 |
+
source: "test",
|
| 85 |
+
plugin: {
|
| 86 |
+
...createChannelTestPluginBase({ id: "stop-test" }),
|
| 87 |
+
conversationBindings: { supportsCurrentConversationBinding: true },
|
| 88 |
+
},
|
| 89 |
+
},
|
| 90 |
+
]),
|
| 91 |
+
);
|
| 92 |
+
const binding = await getSessionBindingService().bind({
|
| 93 |
+
targetSessionKey: acpKey,
|
| 94 |
+
targetKind: "session",
|
| 95 |
+
conversation: { channel: "stop-test", accountId: "default", conversationId: "room" },
|
| 96 |
+
placement: "current",
|
| 97 |
+
});
|
| 98 |
+
const entered = createDeferred();
|
| 99 |
+
const proceed = createDeferred();
|
| 100 |
+
const cancelFinished = createDeferred();
|
| 101 |
+
const turnStarted = createDeferred<AbortSignal>();
|
| 102 |
+
const finishTurn = createDeferred();
|
| 103 |
+
const nativeInterrupted = createDeferred();
|
| 104 |
+
const cancel = vi.fn<AcpRuntime["cancel"]>(async () => {
|
| 105 |
+
entered.resolve();
|
| 106 |
+
await proceed.promise;
|
| 107 |
+
cancelFinished.resolve();
|
| 108 |
+
if (completion === "reject") {
|
| 109 |
+
throw new Error("backend cancellation failed");
|
| 110 |
+
}
|
| 111 |
+
});
|
| 112 |
+
registerAcpRuntimeBackend({
|
| 113 |
+
id: "stop-test",
|
| 114 |
+
runtime: {
|
| 115 |
+
ensureSession: async ({ sessionKey }) => ({
|
| 116 |
+
sessionKey,
|
| 117 |
+
backend: "stop-test",
|
| 118 |
+
runtimeSessionName: "mock-runtime",
|
| 119 |
+
}),
|
| 120 |
+
async *runTurn({ signal }) {
|
| 121 |
+
if (!signal) {
|
| 122 |
+
throw new Error("ACP manager did not provide its active turn signal");
|
| 123 |
+
}
|
| 124 |
+
turnStarted.resolve(signal);
|
| 125 |
+
await finishTurn.promise;
|
| 126 |
+
yield { type: "done", status: "cancelled" };
|
| 127 |
+
},
|
| 128 |
+
cancel,
|
| 129 |
+
close: async () => {},
|
| 130 |
+
},
|
| 131 |
+
});
|
| 132 |
+
acpTesting.resetAcpSessionManagerForTests();
|
| 133 |
+
const manager = getAcpSessionManager();
|
| 134 |
+
const native = createReplyOperation({
|
| 135 |
+
sessionKey: sourceKey,
|
| 136 |
+
sessionId: "source-session",
|
| 137 |
+
resetTriggered: false,
|
| 138 |
+
});
|
| 139 |
+
native.attachBackend({
|
| 140 |
+
kind: "embedded",
|
| 141 |
+
isStreaming: () => true,
|
| 142 |
+
cancel: () => {
|
| 143 |
+
releaseSwarmRun("capacity");
|
| 144 |
+
if (nativeFailure) {
|
| 145 |
+
throw nativeError;
|
| 146 |
+
}
|
| 147 |
+
},
|
| 148 |
+
});
|
| 149 |
+
const runningAbort = vi.fn();
|
| 150 |
+
const handle = createEmbeddedRunHandle({ runId: "running", abort: runningAbort });
|
| 151 |
+
setActiveEmbeddedRun("running-session", handle, runningKey);
|
| 152 |
+
const selectedDispatch = vi.fn(async () => {});
|
| 153 |
+
const survivorDispatch = vi.fn(async () => {});
|
| 154 |
+
let pending: ReturnType<typeof tryFastAbortFromMessage> | undefined;
|
| 155 |
+
let childAdmission: SessionWorkAdmissionLease | undefined;
|
| 156 |
+
const preparedAdmission = prepareAgentRunAdmission({
|
| 157 |
+
cfg,
|
| 158 |
+
operationalRunInstance: createOperationalRunInstanceRef("acp-steer"),
|
| 159 |
+
facts: {
|
| 160 |
+
runId: "acp-steer",
|
| 161 |
+
agentId: "main",
|
| 162 |
+
ingress: { kind: "acp", boundary: "acp.command.steer", state: "absent" },
|
| 163 |
+
},
|
| 164 |
+
});
|
| 165 |
+
let turn: Promise<void> | undefined;
|
| 166 |
+
let acpSignal: AbortSignal | undefined;
|
| 167 |
+
let stopSettled = false;
|
| 168 |
+
try {
|
| 169 |
+
await manager.initializeSession({
|
| 170 |
+
cfg,
|
| 171 |
+
sessionKey: acpKey,
|
| 172 |
+
agent: "main",
|
| 173 |
+
mode: "persistent",
|
| 174 |
+
backendId: "stop-test",
|
| 175 |
+
});
|
| 176 |
+
if (active) {
|
| 177 |
+
const admittedRunContext = await preparedAdmission.admit("acp");
|
| 178 |
+
// Match /acp steer: the manager owns the only cancellation signal.
|
| 179 |
+
turn = manager
|
| 180 |
+
.runTurn({
|
| 181 |
+
cfg,
|
| 182 |
+
admittedRunContext,
|
| 183 |
+
sessionKey: acpKey,
|
| 184 |
+
requestId: "acp-steer",
|
| 185 |
+
mode: "steer",
|
| 186 |
+
provenance: "agent",
|
| 187 |
+
text: "keep working",
|
| 188 |
+
})
|
| 189 |
+
.finally(() => preparedAdmission.close());
|
| 190 |
+
acpSignal = await Promise.race([
|
| 191 |
+
turnStarted.promise,
|
| 192 |
+
turn.then(() => {
|
| 193 |
+
throw new Error("ACP steer completed before the active-turn gate");
|
| 194 |
+
}),
|
| 195 |
+
]);
|
| 196 |
+
expect(acpSignal.aborted).toBe(false);
|
| 197 |
+
expect(manager.getObservabilitySnapshot().turns.active).toBe(1);
|
| 198 |
+
childAdmission = await beginSessionWorkAdmission({
|
| 199 |
+
scope: storePath,
|
| 200 |
+
identities: [runningKey, "running-session"],
|
| 201 |
+
assertAllowed: () => {},
|
| 202 |
+
onInterrupt: () => nativeInterrupted.resolve(),
|
| 203 |
+
});
|
| 204 |
+
}
|
| 205 |
+
for (const [runId, childSessionKey] of [
|
| 206 |
+
["running", runningKey],
|
| 207 |
+
["queued", queuedKey],
|
| 208 |
+
] as const) {
|
| 209 |
+
registerSubagentRun({
|
| 210 |
+
runId,
|
| 211 |
+
childSessionKey,
|
| 212 |
+
requesterSessionKey: sourceKey,
|
| 213 |
+
requesterAgentId: "main",
|
| 214 |
+
requesterDisplayKey: sourceKey,
|
| 215 |
+
task: runId,
|
| 216 |
+
cleanup: "keep",
|
| 217 |
+
collect: true,
|
| 218 |
+
queued: runId === "queued",
|
| 219 |
+
expectsCompletionMessage: false,
|
| 220 |
+
});
|
| 221 |
+
}
|
| 222 |
+
for (const [runId, start] of [
|
| 223 |
+
["queued", selectedDispatch],
|
| 224 |
+
["survivor", survivorDispatch],
|
| 225 |
+
] as const) {
|
| 226 |
+
enqueueSwarmRun({
|
| 227 |
+
groupId: "bound-acp",
|
| 228 |
+
runId,
|
| 229 |
+
maxConcurrent: 1,
|
| 230 |
+
activeRunIds: ["capacity"],
|
| 231 |
+
start,
|
| 232 |
+
onStartFailure: () => true,
|
| 233 |
+
});
|
| 234 |
+
}
|
| 235 |
+
pending = tryFastAbortFromMessage({
|
| 236 |
+
cfg,
|
| 237 |
+
ctx: buildTestCtx({
|
| 238 |
+
SessionKey: sourceKey,
|
| 239 |
+
CommandBody: "/stop",
|
| 240 |
+
RawBody: "/stop",
|
| 241 |
+
CommandAuthorized: true,
|
| 242 |
+
Provider: "stop-test",
|
| 243 |
+
Surface: "stop-test",
|
| 244 |
+
From: "stop-test:room",
|
| 245 |
+
To: "stop-test:room",
|
| 246 |
+
MessageSid: "77",
|
| 247 |
+
Timestamp: 1234567890000,
|
| 248 |
+
}),
|
| 249 |
+
}).finally(() => {
|
| 250 |
+
stopSettled = true;
|
| 251 |
+
});
|
| 252 |
+
const outcome = pending.then(
|
| 253 |
+
(result) => ({ result }),
|
| 254 |
+
(error: unknown) => ({ error }),
|
| 255 |
+
);
|
| 256 |
+
if (nativeFailure) {
|
| 257 |
+
await Promise.race([
|
| 258 |
+
entered.promise,
|
| 259 |
+
outcome.then(() => {
|
| 260 |
+
throw new Error("Native failure escaped before ACP cancellation started");
|
| 261 |
+
}),
|
| 262 |
+
]);
|
| 263 |
+
expect(native.abortSignal.aborted).toBe(true);
|
| 264 |
+
expect(acpSignal?.aborted).toBe(true);
|
| 265 |
+
expect(stopSettled).toBe(false);
|
| 266 |
+
proceed.resolve();
|
| 267 |
+
expect(await outcome).toEqual({ error: nativeError });
|
| 268 |
+
expect(cancel).toHaveBeenCalledOnce();
|
| 269 |
+
return;
|
| 270 |
+
}
|
| 271 |
+
if (active) {
|
| 272 |
+
await nativeInterrupted.promise;
|
| 273 |
+
expect(native.abortSignal.aborted).toBe(true);
|
| 274 |
+
expect(isSessionWorkAdmissionActive(storePath, [runningKey, "running-session"])).toBe(true);
|
| 275 |
+
await vi.waitFor(() => expect(acpSignal?.aborted).toBe(true));
|
| 276 |
+
expect(cancel).toHaveBeenCalledOnce();
|
| 277 |
+
expect(stopSettled).toBe(false);
|
| 278 |
+
expect(selectedDispatch).not.toHaveBeenCalled();
|
| 279 |
+
if (completion === "reject") {
|
| 280 |
+
proceed.resolve();
|
| 281 |
+
await cancelFinished.promise;
|
| 282 |
+
// Let rejection observers run while the native admission is still retained.
|
| 283 |
+
await new Promise<void>((resolve) => {
|
| 284 |
+
setImmediate(resolve);
|
| 285 |
+
});
|
| 286 |
+
expect(stopSettled).toBe(false);
|
| 287 |
+
}
|
| 288 |
+
childAdmission?.release();
|
| 289 |
+
}
|
| 290 |
+
await Promise.race([
|
| 291 |
+
entered.promise,
|
| 292 |
+
pending.then(() => {
|
| 293 |
+
throw new Error("Stop missed the bound ACP backend");
|
| 294 |
+
}),
|
| 295 |
+
]);
|
| 296 |
+
expect(native.abortSignal.aborted).toBe(true);
|
| 297 |
+
// Synchronize on native terminal state, never on the still-held ACP completion.
|
| 298 |
+
await vi.waitFor(() => expect(runningAbort).toHaveBeenCalledOnce());
|
| 299 |
+
await vi.waitFor(() => expect(survivorDispatch).toHaveBeenCalledOnce());
|
| 300 |
+
expect(selectedDispatch).not.toHaveBeenCalled();
|
| 301 |
+
for (const key of [runningKey, queuedKey]) {
|
| 302 |
+
expect(getSubagentRunByChildSessionKey(key)?.endedReason).toBe("subagent-killed");
|
| 303 |
+
}
|
| 304 |
+
await vi.waitFor(() =>
|
| 305 |
+
expect(loadExactSessionEntryReadOnly({ sessionKey: sourceKey })?.entry).toMatchObject({
|
| 306 |
+
abortedLastRun: true,
|
| 307 |
+
abortCutoffMessageSid: "77",
|
| 308 |
+
abortCutoffTimestamp: 1234567890000,
|
| 309 |
+
}),
|
| 310 |
+
);
|
| 311 |
+
if (!active || completion !== "reject") {
|
| 312 |
+
expect(stopSettled).toBe(false);
|
| 313 |
+
}
|
| 314 |
+
proceed.resolve();
|
| 315 |
+
expect(await pending).toEqual({
|
| 316 |
+
handled: true,
|
| 317 |
+
aborted: true,
|
| 318 |
+
stoppedSubagents: 2,
|
| 319 |
+
failedSubagents: 0,
|
| 320 |
+
});
|
| 321 |
+
expect(cancel).toHaveBeenCalledExactlyOnceWith({
|
| 322 |
+
handle: expect.objectContaining({ sessionKey: acpKey }),
|
| 323 |
+
reason: "fast-abort",
|
| 324 |
+
});
|
| 325 |
+
finishTurn.resolve();
|
| 326 |
+
await turn;
|
| 327 |
+
expect(readAcpSessionMeta({ cfg, sessionKey: acpKey })?.state).toBe(
|
| 328 |
+
!active && completion === "reject" ? "error" : "idle",
|
| 329 |
+
);
|
| 330 |
+
} finally {
|
| 331 |
+
childAdmission?.release();
|
| 332 |
+
proceed.resolve();
|
| 333 |
+
finishTurn.resolve();
|
| 334 |
+
await pending?.catch(() => undefined);
|
| 335 |
+
await turn?.catch(() => undefined);
|
| 336 |
+
preparedAdmission.close();
|
| 337 |
+
native.complete();
|
| 338 |
+
clearActiveEmbeddedRun("running-session", handle, runningKey);
|
| 339 |
+
releaseSwarmRun("capacity");
|
| 340 |
+
releaseSwarmRun("queued");
|
| 341 |
+
releaseSwarmRun("survivor");
|
| 342 |
+
await disposeAcpSessionManagerInstance(manager, "test-cleanup");
|
| 343 |
+
acpTesting.resetAcpSessionManagerForTests();
|
| 344 |
+
unregisterAcpRuntimeBackend("stop-test");
|
| 345 |
+
await getSessionBindingService().unbind({
|
| 346 |
+
bindingId: binding.bindingId,
|
| 347 |
+
reason: "test-cleanup",
|
| 348 |
+
});
|
| 349 |
+
restoreActivePluginRegistrySnapshot(registry);
|
| 350 |
+
}
|
| 351 |
+
},
|
| 352 |
+
);
|
src/auto-reply/reply/abort.lazy.test.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, it, vi } from "vitest";
|
| 2 |
+
import { buildTestCtx } from "./test-ctx.js";
|
| 3 |
+
|
| 4 |
+
vi.mock("../../agents/subagents/registry/subagent-control.js", () => {
|
| 5 |
+
throw new Error("ordinary messages must not initialize cancellation owners");
|
| 6 |
+
});
|
| 7 |
+
|
| 8 |
+
it.each(["direct", "group"])(
|
| 9 |
+
"keeps %s ordinary messages outside cancellation runtime",
|
| 10 |
+
async (chatType) => {
|
| 11 |
+
const { tryFastAbortFromMessage } = await import("./abort.js");
|
| 12 |
+
await expect(
|
| 13 |
+
tryFastAbortFromMessage({
|
| 14 |
+
ctx: buildTestCtx({ CommandBody: "continue the conversation", ChatType: chatType }),
|
| 15 |
+
cfg: {},
|
| 16 |
+
}),
|
| 17 |
+
).resolves.toEqual({ handled: false, aborted: false });
|
| 18 |
+
},
|
| 19 |
+
);
|
src/auto-reply/reply/abort.runtime-types.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared abort runtime types for cancellation and cutoff persistence.
|
| 2 |
+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
| 3 |
+
import type { FinalizedRuntimeMsgContext } from "../templating.js";
|
| 4 |
+
|
| 5 |
+
/** Result from the fast abort path before normal reply dispatch starts. */
|
| 6 |
+
type FastAbortResult = {
|
| 7 |
+
handled: boolean;
|
| 8 |
+
aborted: boolean;
|
| 9 |
+
rejectionReason?: "finalizing";
|
| 10 |
+
stoppedSubagents?: number;
|
| 11 |
+
failedSubagents?: number;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
/** Runtime hook that may convert a message into an immediate abort action. */
|
| 15 |
+
export type TryFastAbortFromMessage = (params: {
|
| 16 |
+
ctx: FinalizedRuntimeMsgContext;
|
| 17 |
+
cfg: OpenClawConfig;
|
| 18 |
+
isCommandTargetCurrent?: () => boolean;
|
| 19 |
+
}) => Promise<FastAbortResult>;
|
| 20 |
+
|
| 21 |
+
/** Formats the user-visible abort acknowledgement text. */
|
| 22 |
+
export type FormatAbortReplyText = (
|
| 23 |
+
stoppedSubagents?: number,
|
| 24 |
+
rejectionReason?: FastAbortResult["rejectionReason"],
|
| 25 |
+
failedSubagents?: number,
|
| 26 |
+
) => string;
|
src/auto-reply/reply/abort.runtime.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Runtime facade for fast abort handling used by reply dispatch. */
|
| 2 |
+
export { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
src/auto-reply/reply/abort.target-owner.test.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from "node:path";
|
| 2 |
+
import { afterAll, afterEach, beforeAll, describe, expect, it, onTestFinished } from "vitest";
|
| 3 |
+
import { createDeferred } from "../../../test/helpers/promise.js";
|
| 4 |
+
import {
|
| 5 |
+
loadSessionEntry,
|
| 6 |
+
replaceSessionEntry,
|
| 7 |
+
replaceSessionEntrySync,
|
| 8 |
+
} from "../../config/sessions/session-accessor.js";
|
| 9 |
+
import {
|
| 10 |
+
getConversationSession,
|
| 11 |
+
normalizeSessionDeliveryState,
|
| 12 |
+
patchSessionEntry,
|
| 13 |
+
} from "../../plugin-sdk/session-store-runtime.js";
|
| 14 |
+
import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js";
|
| 15 |
+
import { tryFastAbortFromMessage } from "./abort.js";
|
| 16 |
+
import { handleStopCommand } from "./commands-session-abort.js";
|
| 17 |
+
import type { HandleCommandsParams } from "./commands-types.js";
|
| 18 |
+
import { parseInlineSessionDirectives } from "./directive-handling.parse.js";
|
| 19 |
+
import { clearSessionQueues, enqueueFollowupRun, getFollowupQueueDepth } from "./queue.js";
|
| 20 |
+
import { createQueueTestRun } from "./queue.test-helpers.js";
|
| 21 |
+
import { createReplyOperation } from "./reply-run-registry.js";
|
| 22 |
+
import { testing } from "./reply-run-registry.test-support.js";
|
| 23 |
+
import { buildTestCtx } from "./test-ctx.js";
|
| 24 |
+
|
| 25 |
+
const dirs = createSuiteTempRootTracker({ prefix: "openclaw-stop-owner-" });
|
| 26 |
+
const sessionKey = "agent:main:slack:group:g12345678";
|
| 27 |
+
|
| 28 |
+
beforeAll(() => dirs.setup());
|
| 29 |
+
afterAll(() => dirs.cleanup());
|
| 30 |
+
afterEach(() => {
|
| 31 |
+
clearSessionQueues([sessionKey]);
|
| 32 |
+
testing.resetReplyRunRegistry();
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
async function setupStop() {
|
| 36 |
+
const root = await dirs.make("case");
|
| 37 |
+
const storePath = path.join(root, "sessions.json");
|
| 38 |
+
const cfg = { session: { store: storePath }, commands: { allowFrom: { "*": ["*"] } } };
|
| 39 |
+
const entry = { sessionId: "session-a", updatedAt: Date.now() };
|
| 40 |
+
await replaceSessionEntry({ storePath, sessionKey }, entry);
|
| 41 |
+
const ctx = buildTestCtx({
|
| 42 |
+
Body: "/stop",
|
| 43 |
+
CommandBody: "/stop",
|
| 44 |
+
RawBody: "/stop",
|
| 45 |
+
Provider: "slack",
|
| 46 |
+
Surface: "slack",
|
| 47 |
+
From: "slack:U12345678",
|
| 48 |
+
To: "slack:G12345678",
|
| 49 |
+
SenderId: "U12345678",
|
| 50 |
+
CommandSource: "native",
|
| 51 |
+
CommandAuthorized: true,
|
| 52 |
+
SessionKey: "slack:slash:U12345678",
|
| 53 |
+
CommandTargetSessionKey: sessionKey,
|
| 54 |
+
});
|
| 55 |
+
const isCommandTargetCurrent = () =>
|
| 56 |
+
loadSessionEntry({ storePath, sessionKey })?.sessionId === entry.sessionId;
|
| 57 |
+
const params: HandleCommandsParams = {
|
| 58 |
+
cfg,
|
| 59 |
+
ctx,
|
| 60 |
+
command: {
|
| 61 |
+
commandBodyNormalized: "/stop",
|
| 62 |
+
rawBodyNormalized: "/stop",
|
| 63 |
+
isAuthorizedSender: true,
|
| 64 |
+
senderIsOwner: true,
|
| 65 |
+
senderId: "U12345678",
|
| 66 |
+
channel: "slack",
|
| 67 |
+
surface: "slack",
|
| 68 |
+
ownerList: [],
|
| 69 |
+
},
|
| 70 |
+
agentId: "main",
|
| 71 |
+
directives: parseInlineSessionDirectives(""),
|
| 72 |
+
elevated: { enabled: false, allowed: false, failures: [] },
|
| 73 |
+
sessionKey: ctx.SessionKey ?? "",
|
| 74 |
+
sessionStore: { [sessionKey]: entry },
|
| 75 |
+
storePath,
|
| 76 |
+
workspaceDir: root,
|
| 77 |
+
defaultGroupActivation: () => "always",
|
| 78 |
+
resolvedVerboseLevel: "off",
|
| 79 |
+
resolvedReasoningLevel: "off",
|
| 80 |
+
resolvedBlockStreamingBreak: "text_end",
|
| 81 |
+
resolveDefaultThinkingLevel: async () => undefined,
|
| 82 |
+
provider: "openai",
|
| 83 |
+
model: "gpt-test",
|
| 84 |
+
contextTokens: 1000,
|
| 85 |
+
isGroup: true,
|
| 86 |
+
opts: { isCommandTargetCurrent },
|
| 87 |
+
};
|
| 88 |
+
return { cfg, ctx, entry, params, storePath, isCommandTargetCurrent };
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
describe.each(["fast", "command"] as const)("%s Stop current owner", (pathKind) => {
|
| 92 |
+
it("retires an idle MCP runtime on explicit Stop", async () => {
|
| 93 |
+
const state = await setupStop();
|
| 94 |
+
const { getOrCreateSessionMcpRuntime } =
|
| 95 |
+
await import("../../agents/agent-bundle-mcp-manager.test-support.js");
|
| 96 |
+
const { getSessionMcpRuntimeManagerForTesting } =
|
| 97 |
+
await import("../../agents/agent-bundle-mcp-manager-api.js");
|
| 98 |
+
const manager = getSessionMcpRuntimeManagerForTesting();
|
| 99 |
+
try {
|
| 100 |
+
await getOrCreateSessionMcpRuntime({
|
| 101 |
+
sessionId: state.entry.sessionId,
|
| 102 |
+
sessionKey,
|
| 103 |
+
workspaceDir: state.params.workspaceDir,
|
| 104 |
+
cfg: { mcp: { servers: {} } },
|
| 105 |
+
manifestRegistry: { plugins: [] },
|
| 106 |
+
});
|
| 107 |
+
if (pathKind === "fast") {
|
| 108 |
+
await tryFastAbortFromMessage({
|
| 109 |
+
ctx: state.ctx,
|
| 110 |
+
cfg: state.cfg,
|
| 111 |
+
isCommandTargetCurrent: state.isCommandTargetCurrent,
|
| 112 |
+
});
|
| 113 |
+
} else {
|
| 114 |
+
await handleStopCommand(state.params, true);
|
| 115 |
+
}
|
| 116 |
+
expect(manager.peekSession({ sessionId: state.entry.sessionId })).toBeUndefined();
|
| 117 |
+
} finally {
|
| 118 |
+
await manager.disposeAll();
|
| 119 |
+
}
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
it("stops the selected agent's global session in an explicit roster", async () => {
|
| 123 |
+
const state = await setupStop();
|
| 124 |
+
const agentId = "selected";
|
| 125 |
+
const globalKey = "global";
|
| 126 |
+
const storePath = path.join(
|
| 127 |
+
state.params.workspaceDir,
|
| 128 |
+
"agents",
|
| 129 |
+
agentId,
|
| 130 |
+
"sessions",
|
| 131 |
+
"sessions.json",
|
| 132 |
+
);
|
| 133 |
+
const cfg = {
|
| 134 |
+
...state.cfg,
|
| 135 |
+
agents: { ownership: "explicit" as const, entries: { other: {}, [agentId]: {} } },
|
| 136 |
+
session: {
|
| 137 |
+
scope: "global" as const,
|
| 138 |
+
store: path.join(
|
| 139 |
+
state.params.workspaceDir,
|
| 140 |
+
"agents",
|
| 141 |
+
"{agentId}",
|
| 142 |
+
"sessions",
|
| 143 |
+
"sessions.json",
|
| 144 |
+
),
|
| 145 |
+
},
|
| 146 |
+
};
|
| 147 |
+
const scope = { agentId, storePath, sessionKey: globalKey };
|
| 148 |
+
await replaceSessionEntry(scope, state.entry);
|
| 149 |
+
const ctx = { ...state.ctx, AgentId: agentId, CommandTargetSessionKey: globalKey };
|
| 150 |
+
const isCommandTargetCurrent = () =>
|
| 151 |
+
loadSessionEntry(scope)?.sessionId === state.entry.sessionId;
|
| 152 |
+
const operation = createReplyOperation({
|
| 153 |
+
sessionKey: globalKey,
|
| 154 |
+
sessionId: state.entry.sessionId,
|
| 155 |
+
resetTriggered: false,
|
| 156 |
+
});
|
| 157 |
+
operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
|
| 158 |
+
try {
|
| 159 |
+
const result = await (pathKind === "fast"
|
| 160 |
+
? tryFastAbortFromMessage({ cfg, ctx, isCommandTargetCurrent })
|
| 161 |
+
: handleStopCommand(
|
| 162 |
+
{
|
| 163 |
+
...state.params,
|
| 164 |
+
cfg,
|
| 165 |
+
ctx,
|
| 166 |
+
agentId,
|
| 167 |
+
sessionKey: globalKey,
|
| 168 |
+
sessionStore: { [globalKey]: state.entry },
|
| 169 |
+
storePath,
|
| 170 |
+
opts: { isCommandTargetCurrent },
|
| 171 |
+
},
|
| 172 |
+
true,
|
| 173 |
+
));
|
| 174 |
+
expect(operation.abortSignal.aborted).toBe(true);
|
| 175 |
+
expect(result).toMatchObject(
|
| 176 |
+
pathKind === "fast"
|
| 177 |
+
? { handled: true, aborted: true }
|
| 178 |
+
: { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } },
|
| 179 |
+
);
|
| 180 |
+
expect(loadSessionEntry(scope)).toMatchObject({
|
| 181 |
+
sessionId: state.entry.sessionId,
|
| 182 |
+
abortedLastRun: true,
|
| 183 |
+
});
|
| 184 |
+
} finally {
|
| 185 |
+
operation.complete();
|
| 186 |
+
clearSessionQueues([globalKey]);
|
| 187 |
+
}
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
it("does not reclaim a conversation reassigned after abort preparation", async () => {
|
| 191 |
+
const state = await setupStop();
|
| 192 |
+
const nextKey = `${sessionKey}:thread:123.456`;
|
| 193 |
+
const address = {
|
| 194 |
+
agentId: "main",
|
| 195 |
+
storePath: state.storePath,
|
| 196 |
+
channel: "slack",
|
| 197 |
+
accountId: "default",
|
| 198 |
+
kind: "group" as const,
|
| 199 |
+
peerId: "g12345678",
|
| 200 |
+
threadId: "123.456",
|
| 201 |
+
};
|
| 202 |
+
const delivery = normalizeSessionDeliveryState({
|
| 203 |
+
context: {
|
| 204 |
+
channel: "slack",
|
| 205 |
+
accountId: "default",
|
| 206 |
+
to: "group:g12345678",
|
| 207 |
+
threadId: "123.456",
|
| 208 |
+
},
|
| 209 |
+
});
|
| 210 |
+
await replaceSessionEntry(
|
| 211 |
+
{ storePath: state.storePath, sessionKey },
|
| 212 |
+
{ ...state.entry, updatedAt: 100, chatType: "group", delivery },
|
| 213 |
+
);
|
| 214 |
+
const operation = createReplyOperation({
|
| 215 |
+
sessionKey,
|
| 216 |
+
sessionId: state.entry.sessionId,
|
| 217 |
+
resetTriggered: false,
|
| 218 |
+
});
|
| 219 |
+
operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
|
| 220 |
+
let reassigned = false;
|
| 221 |
+
state.isCommandTargetCurrent = () => {
|
| 222 |
+
const current = getConversationSession(address);
|
| 223 |
+
if (
|
| 224 |
+
!reassigned &&
|
| 225 |
+
operation.abortSignal.aborted &&
|
| 226 |
+
(pathKind === "fast" || state.params.sessionStore?.[sessionKey]?.abortedLastRun === true)
|
| 227 |
+
) {
|
| 228 |
+
reassigned = true;
|
| 229 |
+
// A separate synchronous writer commits while abort's prepared patch yields.
|
| 230 |
+
queueMicrotask(() => {
|
| 231 |
+
replaceSessionEntrySync(
|
| 232 |
+
{ storePath: state.storePath, sessionKey: nextKey },
|
| 233 |
+
{ sessionId: "session-b", updatedAt: 200, chatType: "group", delivery },
|
| 234 |
+
);
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
return current?.sessionKey === sessionKey;
|
| 238 |
+
};
|
| 239 |
+
state.params.opts = { isCommandTargetCurrent: state.isCommandTargetCurrent };
|
| 240 |
+
const failure = await (
|
| 241 |
+
pathKind === "fast" ? tryFastAbortFromMessage(state) : handleStopCommand(state.params, true)
|
| 242 |
+
).then(
|
| 243 |
+
() => undefined,
|
| 244 |
+
(error: unknown) => error,
|
| 245 |
+
);
|
| 246 |
+
expect(operation.abortSignal.aborted).toBe(true);
|
| 247 |
+
expect(reassigned).toBe(true);
|
| 248 |
+
expect(getConversationSession(address)).toEqual({
|
| 249 |
+
sessionKey: nextKey,
|
| 250 |
+
sessionId: "session-b",
|
| 251 |
+
});
|
| 252 |
+
expect(loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun).not.toBe(
|
| 253 |
+
true,
|
| 254 |
+
);
|
| 255 |
+
if (failure) {
|
| 256 |
+
expect(failure).toMatchObject({
|
| 257 |
+
message: "The selected session changed before it could be stopped.",
|
| 258 |
+
});
|
| 259 |
+
}
|
| 260 |
+
operation.complete();
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
it("skips stale abort bookkeeping after waiting for a replacement writer", async () => {
|
| 264 |
+
const state = await setupStop();
|
| 265 |
+
const entered = createDeferred();
|
| 266 |
+
const release = createDeferred();
|
| 267 |
+
const writer = patchSessionEntry({
|
| 268 |
+
storePath: state.storePath,
|
| 269 |
+
sessionKey,
|
| 270 |
+
update: async () => {
|
| 271 |
+
entered.resolve();
|
| 272 |
+
await release.promise;
|
| 273 |
+
return { sessionId: "session-b", updatedAt: Date.now() };
|
| 274 |
+
},
|
| 275 |
+
});
|
| 276 |
+
await entered.promise;
|
| 277 |
+
const operation = createReplyOperation({
|
| 278 |
+
sessionKey,
|
| 279 |
+
sessionId: "session-a",
|
| 280 |
+
resetTriggered: false,
|
| 281 |
+
});
|
| 282 |
+
operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
|
| 283 |
+
const aborted = createDeferred();
|
| 284 |
+
const onAbort = () => aborted.resolve();
|
| 285 |
+
operation.abortSignal.addEventListener("abort", onAbort, { once: true });
|
| 286 |
+
const stopping =
|
| 287 |
+
pathKind === "fast" ? tryFastAbortFromMessage(state) : handleStopCommand(state.params, true);
|
| 288 |
+
onTestFinished(async () => {
|
| 289 |
+
operation.abortSignal.removeEventListener("abort", onAbort);
|
| 290 |
+
release.resolve();
|
| 291 |
+
await Promise.allSettled([writer, stopping]);
|
| 292 |
+
operation.complete();
|
| 293 |
+
});
|
| 294 |
+
void stopping.then(
|
| 295 |
+
() => {
|
| 296 |
+
if (!operation.abortSignal.aborted) {
|
| 297 |
+
aborted.reject(new Error("Stop completed without aborting the active operation"));
|
| 298 |
+
}
|
| 299 |
+
},
|
| 300 |
+
(error: unknown) => aborted.reject(error),
|
| 301 |
+
);
|
| 302 |
+
await aborted.promise;
|
| 303 |
+
expect(operation.abortSignal.aborted).toBe(true);
|
| 304 |
+
release.resolve();
|
| 305 |
+
await writer;
|
| 306 |
+
await stopping;
|
| 307 |
+
expect(loadSessionEntry({ storePath: state.storePath, sessionKey })).toMatchObject({
|
| 308 |
+
sessionId: "session-b",
|
| 309 |
+
});
|
| 310 |
+
expect(loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun).toBe(
|
| 311 |
+
false,
|
| 312 |
+
);
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
it("completes cancellation when its own abort releases the live publisher", async () => {
|
| 316 |
+
const state = await setupStop();
|
| 317 |
+
const operation = createReplyOperation({
|
| 318 |
+
sessionKey,
|
| 319 |
+
sessionId: "session-a",
|
| 320 |
+
resetTriggered: false,
|
| 321 |
+
});
|
| 322 |
+
operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
|
| 323 |
+
state.isCommandTargetCurrent = () => !operation.abortSignal.aborted;
|
| 324 |
+
state.params.opts = { isCommandTargetCurrent: state.isCommandTargetCurrent };
|
| 325 |
+
const result = await (pathKind === "fast"
|
| 326 |
+
? tryFastAbortFromMessage(state)
|
| 327 |
+
: handleStopCommand(state.params, true));
|
| 328 |
+
expect(operation.abortSignal.aborted).toBe(true);
|
| 329 |
+
expect(result).toMatchObject(
|
| 330 |
+
pathKind === "fast" ? { handled: true, aborted: true } : { shouldContinue: false },
|
| 331 |
+
);
|
| 332 |
+
expect(
|
| 333 |
+
loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun,
|
| 334 |
+
).toBeUndefined();
|
| 335 |
+
operation.complete();
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
it("preserves a replacement run and its queued input after dispatch handoff", async () => {
|
| 339 |
+
const state = await setupStop();
|
| 340 |
+
const admitted = createReplyOperation({
|
| 341 |
+
sessionKey,
|
| 342 |
+
sessionId: "session-a",
|
| 343 |
+
resetTriggered: false,
|
| 344 |
+
});
|
| 345 |
+
const dispatch = createDeferred();
|
| 346 |
+
expect(state.isCommandTargetCurrent()).toBe(true);
|
| 347 |
+
const pending = dispatch.promise.then(async () =>
|
| 348 |
+
pathKind === "fast" ? tryFastAbortFromMessage(state) : handleStopCommand(state.params, true),
|
| 349 |
+
);
|
| 350 |
+
admitted.complete();
|
| 351 |
+
await replaceSessionEntry(
|
| 352 |
+
{ storePath: state.storePath, sessionKey },
|
| 353 |
+
{
|
| 354 |
+
sessionId: "session-b",
|
| 355 |
+
updatedAt: Date.now(),
|
| 356 |
+
},
|
| 357 |
+
);
|
| 358 |
+
const replacement = createReplyOperation({
|
| 359 |
+
sessionKey,
|
| 360 |
+
sessionId: "session-b",
|
| 361 |
+
resetTriggered: false,
|
| 362 |
+
});
|
| 363 |
+
replacement.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
|
| 364 |
+
enqueueFollowupRun(
|
| 365 |
+
sessionKey,
|
| 366 |
+
createQueueTestRun({ prompt: "next conversation" }),
|
| 367 |
+
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
|
| 368 |
+
"none",
|
| 369 |
+
);
|
| 370 |
+
dispatch.resolve();
|
| 371 |
+
const result = await pending.then(
|
| 372 |
+
() => undefined,
|
| 373 |
+
(error: unknown) => error,
|
| 374 |
+
);
|
| 375 |
+
expect(replacement.abortSignal.aborted).toBe(false);
|
| 376 |
+
expect(result).toBeInstanceOf(Error);
|
| 377 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
|
| 378 |
+
expect(
|
| 379 |
+
loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun,
|
| 380 |
+
).toBeUndefined();
|
| 381 |
+
replacement.complete();
|
| 382 |
+
});
|
| 383 |
+
});
|
src/auto-reply/reply/abort.test.ts
ADDED
|
@@ -0,0 +1,1559 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tests abort request handling, cutoff persistence, and active run cleanup.
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
| 4 |
+
import { createDeferred } from "../../../test/helpers/promise.js";
|
| 5 |
+
import { registerSubagentRun } from "../../agents/subagents/registry/subagent-registry.js";
|
| 6 |
+
import { settleSubagentRegistryPersistenceWork } from "../../agents/subagents/registry/subagent-registry.persistence.test-support.js";
|
| 7 |
+
import {
|
| 8 |
+
addSubagentRunForTests,
|
| 9 |
+
getSubagentRunByChildSessionKey,
|
| 10 |
+
resetSubagentRegistryForTests,
|
| 11 |
+
testing as subagentRegistryTesting,
|
| 12 |
+
} from "../../agents/subagents/registry/subagent-registry.test-helpers.js";
|
| 13 |
+
import type { OpenClawConfig } from "../../config/config.js";
|
| 14 |
+
import {
|
| 15 |
+
loadSessionEntry,
|
| 16 |
+
markSessionAbortTarget,
|
| 17 |
+
replaceSessionEntry,
|
| 18 |
+
resolveSessionAbortTarget,
|
| 19 |
+
type SessionAbortTargetResult,
|
| 20 |
+
} from "../../config/sessions/session-accessor.js";
|
| 21 |
+
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
| 22 |
+
import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js";
|
| 23 |
+
import { resolveAbortCutoffFromContext, shouldSkipMessageByAbortCutoff } from "./abort-cutoff.js";
|
| 24 |
+
import { stopSubagentsForRequester } from "./abort-operation.js";
|
| 25 |
+
import {
|
| 26 |
+
getAbortMemory,
|
| 27 |
+
isAbortRequestText,
|
| 28 |
+
isAbortTrigger,
|
| 29 |
+
setAbortMemory,
|
| 30 |
+
} from "./abort-primitives.js";
|
| 31 |
+
import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
| 32 |
+
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./queue.js";
|
| 33 |
+
import { clearFollowupQueue } from "./queue/state.js";
|
| 34 |
+
import { createReplyOperation, replyRunRegistry } from "./reply-run-registry.js";
|
| 35 |
+
import { testing as replyRunRegistryTesting } from "./reply-run-registry.test-support.js";
|
| 36 |
+
import { buildTestCtx } from "./test-ctx.js";
|
| 37 |
+
|
| 38 |
+
type SubagentRunFixture = Parameters<typeof addSubagentRunForTests>[0];
|
| 39 |
+
|
| 40 |
+
function addSubagentFixture(run: SubagentRunFixture) {
|
| 41 |
+
addSubagentRunForTests({ requesterAgentId: "main", ...run });
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
type AbortEmbeddedAgentRunOptions = Parameters<
|
| 45 |
+
typeof import("../../agents/embedded-agent-runner/runs.js").abortEmbeddedAgentRun
|
| 46 |
+
>[1];
|
| 47 |
+
|
| 48 |
+
vi.mock("../../agents/embedded-agent.js", () => ({
|
| 49 |
+
abortEmbeddedAgentRun: vi.fn().mockReturnValue(true),
|
| 50 |
+
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
| 51 |
+
}));
|
| 52 |
+
|
| 53 |
+
const commandQueueMocks = vi.hoisted(() => ({
|
| 54 |
+
clearCommandLane: vi.fn(() => 1),
|
| 55 |
+
}));
|
| 56 |
+
|
| 57 |
+
vi.mock("../../process/command-queue.js", () => commandQueueMocks);
|
| 58 |
+
|
| 59 |
+
const acpManagerMocks = vi.hoisted(() => ({
|
| 60 |
+
resolveSession: vi.fn<
|
| 61 |
+
() =>
|
| 62 |
+
| { kind: "none" }
|
| 63 |
+
| {
|
| 64 |
+
kind: "ready";
|
| 65 |
+
sessionKey: string;
|
| 66 |
+
meta: unknown;
|
| 67 |
+
}
|
| 68 |
+
>(() => ({ kind: "none" })),
|
| 69 |
+
cancelSession: vi.fn(async () => {}),
|
| 70 |
+
}));
|
| 71 |
+
|
| 72 |
+
const runtimeAbortMocks = vi.hoisted(() => ({
|
| 73 |
+
abortEmbeddedAgentRun: vi.fn<
|
| 74 |
+
(sessionId: string | undefined, opts?: AbortEmbeddedAgentRunOptions) => boolean
|
| 75 |
+
>(() => true),
|
| 76 |
+
resolveActiveEmbeddedRunSessionId: vi.fn(() => undefined as string | undefined),
|
| 77 |
+
isEmbeddedAgentRunActive: vi.fn(() => false),
|
| 78 |
+
}));
|
| 79 |
+
|
| 80 |
+
vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({
|
| 81 |
+
abortEmbeddedAgentRun: runtimeAbortMocks.abortEmbeddedAgentRun,
|
| 82 |
+
isEmbeddedAgentRunActive: runtimeAbortMocks.isEmbeddedAgentRunActive,
|
| 83 |
+
}));
|
| 84 |
+
vi.mock("../../agents/embedded-agent-runner/active-run-projections.js", () => ({
|
| 85 |
+
resolveActiveEmbeddedRunSessionId: runtimeAbortMocks.resolveActiveEmbeddedRunSessionId,
|
| 86 |
+
}));
|
| 87 |
+
vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => {
|
| 88 |
+
const actual = await importOriginal<typeof import("../../config/sessions/session-accessor.js")>();
|
| 89 |
+
return {
|
| 90 |
+
...actual,
|
| 91 |
+
markSessionAbortTarget: vi.fn(actual.markSessionAbortTarget),
|
| 92 |
+
resolveSessionAbortTarget: vi.fn(actual.resolveSessionAbortTarget),
|
| 93 |
+
};
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
vi.mock("../../acp/control-plane/manager.js", () => ({
|
| 97 |
+
getAcpSessionManager: () => ({
|
| 98 |
+
resolveSession: acpManagerMocks.resolveSession,
|
| 99 |
+
cancelSession: acpManagerMocks.cancelSession,
|
| 100 |
+
}),
|
| 101 |
+
}));
|
| 102 |
+
|
| 103 |
+
const suiteTempDirs = createSuiteTempRootTracker({ prefix: "openclaw-abort-" });
|
| 104 |
+
|
| 105 |
+
describe("abort detection", () => {
|
| 106 |
+
const trackedAbortMemoryKeys = new Set<string>();
|
| 107 |
+
|
| 108 |
+
function setTrackedAbortMemory(key: string, value: boolean): void {
|
| 109 |
+
trackedAbortMemoryKeys.add(key);
|
| 110 |
+
setAbortMemory(key, value);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
beforeAll(async () => {
|
| 114 |
+
await suiteTempDirs.setup();
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
afterAll(async () => {
|
| 118 |
+
await suiteTempDirs.cleanup();
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
async function writeSessionStore(
|
| 122 |
+
storePath: string,
|
| 123 |
+
sessionIdsByKey: Record<string, string>,
|
| 124 |
+
nowMs = Date.now(),
|
| 125 |
+
) {
|
| 126 |
+
await Promise.all(
|
| 127 |
+
Object.entries(sessionIdsByKey).map(([sessionKey, sessionId]) =>
|
| 128 |
+
replaceSessionEntry({ storePath, sessionKey }, { sessionId, updatedAt: nowMs }),
|
| 129 |
+
),
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function readAbortSessionEntry(storePath: string, sessionKey: string) {
|
| 134 |
+
return loadSessionEntry({ storePath, sessionKey });
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
async function createAbortConfig(params?: {
|
| 138 |
+
commandsTextEnabled?: boolean;
|
| 139 |
+
sessionIdsByKey?: Record<string, string>;
|
| 140 |
+
nowMs?: number;
|
| 141 |
+
}) {
|
| 142 |
+
const root = await suiteTempDirs.make("case");
|
| 143 |
+
const storePath = path.join(root, "sessions.json");
|
| 144 |
+
const cfg = {
|
| 145 |
+
session: { store: storePath },
|
| 146 |
+
...(typeof params?.commandsTextEnabled === "boolean"
|
| 147 |
+
? { commands: { text: params.commandsTextEnabled } }
|
| 148 |
+
: {}),
|
| 149 |
+
} as OpenClawConfig;
|
| 150 |
+
if (params?.sessionIdsByKey) {
|
| 151 |
+
for (const sessionKey of Object.keys(params.sessionIdsByKey)) {
|
| 152 |
+
trackedAbortMemoryKeys.add(sessionKey);
|
| 153 |
+
}
|
| 154 |
+
await writeSessionStore(storePath, params.sessionIdsByKey, params.nowMs);
|
| 155 |
+
}
|
| 156 |
+
return { root, storePath, cfg };
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
async function runStopCommand(params: {
|
| 160 |
+
cfg: OpenClawConfig;
|
| 161 |
+
sessionKey?: string;
|
| 162 |
+
parentSessionKey?: string;
|
| 163 |
+
from: string;
|
| 164 |
+
to: string;
|
| 165 |
+
senderId?: string;
|
| 166 |
+
commandSource?: "native" | "text";
|
| 167 |
+
targetSessionKey?: string;
|
| 168 |
+
messageSid?: string;
|
| 169 |
+
timestamp?: number;
|
| 170 |
+
}) {
|
| 171 |
+
for (const key of [
|
| 172 |
+
params.sessionKey,
|
| 173 |
+
params.parentSessionKey,
|
| 174 |
+
params.targetSessionKey,
|
| 175 |
+
params.from,
|
| 176 |
+
params.to,
|
| 177 |
+
]) {
|
| 178 |
+
if (key) {
|
| 179 |
+
trackedAbortMemoryKeys.add(key);
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
return tryFastAbortFromMessage({
|
| 183 |
+
ctx: buildTestCtx({
|
| 184 |
+
CommandBody: "/stop",
|
| 185 |
+
RawBody: "/stop",
|
| 186 |
+
CommandAuthorized: true,
|
| 187 |
+
Provider: "telegram",
|
| 188 |
+
Surface: "telegram",
|
| 189 |
+
From: params.from,
|
| 190 |
+
To: params.to,
|
| 191 |
+
...(params.sessionKey ? { SessionKey: params.sessionKey } : {}),
|
| 192 |
+
...(params.parentSessionKey ? { ParentSessionKey: params.parentSessionKey } : {}),
|
| 193 |
+
...(params.senderId ? { SenderId: params.senderId } : {}),
|
| 194 |
+
...(params.commandSource ? { CommandSource: params.commandSource } : {}),
|
| 195 |
+
...(params.targetSessionKey ? { CommandTargetSessionKey: params.targetSessionKey } : {}),
|
| 196 |
+
...(params.messageSid ? { MessageSid: params.messageSid } : {}),
|
| 197 |
+
...(typeof params.timestamp === "number" ? { Timestamp: params.timestamp } : {}),
|
| 198 |
+
}),
|
| 199 |
+
cfg: params.cfg,
|
| 200 |
+
});
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function enqueueQueuedFollowupRun(params: {
|
| 204 |
+
root: string;
|
| 205 |
+
cfg: OpenClawConfig;
|
| 206 |
+
sessionId: string;
|
| 207 |
+
sessionKey: string;
|
| 208 |
+
}) {
|
| 209 |
+
trackedAbortMemoryKeys.add(params.sessionKey);
|
| 210 |
+
const followupRun: FollowupRun = {
|
| 211 |
+
prompt: "queued",
|
| 212 |
+
enqueuedAt: Date.now(),
|
| 213 |
+
run: {
|
| 214 |
+
agentId: "main",
|
| 215 |
+
agentDir: path.join(params.root, "agent"),
|
| 216 |
+
sessionId: params.sessionId,
|
| 217 |
+
sessionKey: params.sessionKey,
|
| 218 |
+
messageProvider: "telegram",
|
| 219 |
+
agentAccountId: "acct",
|
| 220 |
+
sessionFile: path.join(params.root, "session.jsonl"),
|
| 221 |
+
workspaceDir: path.join(params.root, "workspace"),
|
| 222 |
+
config: params.cfg,
|
| 223 |
+
provider: "anthropic",
|
| 224 |
+
model: "claude-opus-4-6",
|
| 225 |
+
timeoutMs: 1000,
|
| 226 |
+
blockReplyBreak: "text_end",
|
| 227 |
+
},
|
| 228 |
+
};
|
| 229 |
+
enqueueFollowupRun(
|
| 230 |
+
params.sessionKey,
|
| 231 |
+
followupRun,
|
| 232 |
+
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
|
| 233 |
+
"none",
|
| 234 |
+
);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function expectSessionLaneCleared(sessionKey: string) {
|
| 238 |
+
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function bindAcpSessionForTest(targetSessionKey: string) {
|
| 242 |
+
vi.spyOn(getSessionBindingService(), "resolveByConversation").mockImplementation(
|
| 243 |
+
(conversation) => ({
|
| 244 |
+
bindingId: "test-acp-binding",
|
| 245 |
+
targetKind: "session",
|
| 246 |
+
targetSessionKey,
|
| 247 |
+
conversation,
|
| 248 |
+
status: "active",
|
| 249 |
+
boundAt: 0,
|
| 250 |
+
}),
|
| 251 |
+
);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
beforeEach(() => {
|
| 255 |
+
subagentRegistryTesting.setDepsForTest({
|
| 256 |
+
persistSubagentRunsToDisk: () => {},
|
| 257 |
+
persistSubagentRunsToDiskOrThrow: () => {},
|
| 258 |
+
restoreSubagentRunsFromDisk: () => 0,
|
| 259 |
+
cleanupBrowserSessionsForLifecycleEnd: async () => {},
|
| 260 |
+
ensureContextEnginesInitialized: () => {},
|
| 261 |
+
loadAgentRuntimePluginRegistryHandle: () => undefined,
|
| 262 |
+
});
|
| 263 |
+
commandQueueMocks.clearCommandLane.mockClear().mockReturnValue(1);
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
afterEach(async () => {
|
| 267 |
+
for (const key of trackedAbortMemoryKeys) {
|
| 268 |
+
setAbortMemory(key, false);
|
| 269 |
+
clearFollowupQueue(key);
|
| 270 |
+
}
|
| 271 |
+
trackedAbortMemoryKeys.clear();
|
| 272 |
+
vi.restoreAllMocks();
|
| 273 |
+
vi.mocked(markSessionAbortTarget).mockReset();
|
| 274 |
+
vi.mocked(resolveSessionAbortTarget).mockReset();
|
| 275 |
+
replyRunRegistryTesting.resetReplyRunRegistry();
|
| 276 |
+
commandQueueMocks.clearCommandLane.mockClear().mockReturnValue(1);
|
| 277 |
+
acpManagerMocks.resolveSession.mockReset().mockReturnValue({ kind: "none" });
|
| 278 |
+
acpManagerMocks.cancelSession.mockReset().mockResolvedValue(undefined);
|
| 279 |
+
runtimeAbortMocks.abortEmbeddedAgentRun.mockReset().mockReturnValue(true);
|
| 280 |
+
runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReset().mockReturnValue(undefined);
|
| 281 |
+
await settleSubagentRegistryPersistenceWork();
|
| 282 |
+
resetSubagentRegistryForTests({ persist: false });
|
| 283 |
+
subagentRegistryTesting.setDepsForTest();
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
it("isAbortTrigger matches standalone abort trigger phrases", () => {
|
| 287 |
+
const positives = [
|
| 288 |
+
"stop",
|
| 289 |
+
"esc",
|
| 290 |
+
"abort",
|
| 291 |
+
"exit",
|
| 292 |
+
"interrupt",
|
| 293 |
+
"stop openclaw",
|
| 294 |
+
"openclaw stop",
|
| 295 |
+
"stop action",
|
| 296 |
+
"stop current action",
|
| 297 |
+
"stop run",
|
| 298 |
+
"stop current run",
|
| 299 |
+
"stop agent",
|
| 300 |
+
"stop the agent",
|
| 301 |
+
"stop don't do anything",
|
| 302 |
+
"stop dont do anything",
|
| 303 |
+
"stop do not do anything",
|
| 304 |
+
"stop doing anything",
|
| 305 |
+
"do not do that",
|
| 306 |
+
"please stop",
|
| 307 |
+
"stop please",
|
| 308 |
+
"STOP OPENCLAW",
|
| 309 |
+
"stop openclaw!!!",
|
| 310 |
+
"stop don’t do anything",
|
| 311 |
+
"detente",
|
| 312 |
+
"detén",
|
| 313 |
+
"arrête",
|
| 314 |
+
"停止",
|
| 315 |
+
"停下来",
|
| 316 |
+
"暂停",
|
| 317 |
+
"停下来!",
|
| 318 |
+
"やめて",
|
| 319 |
+
"止めて",
|
| 320 |
+
"रुको",
|
| 321 |
+
"توقف",
|
| 322 |
+
"стоп",
|
| 323 |
+
"остановись",
|
| 324 |
+
"останови",
|
| 325 |
+
"остановить",
|
| 326 |
+
"прекрати",
|
| 327 |
+
"halt",
|
| 328 |
+
"anhalten",
|
| 329 |
+
"aufhören",
|
| 330 |
+
"hoer auf",
|
| 331 |
+
"stopp",
|
| 332 |
+
"pare",
|
| 333 |
+
];
|
| 334 |
+
for (const candidate of positives) {
|
| 335 |
+
expect(isAbortTrigger(candidate)).toBe(true);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
expect(isAbortTrigger("hello")).toBe(false);
|
| 339 |
+
expect(isAbortTrigger("wait")).toBe(false);
|
| 340 |
+
expect(isAbortTrigger("please wait")).toBe(false);
|
| 341 |
+
expect(isAbortTrigger("please do not do that")).toBe(false);
|
| 342 |
+
// /stop is NOT matched by isAbortTrigger - it's handled separately.
|
| 343 |
+
expect(isAbortTrigger("/stop")).toBe(false);
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
it("isAbortRequestText aligns abort command semantics", () => {
|
| 347 |
+
expect(isAbortRequestText("/stop")).toBe(true);
|
| 348 |
+
expect(isAbortRequestText("/STOP")).toBe(true);
|
| 349 |
+
expect(isAbortRequestText("/stop!!!")).toBe(true);
|
| 350 |
+
expect(isAbortRequestText("/Stop!!!")).toBe(true);
|
| 351 |
+
expect(isAbortRequestText("stop")).toBe(true);
|
| 352 |
+
expect(isAbortRequestText("Stop")).toBe(true);
|
| 353 |
+
expect(isAbortRequestText("STOP")).toBe(true);
|
| 354 |
+
expect(isAbortRequestText("stop action")).toBe(true);
|
| 355 |
+
expect(isAbortRequestText("stop openclaw!!!")).toBe(true);
|
| 356 |
+
expect(isAbortRequestText("停下来")).toBe(true);
|
| 357 |
+
expect(isAbortRequestText("暂停")).toBe(true);
|
| 358 |
+
expect(isAbortRequestText("やめて")).toBe(true);
|
| 359 |
+
expect(isAbortRequestText("остановись")).toBe(true);
|
| 360 |
+
expect(isAbortRequestText("halt")).toBe(true);
|
| 361 |
+
expect(isAbortRequestText("stopp")).toBe(true);
|
| 362 |
+
expect(isAbortRequestText("pare")).toBe(true);
|
| 363 |
+
expect(isAbortRequestText(" توقف ")).toBe(true);
|
| 364 |
+
expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true);
|
| 365 |
+
expect(isAbortRequestText("/Stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true);
|
| 366 |
+
expect(
|
| 367 |
+
isAbortRequestText("/stop@unresolved_bot", {
|
| 368 |
+
targetedCommandMode: "pre-identity",
|
| 369 |
+
}),
|
| 370 |
+
).toBe(true);
|
| 371 |
+
expect(
|
| 372 |
+
isAbortRequestText("/stop@unresolved_bot!", {
|
| 373 |
+
targetedCommandMode: "pre-identity",
|
| 374 |
+
}),
|
| 375 |
+
).toBe(true);
|
| 376 |
+
expect(
|
| 377 |
+
isAbortRequestText("/queue@unresolved_bot", {
|
| 378 |
+
targetedCommandMode: "pre-identity",
|
| 379 |
+
}),
|
| 380 |
+
).toBe(false);
|
| 381 |
+
expect(
|
| 382 |
+
isAbortRequestText("/stop@some_other_bot", {
|
| 383 |
+
botUsername: "openclaw_bot",
|
| 384 |
+
targetedCommandMode: "pre-identity",
|
| 385 |
+
}),
|
| 386 |
+
).toBe(false);
|
| 387 |
+
|
| 388 |
+
expect(isAbortRequestText("/status")).toBe(false);
|
| 389 |
+
expect(isAbortRequestText("wait")).toBe(false);
|
| 390 |
+
expect(isAbortRequestText("please wait")).toBe(false);
|
| 391 |
+
expect(isAbortRequestText("do not do that")).toBe(true);
|
| 392 |
+
expect(isAbortRequestText("please do not do that")).toBe(false);
|
| 393 |
+
expect(isAbortRequestText("/abort")).toBe(false);
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
it("removes abort memory entry when flag is reset", () => {
|
| 397 |
+
setTrackedAbortMemory("session-1", true);
|
| 398 |
+
expect(getAbortMemory("session-1")).toBe(true);
|
| 399 |
+
|
| 400 |
+
setTrackedAbortMemory("session-1", false);
|
| 401 |
+
expect(getAbortMemory("session-1")).toBeUndefined();
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
it("caps abort memory tracking to a bounded max size", () => {
|
| 405 |
+
for (let i = 0; i < 2105; i += 1) {
|
| 406 |
+
setTrackedAbortMemory(`bounded-memory-session-${i}`, true);
|
| 407 |
+
}
|
| 408 |
+
expect(getAbortMemory("bounded-memory-session-0")).toBeUndefined();
|
| 409 |
+
expect(getAbortMemory("bounded-memory-session-2104")).toBe(true);
|
| 410 |
+
});
|
| 411 |
+
|
| 412 |
+
it("extracts abort cutoff metadata from context", () => {
|
| 413 |
+
expect(
|
| 414 |
+
resolveAbortCutoffFromContext(
|
| 415 |
+
buildTestCtx({
|
| 416 |
+
MessageSid: "42",
|
| 417 |
+
Timestamp: 123,
|
| 418 |
+
}),
|
| 419 |
+
),
|
| 420 |
+
).toEqual({
|
| 421 |
+
messageSid: "42",
|
| 422 |
+
timestamp: 123,
|
| 423 |
+
});
|
| 424 |
+
});
|
| 425 |
+
|
| 426 |
+
it("treats numeric message IDs at or before cutoff as stale", () => {
|
| 427 |
+
expect(
|
| 428 |
+
shouldSkipMessageByAbortCutoff({
|
| 429 |
+
cutoffMessageSid: "200",
|
| 430 |
+
messageSid: "199",
|
| 431 |
+
}),
|
| 432 |
+
).toBe(true);
|
| 433 |
+
expect(
|
| 434 |
+
shouldSkipMessageByAbortCutoff({
|
| 435 |
+
cutoffMessageSid: "200",
|
| 436 |
+
messageSid: "200",
|
| 437 |
+
}),
|
| 438 |
+
).toBe(true);
|
| 439 |
+
expect(
|
| 440 |
+
shouldSkipMessageByAbortCutoff({
|
| 441 |
+
cutoffMessageSid: "200",
|
| 442 |
+
messageSid: "201",
|
| 443 |
+
}),
|
| 444 |
+
).toBe(false);
|
| 445 |
+
});
|
| 446 |
+
|
| 447 |
+
it("falls back to timestamp cutoff when message IDs are unavailable", () => {
|
| 448 |
+
expect(
|
| 449 |
+
shouldSkipMessageByAbortCutoff({
|
| 450 |
+
cutoffTimestamp: 2000,
|
| 451 |
+
timestamp: 1999,
|
| 452 |
+
}),
|
| 453 |
+
).toBe(true);
|
| 454 |
+
expect(
|
| 455 |
+
shouldSkipMessageByAbortCutoff({
|
| 456 |
+
cutoffTimestamp: 2000,
|
| 457 |
+
timestamp: 2000,
|
| 458 |
+
}),
|
| 459 |
+
).toBe(true);
|
| 460 |
+
expect(
|
| 461 |
+
shouldSkipMessageByAbortCutoff({
|
| 462 |
+
cutoffTimestamp: 2000,
|
| 463 |
+
timestamp: 2001,
|
| 464 |
+
}),
|
| 465 |
+
).toBe(false);
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
it("fast-aborts even when text commands are disabled", async () => {
|
| 469 |
+
const { cfg } = await createAbortConfig({ commandsTextEnabled: false });
|
| 470 |
+
|
| 471 |
+
const result = await runStopCommand({
|
| 472 |
+
cfg,
|
| 473 |
+
sessionKey: "telegram:123",
|
| 474 |
+
from: "telegram:123",
|
| 475 |
+
to: "telegram:123",
|
| 476 |
+
});
|
| 477 |
+
|
| 478 |
+
expect(result.handled).toBe(true);
|
| 479 |
+
});
|
| 480 |
+
|
| 481 |
+
it("resolves owner authorization after loading cancellation runtime", async () => {
|
| 482 |
+
const sessionKey = "telegram:123";
|
| 483 |
+
const sessionId = "session-123";
|
| 484 |
+
const { root, cfg } = await createAbortConfig({
|
| 485 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 486 |
+
});
|
| 487 |
+
cfg.commands = { ownerAllowFrom: ["telegram:123"] };
|
| 488 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
| 489 |
+
const pending = runStopCommand({
|
| 490 |
+
cfg,
|
| 491 |
+
sessionKey,
|
| 492 |
+
from: "telegram:123",
|
| 493 |
+
to: "telegram:123",
|
| 494 |
+
senderId: "123",
|
| 495 |
+
});
|
| 496 |
+
cfg.commands.ownerAllowFrom = ["telegram:other-owner"];
|
| 497 |
+
|
| 498 |
+
await expect(pending).resolves.toEqual({ handled: false, aborted: false });
|
| 499 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
|
| 500 |
+
});
|
| 501 |
+
|
| 502 |
+
it("fast-aborts authorized text slash stop commands before they queue", async () => {
|
| 503 |
+
const sessionKey = "telegram:123";
|
| 504 |
+
const sessionId = "session-123";
|
| 505 |
+
const activeSessionId = "session-active";
|
| 506 |
+
const { root, cfg } = await createAbortConfig({
|
| 507 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 508 |
+
});
|
| 509 |
+
cfg.commands = {
|
| 510 |
+
...cfg.commands,
|
| 511 |
+
ownerAllowFrom: ["telegram:123"],
|
| 512 |
+
};
|
| 513 |
+
runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(activeSessionId);
|
| 514 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
| 515 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
|
| 516 |
+
|
| 517 |
+
const result = await runStopCommand({
|
| 518 |
+
cfg,
|
| 519 |
+
sessionKey,
|
| 520 |
+
from: "telegram:123",
|
| 521 |
+
to: "telegram:123",
|
| 522 |
+
senderId: "123",
|
| 523 |
+
commandSource: "text",
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
expect(result.handled).toBe(true);
|
| 527 |
+
expect(runtimeAbortMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith(sessionKey);
|
| 528 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(activeSessionId);
|
| 529 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
|
| 530 |
+
expectSessionLaneCleared(sessionKey);
|
| 531 |
+
});
|
| 532 |
+
|
| 533 |
+
it("fast-abort clears queued followups and session lane", async () => {
|
| 534 |
+
const sessionKey = "telegram:123";
|
| 535 |
+
const sessionId = "session-123";
|
| 536 |
+
const { root, cfg } = await createAbortConfig({
|
| 537 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 538 |
+
});
|
| 539 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
| 540 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
|
| 541 |
+
|
| 542 |
+
const result = await runStopCommand({
|
| 543 |
+
cfg,
|
| 544 |
+
sessionKey,
|
| 545 |
+
from: "telegram:123",
|
| 546 |
+
to: "telegram:123",
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
expect(result.handled).toBe(true);
|
| 550 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
|
| 551 |
+
expectSessionLaneCleared(sessionKey);
|
| 552 |
+
});
|
| 553 |
+
|
| 554 |
+
it("fast-abort resolves canonical stored session identity before metadata persistence", async () => {
|
| 555 |
+
const storeKey = "agent:main:telegram:group:-1001234567890:topic:99";
|
| 556 |
+
const lookupKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
|
| 557 |
+
const sessionId = "agent-topic-99";
|
| 558 |
+
const { root, cfg } = await createAbortConfig({
|
| 559 |
+
sessionIdsByKey: { [storeKey]: sessionId },
|
| 560 |
+
});
|
| 561 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey: storeKey });
|
| 562 |
+
|
| 563 |
+
const result = await runStopCommand({
|
| 564 |
+
cfg,
|
| 565 |
+
sessionKey: lookupKey,
|
| 566 |
+
from: "telegram:123",
|
| 567 |
+
to: "telegram:123",
|
| 568 |
+
});
|
| 569 |
+
|
| 570 |
+
expect(result.handled).toBe(true);
|
| 571 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
|
| 572 |
+
expect(getFollowupQueueDepth(storeKey)).toBe(0);
|
| 573 |
+
expectSessionLaneCleared(storeKey);
|
| 574 |
+
});
|
| 575 |
+
|
| 576 |
+
it("fast-abort still stops active runs when abort metadata persistence fails", async () => {
|
| 577 |
+
const sessionKey = "telegram:persistence-failure";
|
| 578 |
+
const sessionId = "session-persistence-failure";
|
| 579 |
+
const activeSessionId = "active-persistence-failure";
|
| 580 |
+
const { root, cfg } = await createAbortConfig({
|
| 581 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 582 |
+
});
|
| 583 |
+
runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(activeSessionId);
|
| 584 |
+
vi.mocked(markSessionAbortTarget).mockRejectedValueOnce(
|
| 585 |
+
new Error("simulated persistence failure"),
|
| 586 |
+
);
|
| 587 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
| 588 |
+
|
| 589 |
+
const result = await runStopCommand({
|
| 590 |
+
cfg,
|
| 591 |
+
sessionKey,
|
| 592 |
+
from: "telegram:123",
|
| 593 |
+
to: "telegram:123",
|
| 594 |
+
});
|
| 595 |
+
|
| 596 |
+
expect(result.handled).toBe(true);
|
| 597 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(activeSessionId);
|
| 598 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
|
| 599 |
+
expectSessionLaneCleared(sessionKey);
|
| 600 |
+
expect(getAbortMemory(sessionKey)).toBeUndefined();
|
| 601 |
+
});
|
| 602 |
+
|
| 603 |
+
it("fast-abort uses resolved target identity when abort metadata save fails", async () => {
|
| 604 |
+
const requestedKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
|
| 605 |
+
const canonicalKey = "agent:main:telegram:group:-1001234567890:topic:99";
|
| 606 |
+
const sessionId = "resolved-persistence-failure";
|
| 607 |
+
const { root, cfg } = await createAbortConfig();
|
| 608 |
+
vi.mocked(markSessionAbortTarget).mockResolvedValueOnce({
|
| 609 |
+
entry: {
|
| 610 |
+
sessionId,
|
| 611 |
+
updatedAt: 10,
|
| 612 |
+
},
|
| 613 |
+
persisted: false,
|
| 614 |
+
persistenceError: "simulated persistence failure",
|
| 615 |
+
sessionId,
|
| 616 |
+
sessionKey: canonicalKey,
|
| 617 |
+
});
|
| 618 |
+
vi.mocked(resolveSessionAbortTarget).mockReturnValueOnce({
|
| 619 |
+
entry: {
|
| 620 |
+
sessionId,
|
| 621 |
+
updatedAt: 10,
|
| 622 |
+
},
|
| 623 |
+
sessionId,
|
| 624 |
+
sessionKey: canonicalKey,
|
| 625 |
+
});
|
| 626 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey: canonicalKey });
|
| 627 |
+
|
| 628 |
+
const result = await runStopCommand({
|
| 629 |
+
cfg,
|
| 630 |
+
sessionKey: requestedKey,
|
| 631 |
+
from: "telegram:123",
|
| 632 |
+
to: "telegram:123",
|
| 633 |
+
});
|
| 634 |
+
|
| 635 |
+
expect(result.handled).toBe(true);
|
| 636 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
|
| 637 |
+
expect(getFollowupQueueDepth(canonicalKey)).toBe(0);
|
| 638 |
+
expectSessionLaneCleared(canonicalKey);
|
| 639 |
+
expect(getAbortMemory(canonicalKey)).toBeUndefined();
|
| 640 |
+
});
|
| 641 |
+
|
| 642 |
+
it("fast-abort uses abort memory when no persisted target entry exists", async () => {
|
| 643 |
+
const sessionKey = "telegram:missing-persistence-target";
|
| 644 |
+
const { cfg } = await createAbortConfig();
|
| 645 |
+
vi.mocked(markSessionAbortTarget).mockResolvedValueOnce(null);
|
| 646 |
+
vi.mocked(resolveSessionAbortTarget).mockReturnValueOnce(null);
|
| 647 |
+
|
| 648 |
+
const result = await runStopCommand({
|
| 649 |
+
cfg,
|
| 650 |
+
sessionKey,
|
| 651 |
+
from: "telegram:123",
|
| 652 |
+
to: "telegram:123",
|
| 653 |
+
});
|
| 654 |
+
|
| 655 |
+
expect(result.handled).toBe(true);
|
| 656 |
+
expect(getAbortMemory(sessionKey)).toBe(true);
|
| 657 |
+
});
|
| 658 |
+
|
| 659 |
+
it("fast-abort does not wait for abort metadata persistence before stopping runs", async () => {
|
| 660 |
+
const sessionKey = "telegram:slow-persistence";
|
| 661 |
+
const childKey = "agent:main:subagent:slow-persistence-child";
|
| 662 |
+
const sessionId = "session-slow-persistence";
|
| 663 |
+
const childSessionId = "session-slow-persistence-child";
|
| 664 |
+
const { root, cfg } = await createAbortConfig({
|
| 665 |
+
sessionIdsByKey: {
|
| 666 |
+
[childKey]: childSessionId,
|
| 667 |
+
[sessionKey]: sessionId,
|
| 668 |
+
},
|
| 669 |
+
});
|
| 670 |
+
let finishPersistence: (() => void) | undefined;
|
| 671 |
+
const persistenceStarted = new Promise<void>((resolveStarted) => {
|
| 672 |
+
vi.mocked(markSessionAbortTarget).mockImplementationOnce(
|
| 673 |
+
() =>
|
| 674 |
+
new Promise<SessionAbortTargetResult | null>((resolvePersistence) => {
|
| 675 |
+
resolveStarted();
|
| 676 |
+
finishPersistence = () => {
|
| 677 |
+
resolvePersistence({
|
| 678 |
+
entry: {
|
| 679 |
+
sessionId,
|
| 680 |
+
updatedAt: 10,
|
| 681 |
+
},
|
| 682 |
+
persisted: true,
|
| 683 |
+
sessionId,
|
| 684 |
+
sessionKey,
|
| 685 |
+
});
|
| 686 |
+
};
|
| 687 |
+
}),
|
| 688 |
+
);
|
| 689 |
+
vi.mocked(resolveSessionAbortTarget).mockReturnValueOnce({
|
| 690 |
+
entry: {
|
| 691 |
+
sessionId,
|
| 692 |
+
updatedAt: 10,
|
| 693 |
+
},
|
| 694 |
+
sessionId,
|
| 695 |
+
sessionKey,
|
| 696 |
+
});
|
| 697 |
+
});
|
| 698 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
| 699 |
+
addSubagentFixture({
|
| 700 |
+
runId: "slow-child-run",
|
| 701 |
+
childSessionKey: childKey,
|
| 702 |
+
requesterSessionKey: sessionKey,
|
| 703 |
+
requesterDisplayKey: sessionKey,
|
| 704 |
+
task: "slow child",
|
| 705 |
+
cleanup: "keep",
|
| 706 |
+
createdAt: Date.now(),
|
| 707 |
+
});
|
| 708 |
+
|
| 709 |
+
const resultPromise = runStopCommand({
|
| 710 |
+
cfg,
|
| 711 |
+
sessionKey,
|
| 712 |
+
from: "telegram:123",
|
| 713 |
+
to: "telegram:123",
|
| 714 |
+
});
|
| 715 |
+
await persistenceStarted;
|
| 716 |
+
|
| 717 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
|
| 718 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(childSessionId);
|
| 719 |
+
expect(getSubagentRunByChildSessionKey(childKey)).toMatchObject({
|
| 720 |
+
endedReason: "subagent-killed",
|
| 721 |
+
killReconciliation: { suppressTaskDelivery: true },
|
| 722 |
+
});
|
| 723 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
|
| 724 |
+
expectSessionLaneCleared(sessionKey);
|
| 725 |
+
|
| 726 |
+
finishPersistence?.();
|
| 727 |
+
await expect(resultPromise).resolves.toMatchObject({
|
| 728 |
+
aborted: true,
|
| 729 |
+
handled: true,
|
| 730 |
+
});
|
| 731 |
+
});
|
| 732 |
+
|
| 733 |
+
it("plain-language stop on ACP-bound session triggers ACP cancel", async () => {
|
| 734 |
+
const sessionKey = "agent:codex:acp:test-1";
|
| 735 |
+
const sessionId = "session-123";
|
| 736 |
+
const { cfg } = await createAbortConfig({
|
| 737 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 738 |
+
});
|
| 739 |
+
acpManagerMocks.resolveSession.mockReturnValue({
|
| 740 |
+
kind: "ready",
|
| 741 |
+
sessionKey,
|
| 742 |
+
meta: {} as never,
|
| 743 |
+
});
|
| 744 |
+
|
| 745 |
+
const result = await runStopCommand({
|
| 746 |
+
cfg,
|
| 747 |
+
sessionKey,
|
| 748 |
+
from: "telegram:123",
|
| 749 |
+
to: "telegram:123",
|
| 750 |
+
targetSessionKey: sessionKey,
|
| 751 |
+
});
|
| 752 |
+
|
| 753 |
+
expect(result.handled).toBe(true);
|
| 754 |
+
expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
|
| 755 |
+
cfg,
|
| 756 |
+
sessionKey,
|
| 757 |
+
reason: "fast-abort",
|
| 758 |
+
});
|
| 759 |
+
});
|
| 760 |
+
|
| 761 |
+
it("ACP cancel failures do not skip queue and lane cleanup", async () => {
|
| 762 |
+
const sessionKey = "agent:codex:acp:test-2";
|
| 763 |
+
const sessionId = "session-456";
|
| 764 |
+
const { root, cfg } = await createAbortConfig({
|
| 765 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 766 |
+
});
|
| 767 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
| 768 |
+
acpManagerMocks.resolveSession.mockReturnValue({
|
| 769 |
+
kind: "ready",
|
| 770 |
+
sessionKey,
|
| 771 |
+
meta: {} as never,
|
| 772 |
+
});
|
| 773 |
+
acpManagerMocks.cancelSession.mockRejectedValueOnce(new Error("cancel failed"));
|
| 774 |
+
|
| 775 |
+
const result = await runStopCommand({
|
| 776 |
+
cfg,
|
| 777 |
+
sessionKey,
|
| 778 |
+
from: "telegram:123",
|
| 779 |
+
to: "telegram:123",
|
| 780 |
+
targetSessionKey: sessionKey,
|
| 781 |
+
});
|
| 782 |
+
|
| 783 |
+
expect(result.handled).toBe(true);
|
| 784 |
+
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
|
| 785 |
+
expectSessionLaneCleared(sessionKey);
|
| 786 |
+
});
|
| 787 |
+
|
| 788 |
+
it("signals the native parent before deferred ACP cancellation and never retargets its replacement", async () => {
|
| 789 |
+
const sessionKey = "agent:main:discord:channel:deferred-acp";
|
| 790 |
+
const acpKey = "agent:main:acp:deferred-acp";
|
| 791 |
+
const { root, cfg } = await createAbortConfig({
|
| 792 |
+
sessionIdsByKey: { [sessionKey]: "native-session", [acpKey]: "acp-session" },
|
| 793 |
+
});
|
| 794 |
+
const native = createReplyOperation({
|
| 795 |
+
sessionKey,
|
| 796 |
+
sessionId: "native-session",
|
| 797 |
+
resetTriggered: false,
|
| 798 |
+
});
|
| 799 |
+
native.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
|
| 800 |
+
enqueueQueuedFollowupRun({ root, cfg, sessionId: "native-session", sessionKey });
|
| 801 |
+
bindAcpSessionForTest(acpKey);
|
| 802 |
+
acpManagerMocks.resolveSession.mockReturnValue({ kind: "ready", sessionKey: acpKey, meta: {} });
|
| 803 |
+
const entered = createDeferred();
|
| 804 |
+
const proceed = createDeferred();
|
| 805 |
+
acpManagerMocks.cancelSession.mockImplementationOnce(async () => {
|
| 806 |
+
entered.resolve();
|
| 807 |
+
await proceed.promise;
|
| 808 |
+
});
|
| 809 |
+
const pending = runStopCommand({
|
| 810 |
+
cfg,
|
| 811 |
+
sessionKey,
|
| 812 |
+
from: "discord:deferred-acp",
|
| 813 |
+
to: "discord:deferred-acp",
|
| 814 |
+
});
|
| 815 |
+
let replacement: ReturnType<typeof createReplyOperation> | undefined;
|
| 816 |
+
try {
|
| 817 |
+
await entered.promise;
|
| 818 |
+
const signaledBeforeAcpWait = native.abortSignal.aborted;
|
| 819 |
+
if (!signaledBeforeAcpWait) {
|
| 820 |
+
// This is still-live parent work, not a post-closure registration claim.
|
| 821 |
+
registerSubagentRun({
|
| 822 |
+
runId: "during-acp-wait",
|
| 823 |
+
childSessionKey: "agent:main:subagent:during-acp-wait",
|
| 824 |
+
requesterSessionKey: sessionKey,
|
| 825 |
+
requesterAgentId: "main",
|
| 826 |
+
requesterDisplayKey: sessionKey,
|
| 827 |
+
task: "registered before native parent was signaled",
|
| 828 |
+
cleanup: "keep",
|
| 829 |
+
collect: true,
|
| 830 |
+
queued: true,
|
| 831 |
+
});
|
| 832 |
+
}
|
| 833 |
+
const queueClearedBeforeAcpWait = getFollowupQueueDepth(sessionKey) === 0;
|
| 834 |
+
native.complete();
|
| 835 |
+
replacement = createReplyOperation({
|
| 836 |
+
sessionKey,
|
| 837 |
+
sessionId: "replacement-session",
|
| 838 |
+
resetTriggered: false,
|
| 839 |
+
});
|
| 840 |
+
replacement.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
|
| 841 |
+
proceed.resolve();
|
| 842 |
+
await pending;
|
| 843 |
+
expect(
|
| 844 |
+
signaledBeforeAcpWait,
|
| 845 |
+
"native parent must be signaled before the independent ACP await",
|
| 846 |
+
).toBe(true);
|
| 847 |
+
expect(queueClearedBeforeAcpWait).toBe(true);
|
| 848 |
+
expect(
|
| 849 |
+
replacement.abortSignal.aborted,
|
| 850 |
+
"do not rediscover a replacement parent after ACP settles",
|
| 851 |
+
).toBe(false);
|
| 852 |
+
expect(getSubagentRunByChildSessionKey("agent:main:subagent:during-acp-wait")).toBeNull();
|
| 853 |
+
} finally {
|
| 854 |
+
proceed.resolve();
|
| 855 |
+
await pending;
|
| 856 |
+
native.complete();
|
| 857 |
+
replacement?.complete();
|
| 858 |
+
}
|
| 859 |
+
});
|
| 860 |
+
|
| 861 |
+
it.each([undefined, "agent:main:main"])(
|
| 862 |
+
"propagates a zero-child callback failure for requester %s",
|
| 863 |
+
async (requesterSessionKey) => {
|
| 864 |
+
const beforeKill = vi.fn(() => {
|
| 865 |
+
throw new Error("parent cancellation failed");
|
| 866 |
+
});
|
| 867 |
+
await expect(
|
| 868 |
+
stopSubagentsForRequester({ cfg: {}, requesterSessionKey, beforeKill }),
|
| 869 |
+
).rejects.toThrow("parent cancellation failed");
|
| 870 |
+
expect(beforeKill).toHaveBeenCalledOnce();
|
| 871 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).not.toHaveBeenCalled();
|
| 872 |
+
},
|
| 873 |
+
);
|
| 874 |
+
|
| 875 |
+
it("fast-abort of an ACP target also aborts the bound source dispatch lane", async () => {
|
| 876 |
+
const sourceSessionKey = "agent:main:discord:channel:C1";
|
| 877 |
+
const acpSessionKey = "agent:codex:acp:bound-session";
|
| 878 |
+
const { root, cfg } = await createAbortConfig({
|
| 879 |
+
sessionIdsByKey: {
|
| 880 |
+
[sourceSessionKey]: "source-store-session",
|
| 881 |
+
[acpSessionKey]: "acp-store-session",
|
| 882 |
+
},
|
| 883 |
+
});
|
| 884 |
+
const sourceOperation = createReplyOperation({
|
| 885 |
+
sessionKey: sourceSessionKey,
|
| 886 |
+
sessionId: "source-active-session",
|
| 887 |
+
resetTriggered: false,
|
| 888 |
+
});
|
| 889 |
+
enqueueQueuedFollowupRun({
|
| 890 |
+
root,
|
| 891 |
+
cfg,
|
| 892 |
+
sessionId: "source-active-session",
|
| 893 |
+
sessionKey: sourceSessionKey,
|
| 894 |
+
});
|
| 895 |
+
enqueueQueuedFollowupRun({
|
| 896 |
+
root,
|
| 897 |
+
cfg,
|
| 898 |
+
sessionId: "acp-store-session",
|
| 899 |
+
sessionKey: acpSessionKey,
|
| 900 |
+
});
|
| 901 |
+
bindAcpSessionForTest(acpSessionKey);
|
| 902 |
+
acpManagerMocks.resolveSession.mockReturnValue({
|
| 903 |
+
kind: "ready",
|
| 904 |
+
sessionKey: acpSessionKey,
|
| 905 |
+
meta: {} as never,
|
| 906 |
+
});
|
| 907 |
+
|
| 908 |
+
const result = await runStopCommand({
|
| 909 |
+
cfg,
|
| 910 |
+
sessionKey: sourceSessionKey,
|
| 911 |
+
from: "discord:C1",
|
| 912 |
+
to: "discord:C1",
|
| 913 |
+
targetSessionKey: acpSessionKey,
|
| 914 |
+
commandSource: "native",
|
| 915 |
+
});
|
| 916 |
+
|
| 917 |
+
expect(result.handled).toBe(true);
|
| 918 |
+
expect(sourceOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
|
| 919 |
+
expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(false);
|
| 920 |
+
expect(getFollowupQueueDepth(sourceSessionKey)).toBe(0);
|
| 921 |
+
expect(getFollowupQueueDepth(acpSessionKey)).toBe(0);
|
| 922 |
+
expectSessionLaneCleared(sourceSessionKey);
|
| 923 |
+
expectSessionLaneCleared(acpSessionKey);
|
| 924 |
+
expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
|
| 925 |
+
cfg,
|
| 926 |
+
sessionKey: acpSessionKey,
|
| 927 |
+
reason: "fast-abort",
|
| 928 |
+
});
|
| 929 |
+
});
|
| 930 |
+
|
| 931 |
+
it("does not report /stop success after the active backend freezes its outcome", async () => {
|
| 932 |
+
const sessionKey = "agent:main:telegram:direct:finalizing";
|
| 933 |
+
const sessionId = "session-finalizing";
|
| 934 |
+
const { cfg } = await createAbortConfig({
|
| 935 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 936 |
+
});
|
| 937 |
+
const cancel = vi.fn();
|
| 938 |
+
const operation = createReplyOperation({
|
| 939 |
+
sessionKey,
|
| 940 |
+
sessionId,
|
| 941 |
+
resetTriggered: false,
|
| 942 |
+
});
|
| 943 |
+
operation.attachBackend({
|
| 944 |
+
kind: "embedded",
|
| 945 |
+
cancel,
|
| 946 |
+
isStreaming: () => false,
|
| 947 |
+
isAbortable: () => false,
|
| 948 |
+
});
|
| 949 |
+
operation.setPhase("running");
|
| 950 |
+
runtimeAbortMocks.abortEmbeddedAgentRun.mockReturnValue(false);
|
| 951 |
+
runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(sessionId);
|
| 952 |
+
vi.mocked(markSessionAbortTarget).mockClear();
|
| 953 |
+
|
| 954 |
+
const result = await runStopCommand({
|
| 955 |
+
cfg,
|
| 956 |
+
sessionKey,
|
| 957 |
+
from: "telegram:finalizing",
|
| 958 |
+
to: "telegram:finalizing",
|
| 959 |
+
});
|
| 960 |
+
|
| 961 |
+
expect(result).toMatchObject({
|
| 962 |
+
handled: true,
|
| 963 |
+
aborted: false,
|
| 964 |
+
rejectionReason: "finalizing",
|
| 965 |
+
});
|
| 966 |
+
expect(operation.result).toBeNull();
|
| 967 |
+
expect(replyRunRegistry.isActive(sessionKey)).toBe(true);
|
| 968 |
+
expect(cancel).not.toHaveBeenCalled();
|
| 969 |
+
expect(markSessionAbortTarget).not.toHaveBeenCalled();
|
| 970 |
+
expect(getAbortMemory(sessionKey)).toBeUndefined();
|
| 971 |
+
expect(formatAbortReplyText(undefined, result.rejectionReason)).toBe(
|
| 972 |
+
"Agent reply is already finalizing and can no longer be aborted.",
|
| 973 |
+
);
|
| 974 |
+
expect(formatAbortReplyText(0, undefined, 1)).toBe(
|
| 975 |
+
"⚙️ Agent was aborted. Cancellation was incomplete for 1 sub-agent. Retry /stop.",
|
| 976 |
+
);
|
| 977 |
+
operation.complete();
|
| 978 |
+
});
|
| 979 |
+
|
| 980 |
+
it("fast-abort of an ACP target aborts the source stored session when no source reply operation is registered", async () => {
|
| 981 |
+
const sourceSessionKey = "agent:main:discord:channel:C2";
|
| 982 |
+
const acpSessionKey = "agent:codex:acp:bound-session-stored-source";
|
| 983 |
+
const { root, cfg } = await createAbortConfig({
|
| 984 |
+
sessionIdsByKey: {
|
| 985 |
+
[sourceSessionKey]: "source-store-session",
|
| 986 |
+
[acpSessionKey]: "acp-store-session",
|
| 987 |
+
},
|
| 988 |
+
});
|
| 989 |
+
enqueueQueuedFollowupRun({
|
| 990 |
+
root,
|
| 991 |
+
cfg,
|
| 992 |
+
sessionId: "source-store-session",
|
| 993 |
+
sessionKey: sourceSessionKey,
|
| 994 |
+
});
|
| 995 |
+
enqueueQueuedFollowupRun({
|
| 996 |
+
root,
|
| 997 |
+
cfg,
|
| 998 |
+
sessionId: "acp-store-session",
|
| 999 |
+
sessionKey: acpSessionKey,
|
| 1000 |
+
});
|
| 1001 |
+
bindAcpSessionForTest(acpSessionKey);
|
| 1002 |
+
acpManagerMocks.resolveSession.mockReturnValue({
|
| 1003 |
+
kind: "ready",
|
| 1004 |
+
sessionKey: acpSessionKey,
|
| 1005 |
+
meta: {} as never,
|
| 1006 |
+
});
|
| 1007 |
+
|
| 1008 |
+
const result = await runStopCommand({
|
| 1009 |
+
cfg,
|
| 1010 |
+
sessionKey: sourceSessionKey,
|
| 1011 |
+
from: "discord:C2",
|
| 1012 |
+
to: "discord:C2",
|
| 1013 |
+
targetSessionKey: acpSessionKey,
|
| 1014 |
+
commandSource: "native",
|
| 1015 |
+
});
|
| 1016 |
+
|
| 1017 |
+
expect(result.handled).toBe(true);
|
| 1018 |
+
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith("source-store-session");
|
| 1019 |
+
expect(getFollowupQueueDepth(sourceSessionKey)).toBe(0);
|
| 1020 |
+
expect(getFollowupQueueDepth(acpSessionKey)).toBe(0);
|
| 1021 |
+
expectSessionLaneCleared(sourceSessionKey);
|
| 1022 |
+
expectSessionLaneCleared(acpSessionKey);
|
| 1023 |
+
});
|
| 1024 |
+
|
| 1025 |
+
it("does not abort the caller source lane for an unbound explicit ACP target", async () => {
|
| 1026 |
+
const sourceSessionKey = "agent:main:discord:channel:C3";
|
| 1027 |
+
const acpSessionKey = "agent:codex:acp:unbound-explicit-target";
|
| 1028 |
+
const { cfg } = await createAbortConfig({
|
| 1029 |
+
sessionIdsByKey: {
|
| 1030 |
+
[sourceSessionKey]: "source-store-session",
|
| 1031 |
+
[acpSessionKey]: "acp-store-session",
|
| 1032 |
+
},
|
| 1033 |
+
});
|
| 1034 |
+
const sourceOperation = createReplyOperation({
|
| 1035 |
+
sessionKey: sourceSessionKey,
|
| 1036 |
+
sessionId: "source-active-session",
|
| 1037 |
+
resetTriggered: false,
|
| 1038 |
+
});
|
| 1039 |
+
acpManagerMocks.resolveSession.mockReturnValue({
|
| 1040 |
+
kind: "ready",
|
| 1041 |
+
sessionKey: acpSessionKey,
|
| 1042 |
+
meta: {} as never,
|
| 1043 |
+
});
|
| 1044 |
+
|
| 1045 |
+
const result = await runStopCommand({
|
| 1046 |
+
cfg,
|
| 1047 |
+
sessionKey: sourceSessionKey,
|
| 1048 |
+
from: "discord:C3",
|
| 1049 |
+
to: "discord:C3",
|
| 1050 |
+
targetSessionKey: acpSessionKey,
|
| 1051 |
+
commandSource: "native",
|
| 1052 |
+
});
|
| 1053 |
+
|
| 1054 |
+
expect(result.handled).toBe(true);
|
| 1055 |
+
expect(sourceOperation.result).toBeNull();
|
| 1056 |
+
expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(true);
|
| 1057 |
+
expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
|
| 1058 |
+
cfg,
|
| 1059 |
+
sessionKey: acpSessionKey,
|
| 1060 |
+
reason: "fast-abort",
|
| 1061 |
+
});
|
| 1062 |
+
sourceOperation.complete();
|
| 1063 |
+
});
|
| 1064 |
+
|
| 1065 |
+
it("uses ParentSessionKey as the source lane for a bound explicit ACP target", async () => {
|
| 1066 |
+
const sourceSessionKey = "agent:main:discord:channel:C4";
|
| 1067 |
+
const acpSessionKey = "agent:codex:acp:bound-parent-source";
|
| 1068 |
+
const { cfg } = await createAbortConfig({
|
| 1069 |
+
sessionIdsByKey: {
|
| 1070 |
+
[sourceSessionKey]: "source-store-session",
|
| 1071 |
+
[acpSessionKey]: "acp-store-session",
|
| 1072 |
+
},
|
| 1073 |
+
});
|
| 1074 |
+
const sourceOperation = createReplyOperation({
|
| 1075 |
+
sessionKey: sourceSessionKey,
|
| 1076 |
+
sessionId: "source-active-session",
|
| 1077 |
+
resetTriggered: false,
|
| 1078 |
+
});
|
| 1079 |
+
bindAcpSessionForTest(acpSessionKey);
|
| 1080 |
+
acpManagerMocks.resolveSession.mockReturnValue({
|
| 1081 |
+
kind: "ready",
|
| 1082 |
+
sessionKey: acpSessionKey,
|
| 1083 |
+
meta: {} as never,
|
| 1084 |
+
});
|
| 1085 |
+
|
| 1086 |
+
const result = await runStopCommand({
|
| 1087 |
+
cfg,
|
| 1088 |
+
parentSessionKey: sourceSessionKey,
|
| 1089 |
+
from: "discord:C4",
|
| 1090 |
+
to: "discord:C4",
|
| 1091 |
+
targetSessionKey: acpSessionKey,
|
| 1092 |
+
commandSource: "native",
|
| 1093 |
+
});
|
| 1094 |
+
|
| 1095 |
+
expect(result.handled).toBe(true);
|
| 1096 |
+
expect(sourceOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
|
| 1097 |
+
expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(false);
|
| 1098 |
+
});
|
| 1099 |
+
|
| 1100 |
+
it("fast-abort from an ACP-bound source conversation aborts source and bound ACP lanes", async () => {
|
| 1101 |
+
const sourceSessionKey = "agent:main:telegram:direct:source-1";
|
| 1102 |
+
const acpSessionKey = "agent:codex:acp:bound-source-stop";
|
| 1103 |
+
const { root, storePath, cfg } = await createAbortConfig({
|
| 1104 |
+
sessionIdsByKey: {
|
| 1105 |
+
[sourceSessionKey]: "source-store-session",
|
| 1106 |
+
[acpSessionKey]: "acp-store-session",
|
| 1107 |
+
},
|
| 1108 |
+
});
|
| 1109 |
+
const sourceOperation = createReplyOperation({
|
| 1110 |
+
sessionKey: sourceSessionKey,
|
| 1111 |
+
sessionId: "source-active-session",
|
| 1112 |
+
resetTriggered: false,
|
| 1113 |
+
});
|
| 1114 |
+
const acpOperation = createReplyOperation({
|
| 1115 |
+
sessionKey: acpSessionKey,
|
| 1116 |
+
sessionId: "acp-active-session",
|
| 1117 |
+
resetTriggered: false,
|
| 1118 |
+
});
|
| 1119 |
+
enqueueQueuedFollowupRun({
|
| 1120 |
+
root,
|
| 1121 |
+
cfg,
|
| 1122 |
+
sessionId: "source-active-session",
|
| 1123 |
+
sessionKey: sourceSessionKey,
|
| 1124 |
+
});
|
| 1125 |
+
enqueueQueuedFollowupRun({
|
| 1126 |
+
root,
|
| 1127 |
+
cfg,
|
| 1128 |
+
sessionId: "acp-active-session",
|
| 1129 |
+
sessionKey: acpSessionKey,
|
| 1130 |
+
});
|
| 1131 |
+
bindAcpSessionForTest(acpSessionKey);
|
| 1132 |
+
acpManagerMocks.resolveSession.mockReturnValue({
|
| 1133 |
+
kind: "ready",
|
| 1134 |
+
sessionKey: acpSessionKey,
|
| 1135 |
+
meta: {} as never,
|
| 1136 |
+
});
|
| 1137 |
+
|
| 1138 |
+
const result = await runStopCommand({
|
| 1139 |
+
cfg,
|
| 1140 |
+
sessionKey: sourceSessionKey,
|
| 1141 |
+
from: "telegram:source-1",
|
| 1142 |
+
to: "telegram:source-1",
|
| 1143 |
+
messageSid: "77",
|
| 1144 |
+
timestamp: 1234567890000,
|
| 1145 |
+
});
|
| 1146 |
+
|
| 1147 |
+
expect(result.handled).toBe(true);
|
| 1148 |
+
expect(sourceOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
|
| 1149 |
+
expect(acpOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
|
| 1150 |
+
expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(false);
|
| 1151 |
+
expect(replyRunRegistry.isActive(acpSessionKey)).toBe(false);
|
| 1152 |
+
expect(getFollowupQueueDepth(sourceSessionKey)).toBe(0);
|
| 1153 |
+
expect(getFollowupQueueDepth(acpSessionKey)).toBe(0);
|
| 1154 |
+
expectSessionLaneCleared(sourceSessionKey);
|
| 1155 |
+
expectSessionLaneCleared(acpSessionKey);
|
| 1156 |
+
expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
|
| 1157 |
+
cfg,
|
| 1158 |
+
sessionKey: acpSessionKey,
|
| 1159 |
+
reason: "fast-abort",
|
| 1160 |
+
});
|
| 1161 |
+
const sourceEntry = readAbortSessionEntry(storePath, sourceSessionKey);
|
| 1162 |
+
const acpEntry = readAbortSessionEntry(storePath, acpSessionKey);
|
| 1163 |
+
expect(sourceEntry?.abortCutoffMessageSid).toBe("77");
|
| 1164 |
+
expect(sourceEntry?.abortCutoffTimestamp).toBe(1234567890000);
|
| 1165 |
+
expect(acpEntry?.abortCutoffMessageSid).toBeUndefined();
|
| 1166 |
+
expect(acpEntry?.abortCutoffTimestamp).toBeUndefined();
|
| 1167 |
+
});
|
| 1168 |
+
|
| 1169 |
+
it("persists abort cutoff metadata on /stop when command and target session match", async () => {
|
| 1170 |
+
const sessionKey = "telegram:123";
|
| 1171 |
+
const sessionId = "session-123";
|
| 1172 |
+
const { storePath, cfg } = await createAbortConfig({
|
| 1173 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 1174 |
+
});
|
| 1175 |
+
|
| 1176 |
+
const result = await runStopCommand({
|
| 1177 |
+
cfg,
|
| 1178 |
+
sessionKey,
|
| 1179 |
+
from: "telegram:123",
|
| 1180 |
+
to: "telegram:123",
|
| 1181 |
+
messageSid: "55",
|
| 1182 |
+
timestamp: 1234567890000,
|
| 1183 |
+
});
|
| 1184 |
+
|
| 1185 |
+
expect(result.handled).toBe(true);
|
| 1186 |
+
const entry = readAbortSessionEntry(storePath, sessionKey);
|
| 1187 |
+
expect(entry?.abortedLastRun).toBe(true);
|
| 1188 |
+
expect(entry?.abortCutoffMessageSid).toBe("55");
|
| 1189 |
+
expect(entry?.abortCutoffTimestamp).toBe(1234567890000);
|
| 1190 |
+
});
|
| 1191 |
+
|
| 1192 |
+
it("persists abort cutoff metadata when only ParentSessionKey identifies the command session", async () => {
|
| 1193 |
+
const sessionKey = "telegram:parent-only";
|
| 1194 |
+
const sessionId = "session-parent-only";
|
| 1195 |
+
const { storePath, cfg } = await createAbortConfig({
|
| 1196 |
+
sessionIdsByKey: { [sessionKey]: sessionId },
|
| 1197 |
+
});
|
| 1198 |
+
|
| 1199 |
+
const result = await runStopCommand({
|
| 1200 |
+
cfg,
|
| 1201 |
+
parentSessionKey: sessionKey,
|
| 1202 |
+
from: "telegram:parent-only",
|
| 1203 |
+
to: "telegram:parent-only",
|
| 1204 |
+
messageSid: "56",
|
| 1205 |
+
timestamp: 1234567890001,
|
| 1206 |
+
});
|
| 1207 |
+
|
| 1208 |
+
expect(result.handled).toBe(true);
|
| 1209 |
+
const entry = readAbortSessionEntry(storePath, sessionKey);
|
| 1210 |
+
expect(entry?.abortedLastRun).toBe(true);
|
| 1211 |
+
expect(entry?.abortCutoffMessageSid).toBe("56");
|
| 1212 |
+
expect(entry?.abortCutoffTimestamp).toBe(1234567890001);
|
| 1213 |
+
});
|
| 1214 |
+
|
| 1215 |
+
it("does not persist cutoff metadata when native /stop targets a different session", async () => {
|
| 1216 |
+
const slashSessionKey = "telegram:slash:123";
|
| 1217 |
+
const targetSessionKey = "agent:main:telegram:group:123";
|
| 1218 |
+
const targetSessionId = "session-target";
|
| 1219 |
+
const { storePath, cfg } = await createAbortConfig({
|
| 1220 |
+
sessionIdsByKey: { [targetSessionKey]: targetSessionId },
|
| 1221 |
+
});
|
| 1222 |
+
|
| 1223 |
+
const result = await runStopCommand({
|
| 1224 |
+
cfg,
|
| 1225 |
+
sessionKey: slashSessionKey,
|
| 1226 |
+
from: "telegram:123",
|
| 1227 |
+
to: "telegram:123",
|
| 1228 |
+
targetSessionKey,
|
| 1229 |
+
messageSid: "999",
|
| 1230 |
+
timestamp: 1234567890000,
|
| 1231 |
+
});
|
| 1232 |
+
|
| 1233 |
+
expect(result.handled).toBe(true);
|
| 1234 |
+
const entry = readAbortSessionEntry(storePath, targetSessionKey);
|
| 1235 |
+
expect(entry?.abortedLastRun).toBe(true);
|
| 1236 |
+
expect(entry?.abortCutoffMessageSid).toBeUndefined();
|
| 1237 |
+
expect(entry?.abortCutoffTimestamp).toBeUndefined();
|
| 1238 |
+
});
|
| 1239 |
+
|
| 1240 |
+
it("fast-abort stops active subagent runs for requester session", async () => {
|
| 1241 |
+
const sessionKey = "telegram:parent";
|
| 1242 |
+
const childKey = "agent:main:subagent:child-1";
|
| 1243 |
+
const sessionId = "session-parent";
|
| 1244 |
+
const childSessionId = "session-child";
|
| 1245 |
+
const { cfg } = await createAbortConfig({
|
| 1246 |
+
sessionIdsByKey: {
|
| 1247 |
+
[sessionKey]: sessionId,
|
| 1248 |
+
[childKey]: childSessionId,
|
| 1249 |
+
},
|
| 1250 |
+
});
|
| 1251 |
+
|
| 1252 |
+
addSubagentFixture({
|
| 1253 |
+
runId: "run-1",
|
| 1254 |
+
childSessionKey: childKey,
|
| 1255 |
+
requesterSessionKey: sessionKey,
|
| 1256 |
+
requesterDisplayKey: "telegram:parent",
|
| 1257 |
+
task: "do work",
|
| 1258 |
+
cleanup: "keep",
|
| 1259 |
+
createdAt: Date.now(),
|
| 1260 |
+
});
|
| 1261 |
+
|
| 1262 |
+
const result = await runStopCommand({
|
| 1263 |
+
cfg,
|
| 1264 |
+
sessionKey,
|
| 1265 |
+
from: "telegram:parent",
|
| 1266 |
+
to: "telegram:parent",
|
| 1267 |
+
});
|
| 1268 |
+
|
| 1269 |
+
expect(result.stoppedSubagents).toBe(1);
|
| 1270 |
+
expectSessionLaneCleared(childKey);
|
| 1271 |
+
});
|
| 1272 |
+
|
| 1273 |
+
it("continues stopping siblings when one termination persistence write fails", async () => {
|
| 1274 |
+
const sessionKey = "telegram:persistence-failure-parent";
|
| 1275 |
+
const firstChildKey = "agent:main:subagent:persistence-failure-first";
|
| 1276 |
+
const secondChildKey = "agent:main:subagent:persistence-failure-second";
|
| 1277 |
+
const run = (runId: string, childSessionKey: string): SubagentRunFixture => ({
|
| 1278 |
+
runId,
|
| 1279 |
+
childSessionKey,
|
| 1280 |
+
requesterSessionKey: sessionKey,
|
| 1281 |
+
requesterDisplayKey: sessionKey,
|
| 1282 |
+
task: "stop despite persistence failure",
|
| 1283 |
+
cleanup: "keep",
|
| 1284 |
+
createdAt: Date.now(),
|
| 1285 |
+
});
|
| 1286 |
+
for (const fixture of [
|
| 1287 |
+
run("run-persistence-failure-first", firstChildKey),
|
| 1288 |
+
run("run-persistence-failure-second", secondChildKey),
|
| 1289 |
+
]) {
|
| 1290 |
+
addSubagentFixture(fixture);
|
| 1291 |
+
}
|
| 1292 |
+
let failedTombstone = false;
|
| 1293 |
+
subagentRegistryTesting.setDepsForTest({
|
| 1294 |
+
persistSubagentRunsToDiskOrThrow: (runs, changedRunIds) => {
|
| 1295 |
+
const first = runs.get("run-persistence-failure-first");
|
| 1296 |
+
if (
|
| 1297 |
+
!failedTombstone &&
|
| 1298 |
+
changedRunIds?.includes("run-persistence-failure-first") &&
|
| 1299 |
+
first?.execution.status === "terminal" &&
|
| 1300 |
+
first.endedReason === "subagent-killed"
|
| 1301 |
+
) {
|
| 1302 |
+
failedTombstone = true;
|
| 1303 |
+
throw new Error("sqlite busy");
|
| 1304 |
+
}
|
| 1305 |
+
},
|
| 1306 |
+
});
|
| 1307 |
+
|
| 1308 |
+
await expect(
|
| 1309 |
+
stopSubagentsForRequester({
|
| 1310 |
+
cfg: {} as OpenClawConfig,
|
| 1311 |
+
requesterSessionKey: sessionKey,
|
| 1312 |
+
}),
|
| 1313 |
+
).resolves.toEqual({ stopped: 1, failed: 1 });
|
| 1314 |
+
expect(failedTombstone).toBe(true);
|
| 1315 |
+
expect(getSubagentRunByChildSessionKey(firstChildKey)?.killIntent).toBeDefined();
|
| 1316 |
+
expect(getSubagentRunByChildSessionKey(secondChildKey)?.endedReason).toBe("subagent-killed");
|
| 1317 |
+
expectSessionLaneCleared(firstChildKey);
|
| 1318 |
+
expectSessionLaneCleared(secondChildKey);
|
| 1319 |
+
});
|
| 1320 |
+
|
| 1321 |
+
it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => {
|
| 1322 |
+
const sessionKey = "telegram:parent";
|
| 1323 |
+
const depth1Key = "agent:main:subagent:child-1";
|
| 1324 |
+
const depth2Key = "agent:main:subagent:child-1:subagent:grandchild-1";
|
| 1325 |
+
const sessionId = "session-parent";
|
| 1326 |
+
const depth1SessionId = "session-child";
|
| 1327 |
+
const depth2SessionId = "session-grandchild";
|
| 1328 |
+
const { cfg } = await createAbortConfig({
|
| 1329 |
+
sessionIdsByKey: {
|
| 1330 |
+
[sessionKey]: sessionId,
|
| 1331 |
+
[depth1Key]: depth1SessionId,
|
| 1332 |
+
[depth2Key]: depth2SessionId,
|
| 1333 |
+
},
|
| 1334 |
+
});
|
| 1335 |
+
|
| 1336 |
+
addSubagentFixture({
|
| 1337 |
+
runId: "run-1",
|
| 1338 |
+
childSessionKey: depth1Key,
|
| 1339 |
+
requesterSessionKey: sessionKey,
|
| 1340 |
+
requesterDisplayKey: "telegram:parent",
|
| 1341 |
+
task: "orchestrator",
|
| 1342 |
+
cleanup: "keep",
|
| 1343 |
+
createdAt: Date.now(),
|
| 1344 |
+
});
|
| 1345 |
+
addSubagentFixture({
|
| 1346 |
+
runId: "run-2",
|
| 1347 |
+
childSessionKey: depth2Key,
|
| 1348 |
+
requesterSessionKey: depth1Key,
|
| 1349 |
+
requesterDisplayKey: depth1Key,
|
| 1350 |
+
task: "leaf worker",
|
| 1351 |
+
cleanup: "keep",
|
| 1352 |
+
createdAt: Date.now(),
|
| 1353 |
+
});
|
| 1354 |
+
|
| 1355 |
+
const result = await runStopCommand({
|
| 1356 |
+
cfg,
|
| 1357 |
+
sessionKey,
|
| 1358 |
+
from: "telegram:parent",
|
| 1359 |
+
to: "telegram:parent",
|
| 1360 |
+
});
|
| 1361 |
+
|
| 1362 |
+
// Should stop both depth-1 and depth-2 agents (cascade)
|
| 1363 |
+
expect(result.stoppedSubagents).toBe(2);
|
| 1364 |
+
expectSessionLaneCleared(depth1Key);
|
| 1365 |
+
expectSessionLaneCleared(depth2Key);
|
| 1366 |
+
});
|
| 1367 |
+
|
| 1368 |
+
it("stops a subagent that is paused after yielding", async () => {
|
| 1369 |
+
const sessionKey = "telegram:yield-parent";
|
| 1370 |
+
const childKey = "agent:main:subagent:yield-child";
|
| 1371 |
+
const now = Date.now();
|
| 1372 |
+
addSubagentFixture({
|
| 1373 |
+
runId: "run-yield-child",
|
| 1374 |
+
childSessionKey: childKey,
|
| 1375 |
+
requesterSessionKey: sessionKey,
|
| 1376 |
+
requesterDisplayKey: sessionKey,
|
| 1377 |
+
task: "paused worker",
|
| 1378 |
+
cleanup: "keep",
|
| 1379 |
+
createdAt: now - 1_000,
|
| 1380 |
+
endedAt: now - 500,
|
| 1381 |
+
pauseReason: "sessions_yield",
|
| 1382 |
+
});
|
| 1383 |
+
|
| 1384 |
+
const result = await stopSubagentsForRequester({
|
| 1385 |
+
cfg: {} as OpenClawConfig,
|
| 1386 |
+
requesterSessionKey: sessionKey,
|
| 1387 |
+
});
|
| 1388 |
+
|
| 1389 |
+
expect(result).toEqual({ stopped: 1, failed: 0 });
|
| 1390 |
+
expectSessionLaneCleared(childKey);
|
| 1391 |
+
expect(getSubagentRunByChildSessionKey(childKey)).toMatchObject({
|
| 1392 |
+
endedReason: "subagent-killed",
|
| 1393 |
+
killReconciliation: { suppressTaskDelivery: true },
|
| 1394 |
+
});
|
| 1395 |
+
});
|
| 1396 |
+
|
| 1397 |
+
it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => {
|
| 1398 |
+
const sessionKey = "telegram:parent";
|
| 1399 |
+
const depth1Key = "agent:main:subagent:child-ended";
|
| 1400 |
+
const depth2Key = "agent:main:subagent:child-ended:subagent:grandchild-active";
|
| 1401 |
+
const now = Date.now();
|
| 1402 |
+
const { cfg } = await createAbortConfig({
|
| 1403 |
+
nowMs: now,
|
| 1404 |
+
sessionIdsByKey: {
|
| 1405 |
+
[sessionKey]: "session-parent",
|
| 1406 |
+
[depth1Key]: "session-child-ended",
|
| 1407 |
+
[depth2Key]: "session-grandchild-active",
|
| 1408 |
+
},
|
| 1409 |
+
});
|
| 1410 |
+
|
| 1411 |
+
addSubagentFixture({
|
| 1412 |
+
runId: "run-1",
|
| 1413 |
+
childSessionKey: depth1Key,
|
| 1414 |
+
requesterSessionKey: sessionKey,
|
| 1415 |
+
requesterDisplayKey: "telegram:parent",
|
| 1416 |
+
task: "orchestrator",
|
| 1417 |
+
cleanup: "keep",
|
| 1418 |
+
createdAt: now - 1_000,
|
| 1419 |
+
endedAt: now - 500,
|
| 1420 |
+
outcome: { status: "ok" },
|
| 1421 |
+
});
|
| 1422 |
+
addSubagentFixture({
|
| 1423 |
+
runId: "run-2",
|
| 1424 |
+
childSessionKey: depth2Key,
|
| 1425 |
+
requesterSessionKey: depth1Key,
|
| 1426 |
+
requesterDisplayKey: depth1Key,
|
| 1427 |
+
task: "leaf worker",
|
| 1428 |
+
cleanup: "keep",
|
| 1429 |
+
createdAt: now - 500,
|
| 1430 |
+
});
|
| 1431 |
+
|
| 1432 |
+
const result = await runStopCommand({
|
| 1433 |
+
cfg,
|
| 1434 |
+
sessionKey,
|
| 1435 |
+
from: "telegram:parent",
|
| 1436 |
+
to: "telegram:parent",
|
| 1437 |
+
});
|
| 1438 |
+
|
| 1439 |
+
// Should skip killing the ended depth-1 run itself, but still kill depth-2.
|
| 1440 |
+
expect(result.stoppedSubagents).toBe(1);
|
| 1441 |
+
expectSessionLaneCleared(depth2Key);
|
| 1442 |
+
expect(getSubagentRunByChildSessionKey(depth1Key)?.endedReason).not.toBe("subagent-killed");
|
| 1443 |
+
expect(getSubagentRunByChildSessionKey(depth2Key)?.endedReason).toBe("subagent-killed");
|
| 1444 |
+
});
|
| 1445 |
+
|
| 1446 |
+
it("cascade stop still traverses an ended current parent when a stale older active row exists", async () => {
|
| 1447 |
+
const sessionKey = "telegram:parent";
|
| 1448 |
+
const depth1Key = "agent:main:subagent:child-ended-stale";
|
| 1449 |
+
const depth2Key = "agent:main:subagent:child-ended-stale:subagent:grandchild-active";
|
| 1450 |
+
const now = Date.now();
|
| 1451 |
+
const { cfg } = await createAbortConfig({
|
| 1452 |
+
nowMs: now,
|
| 1453 |
+
sessionIdsByKey: {
|
| 1454 |
+
[sessionKey]: "session-parent",
|
| 1455 |
+
[depth1Key]: "session-child-ended-stale",
|
| 1456 |
+
[depth2Key]: "session-grandchild-active",
|
| 1457 |
+
},
|
| 1458 |
+
});
|
| 1459 |
+
|
| 1460 |
+
for (const fixture of [
|
| 1461 |
+
{
|
| 1462 |
+
runId: "run-stale-parent",
|
| 1463 |
+
childSessionKey: depth1Key,
|
| 1464 |
+
requesterSessionKey: sessionKey,
|
| 1465 |
+
requesterDisplayKey: "telegram:parent",
|
| 1466 |
+
task: "stale orchestrator",
|
| 1467 |
+
cleanup: "keep",
|
| 1468 |
+
createdAt: now - 2_000,
|
| 1469 |
+
startedAt: now - 1_900,
|
| 1470 |
+
},
|
| 1471 |
+
{
|
| 1472 |
+
runId: "run-current-parent",
|
| 1473 |
+
childSessionKey: depth1Key,
|
| 1474 |
+
requesterSessionKey: sessionKey,
|
| 1475 |
+
requesterDisplayKey: "telegram:parent",
|
| 1476 |
+
task: "current orchestrator",
|
| 1477 |
+
cleanup: "keep",
|
| 1478 |
+
createdAt: now - 1_000,
|
| 1479 |
+
startedAt: now - 900,
|
| 1480 |
+
endedAt: now - 500,
|
| 1481 |
+
outcome: { status: "ok" },
|
| 1482 |
+
},
|
| 1483 |
+
] satisfies SubagentRunFixture[]) {
|
| 1484 |
+
addSubagentFixture(fixture);
|
| 1485 |
+
}
|
| 1486 |
+
addSubagentFixture({
|
| 1487 |
+
runId: "run-active-child",
|
| 1488 |
+
childSessionKey: depth2Key,
|
| 1489 |
+
requesterSessionKey: depth1Key,
|
| 1490 |
+
requesterDisplayKey: depth1Key,
|
| 1491 |
+
task: "leaf worker",
|
| 1492 |
+
cleanup: "keep",
|
| 1493 |
+
createdAt: now - 400,
|
| 1494 |
+
});
|
| 1495 |
+
|
| 1496 |
+
const result = await runStopCommand({
|
| 1497 |
+
cfg,
|
| 1498 |
+
sessionKey,
|
| 1499 |
+
from: "telegram:parent",
|
| 1500 |
+
to: "telegram:parent",
|
| 1501 |
+
});
|
| 1502 |
+
|
| 1503 |
+
expect(result.stoppedSubagents).toBe(1);
|
| 1504 |
+
expectSessionLaneCleared(depth2Key);
|
| 1505 |
+
expect(getSubagentRunByChildSessionKey(depth1Key)?.endedReason).not.toBe("subagent-killed");
|
| 1506 |
+
expect(getSubagentRunByChildSessionKey(depth2Key)?.endedReason).toBe("subagent-killed");
|
| 1507 |
+
});
|
| 1508 |
+
|
| 1509 |
+
it("stopSubagentsForRequester does not traverse a child that moved to a newer parent", async () => {
|
| 1510 |
+
const oldParentKey = "agent:main:subagent:old-parent";
|
| 1511 |
+
const newParentKey = "agent:main:subagent:new-parent";
|
| 1512 |
+
const childKey = "agent:main:subagent:shared-child";
|
| 1513 |
+
const leafKey = `${childKey}:subagent:leaf`;
|
| 1514 |
+
const now = Date.now();
|
| 1515 |
+
|
| 1516 |
+
addSubagentFixture({
|
| 1517 |
+
runId: "run-shared-child-stale-parent",
|
| 1518 |
+
childSessionKey: childKey,
|
| 1519 |
+
requesterSessionKey: oldParentKey,
|
| 1520 |
+
controllerSessionKey: oldParentKey,
|
| 1521 |
+
requesterDisplayKey: oldParentKey,
|
| 1522 |
+
task: "shared child stale parent",
|
| 1523 |
+
cleanup: "keep",
|
| 1524 |
+
createdAt: now - 2_000,
|
| 1525 |
+
endedAt: now - 1_000,
|
| 1526 |
+
outcome: { status: "ok" },
|
| 1527 |
+
});
|
| 1528 |
+
addSubagentFixture({
|
| 1529 |
+
runId: "run-leaf-active",
|
| 1530 |
+
childSessionKey: leafKey,
|
| 1531 |
+
requesterSessionKey: childKey,
|
| 1532 |
+
controllerSessionKey: childKey,
|
| 1533 |
+
requesterDisplayKey: childKey,
|
| 1534 |
+
task: "leaf worker",
|
| 1535 |
+
cleanup: "keep",
|
| 1536 |
+
createdAt: now - 500,
|
| 1537 |
+
});
|
| 1538 |
+
addSubagentFixture({
|
| 1539 |
+
runId: "run-shared-child-current-parent",
|
| 1540 |
+
childSessionKey: childKey,
|
| 1541 |
+
requesterSessionKey: newParentKey,
|
| 1542 |
+
controllerSessionKey: newParentKey,
|
| 1543 |
+
requesterDisplayKey: newParentKey,
|
| 1544 |
+
task: "shared child current parent",
|
| 1545 |
+
cleanup: "keep",
|
| 1546 |
+
createdAt: now - 250,
|
| 1547 |
+
});
|
| 1548 |
+
|
| 1549 |
+
const result = await stopSubagentsForRequester({
|
| 1550 |
+
cfg: {} as OpenClawConfig,
|
| 1551 |
+
requesterSessionKey: oldParentKey,
|
| 1552 |
+
});
|
| 1553 |
+
|
| 1554 |
+
expect(result).toEqual({ stopped: 0, failed: 0 });
|
| 1555 |
+
expect(getSubagentRunByChildSessionKey(childKey)?.execution.endedAt).toBeUndefined();
|
| 1556 |
+
expect(getSubagentRunByChildSessionKey(leafKey)?.execution.endedAt).toBeUndefined();
|
| 1557 |
+
});
|
| 1558 |
+
});
|
| 1559 |
+
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
src/auto-reply/reply/abort.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Recognizes stop requests before loading active-run cancellation machinery.
|
| 2 |
+
import {
|
| 3 |
+
normalizeOptionalLowercaseString,
|
| 4 |
+
normalizeOptionalString,
|
| 5 |
+
} from "@openclaw/normalization-core/string-coerce";
|
| 6 |
+
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
| 7 |
+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
| 8 |
+
import type { FinalizedRuntimeMsgContext } from "../templating.js";
|
| 9 |
+
import { isAbortRequestText } from "./abort-primitives.js";
|
| 10 |
+
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
| 11 |
+
|
| 12 |
+
export type FastAbortRequestParams = {
|
| 13 |
+
ctx: FinalizedRuntimeMsgContext;
|
| 14 |
+
cfg: OpenClawConfig;
|
| 15 |
+
isCommandTargetCurrent?: () => boolean;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export type FastAbortResult = {
|
| 19 |
+
handled: boolean;
|
| 20 |
+
aborted: boolean;
|
| 21 |
+
rejectionReason?: "finalizing";
|
| 22 |
+
stoppedSubagents?: number;
|
| 23 |
+
failedSubagents?: number;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export function formatAbortReplyText(
|
| 27 |
+
stoppedSubagents?: number,
|
| 28 |
+
rejectionReason?: "finalizing",
|
| 29 |
+
failedSubagents?: number,
|
| 30 |
+
): string {
|
| 31 |
+
const failureSuffix =
|
| 32 |
+
typeof failedSubagents === "number" && failedSubagents > 0
|
| 33 |
+
? ` Cancellation was incomplete for ${failedSubagents} sub-agent${failedSubagents === 1 ? "" : "s"}. Retry /stop.`
|
| 34 |
+
: "";
|
| 35 |
+
if (rejectionReason === "finalizing") {
|
| 36 |
+
const base = "Agent reply is already finalizing and can no longer be aborted.";
|
| 37 |
+
if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {
|
| 38 |
+
return `${base}${failureSuffix}`;
|
| 39 |
+
}
|
| 40 |
+
const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents";
|
| 41 |
+
return `${base} Stopped ${stoppedSubagents} ${label}.${failureSuffix}`;
|
| 42 |
+
}
|
| 43 |
+
if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {
|
| 44 |
+
return `⚙️ Agent was aborted.${failureSuffix}`;
|
| 45 |
+
}
|
| 46 |
+
const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents";
|
| 47 |
+
return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.${failureSuffix}`;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/** Normalize ingress once; current authorization belongs to the loaded operation. */
|
| 51 |
+
function resolveFastAbortRequest(params: FastAbortRequestParams) {
|
| 52 |
+
const { ctx, cfg } = params;
|
| 53 |
+
const commandSessionKey =
|
| 54 |
+
normalizeOptionalString(ctx.SessionKey) ?? normalizeOptionalString(ctx.ParentSessionKey);
|
| 55 |
+
const targetKey = normalizeOptionalString(ctx.CommandTargetSessionKey) ?? commandSessionKey;
|
| 56 |
+
const resolveTargetAgentId = () =>
|
| 57 |
+
resolveSessionAgentId({
|
| 58 |
+
sessionKey: targetKey ?? ctx.SessionKey ?? "",
|
| 59 |
+
config: cfg,
|
| 60 |
+
fallbackAgentId: ctx.AgentId,
|
| 61 |
+
});
|
| 62 |
+
const raw = stripStructuralPrefixes(ctx.commandText);
|
| 63 |
+
const isGroup = normalizeOptionalLowercaseString(ctx.ChatType) === "group";
|
| 64 |
+
const stripped = isGroup ? stripMentions(raw, ctx, cfg, resolveTargetAgentId()) : raw;
|
| 65 |
+
const abortRequested = isAbortRequestText(stripped);
|
| 66 |
+
if (!abortRequested) {
|
| 67 |
+
return undefined;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return { commandSessionKey, targetKey, resolveTargetAgentId };
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export type PreparedFastAbortRequest = NonNullable<ReturnType<typeof resolveFastAbortRequest>>;
|
| 74 |
+
|
| 75 |
+
export async function tryFastAbortFromMessage(
|
| 76 |
+
params: FastAbortRequestParams,
|
| 77 |
+
): Promise<FastAbortResult> {
|
| 78 |
+
const request = resolveFastAbortRequest(params);
|
| 79 |
+
if (!request) {
|
| 80 |
+
return { handled: false, aborted: false };
|
| 81 |
+
}
|
| 82 |
+
// Normalized ingress is not authority. The operation resolves authorization
|
| 83 |
+
// and retains current-target validation after loading cancellation machinery.
|
| 84 |
+
const { executeFastAbortRequest } = await import("./abort-operation.js");
|
| 85 |
+
return executeFastAbortRequest(params, request);
|
| 86 |
+
}
|
src/auto-reply/reply/acp-elicitation-handler-lazy.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { AcpElicitationHandler } from "@openclaw/acp-core/runtime/types";
|
| 2 |
+
import type { AcpElicitationHandlerParams } from "./acp-elicitation-handler.js";
|
| 3 |
+
|
| 4 |
+
/** Defers the structured-input stack while preserving one exact handler per turn. */
|
| 5 |
+
export function createLazyAcpElicitationHandler(
|
| 6 |
+
params: AcpElicitationHandlerParams,
|
| 7 |
+
): AcpElicitationHandler {
|
| 8 |
+
let handler: Promise<AcpElicitationHandler> | undefined;
|
| 9 |
+
return async (request, context) => {
|
| 10 |
+
handler ??= import("./acp-elicitation-handler.js").then(({ createAcpElicitationHandler }) =>
|
| 11 |
+
createAcpElicitationHandler(params),
|
| 12 |
+
);
|
| 13 |
+
return (await handler)(request, context);
|
| 14 |
+
};
|
| 15 |
+
}
|
src/auto-reply/reply/acp-elicitation-handler.test.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { AcpElicitationRequest } from "@openclaw/acp-core/runtime/types";
|
| 2 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { claimPendingAgentQuestionAnswer } from "../../agents/harness/gateway-question.js";
|
| 4 |
+
|
| 5 |
+
const gatewayCallMock = vi.hoisted(() => vi.fn());
|
| 6 |
+
|
| 7 |
+
vi.mock("../../agents/tools/gateway.js", () => ({ callGatewayTool: gatewayCallMock }));
|
| 8 |
+
|
| 9 |
+
import { createAcpElicitationHandler } from "./acp-elicitation-handler.js";
|
| 10 |
+
|
| 11 |
+
type GatewayQuestion = { questionId: string; options?: Array<{ label: string }> };
|
| 12 |
+
type GatewayRequest = {
|
| 13 |
+
id: string;
|
| 14 |
+
questions: GatewayQuestion[];
|
| 15 |
+
sessionKey?: string;
|
| 16 |
+
runId?: string;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
function formRequest(
|
| 20 |
+
properties: Record<string, unknown>,
|
| 21 |
+
required: string[] = [],
|
| 22 |
+
): AcpElicitationRequest {
|
| 23 |
+
return {
|
| 24 |
+
mode: "form",
|
| 25 |
+
sessionId: "acp-session",
|
| 26 |
+
toolCallId: "tool-call",
|
| 27 |
+
message: "ACP needs input",
|
| 28 |
+
requestedSchema: { type: "object", properties, required },
|
| 29 |
+
} as AcpElicitationRequest;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function urlRequest(overrides: Partial<Record<string, unknown>> = {}): AcpElicitationRequest {
|
| 33 |
+
return {
|
| 34 |
+
mode: "url",
|
| 35 |
+
sessionId: "acp-session",
|
| 36 |
+
toolCallId: "tool-call",
|
| 37 |
+
message: "Authenticate",
|
| 38 |
+
elicitationId: "login-1",
|
| 39 |
+
url: "https://example.com/login",
|
| 40 |
+
...overrides,
|
| 41 |
+
} as AcpElicitationRequest;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function createFixture() {
|
| 45 |
+
const delivered: string[] = [];
|
| 46 |
+
let active = true;
|
| 47 |
+
const handler = createAcpElicitationHandler({
|
| 48 |
+
sourceSessionKey: "agent:main:source",
|
| 49 |
+
targetSessionKey: "agent:codex:acp:target",
|
| 50 |
+
outerRequestId: "outer-turn",
|
| 51 |
+
agentId: "main",
|
| 52 |
+
runId: "run-1",
|
| 53 |
+
delivery: {
|
| 54 |
+
deliver: vi.fn(async (_kind, payload) => {
|
| 55 |
+
if (payload.text) {
|
| 56 |
+
delivered.push(payload.text);
|
| 57 |
+
}
|
| 58 |
+
return true;
|
| 59 |
+
}),
|
| 60 |
+
},
|
| 61 |
+
isActive: () => active,
|
| 62 |
+
});
|
| 63 |
+
return {
|
| 64 |
+
delivered,
|
| 65 |
+
handler,
|
| 66 |
+
deactivate: () => {
|
| 67 |
+
active = false;
|
| 68 |
+
},
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function answerGateway(
|
| 73 |
+
answer: (questions: GatewayQuestion[]) => Record<string, string[]>,
|
| 74 |
+
): GatewayRequest[] {
|
| 75 |
+
const requests = new Map<string, GatewayRequest>();
|
| 76 |
+
const ordered: GatewayRequest[] = [];
|
| 77 |
+
gatewayCallMock.mockImplementation(async (method: string, _opts: unknown, rawParams: unknown) => {
|
| 78 |
+
const params = rawParams as GatewayRequest;
|
| 79 |
+
if (method === "question.request") {
|
| 80 |
+
requests.set(params.id, params);
|
| 81 |
+
ordered.push(params);
|
| 82 |
+
return { id: params.id };
|
| 83 |
+
}
|
| 84 |
+
if (method === "question.waitAnswer") {
|
| 85 |
+
const request = requests.get(params.id);
|
| 86 |
+
if (!request) {
|
| 87 |
+
throw new Error("missing question registration");
|
| 88 |
+
}
|
| 89 |
+
return { status: "answered", answers: { answers: answer(request.questions) } };
|
| 90 |
+
}
|
| 91 |
+
if (method === "question.resolve") {
|
| 92 |
+
return { status: "cancelled" };
|
| 93 |
+
}
|
| 94 |
+
throw new Error(`unexpected gateway method ${method}`);
|
| 95 |
+
});
|
| 96 |
+
return ordered;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
describe("ACP elicitation delivery", () => {
|
| 100 |
+
beforeEach(() => {
|
| 101 |
+
gatewayCallMock.mockReset();
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it("rejects accessor-backed request objects without invoking the accessor", async () => {
|
| 105 |
+
let reads = 0;
|
| 106 |
+
const request = Object.defineProperty(
|
| 107 |
+
{
|
| 108 |
+
mode: "form",
|
| 109 |
+
sessionId: "acp-session",
|
| 110 |
+
message: "Input",
|
| 111 |
+
},
|
| 112 |
+
"requestedSchema",
|
| 113 |
+
{
|
| 114 |
+
enumerable: true,
|
| 115 |
+
get() {
|
| 116 |
+
reads += 1;
|
| 117 |
+
return { type: "object", properties: { value: { type: "string" } } };
|
| 118 |
+
},
|
| 119 |
+
},
|
| 120 |
+
) as AcpElicitationRequest;
|
| 121 |
+
const { delivered, handler } = createFixture();
|
| 122 |
+
|
| 123 |
+
const response = await handler(request, {
|
| 124 |
+
requestId: "accessor",
|
| 125 |
+
signal: new AbortController().signal,
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
expect(response.action).toBe("decline");
|
| 129 |
+
expect(reads).toBe(0);
|
| 130 |
+
expect(delivered.join("\n")).toContain("malformed");
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
it("uses outer, scope, session, and tool-call correlation in Gateway records", async () => {
|
| 134 |
+
const requests = answerGateway((questions) => ({ [questions[0]!.questionId]: ["Continue"] }));
|
| 135 |
+
const { handler } = createFixture();
|
| 136 |
+
|
| 137 |
+
await handler(urlRequest(), {
|
| 138 |
+
requestId: 1,
|
| 139 |
+
signal: new AbortController().signal,
|
| 140 |
+
});
|
| 141 |
+
await handler(
|
| 142 |
+
{
|
| 143 |
+
mode: "url",
|
| 144 |
+
requestId: "auth-request",
|
| 145 |
+
message: "Authenticate",
|
| 146 |
+
elicitationId: "login-2",
|
| 147 |
+
url: "https://example.com/login",
|
| 148 |
+
},
|
| 149 |
+
{ requestId: 2, signal: new AbortController().signal },
|
| 150 |
+
);
|
| 151 |
+
|
| 152 |
+
expect(requests).toHaveLength(2);
|
| 153 |
+
expect(requests[0]?.id).toMatch(/^acp_[a-f0-9]{24}_0$/u);
|
| 154 |
+
expect(requests[1]?.id).toMatch(/^acp_[a-f0-9]{24}_0$/u);
|
| 155 |
+
expect(requests[0]?.id).not.toBe(requests[1]?.id);
|
| 156 |
+
expect(requests[0]).toMatchObject({
|
| 157 |
+
sessionKey: "agent:codex:acp:target",
|
| 158 |
+
runId: "run-1",
|
| 159 |
+
});
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
it("cancels an aborted request and rejects a late Gateway answer", async () => {
|
| 163 |
+
let registered = false;
|
| 164 |
+
gatewayCallMock.mockImplementation(
|
| 165 |
+
async (
|
| 166 |
+
method: string,
|
| 167 |
+
_opts: unknown,
|
| 168 |
+
rawParams: unknown,
|
| 169 |
+
extra?: { signal?: AbortSignal },
|
| 170 |
+
) => {
|
| 171 |
+
if (method === "question.request") {
|
| 172 |
+
registered = true;
|
| 173 |
+
return { id: (rawParams as { id: string }).id };
|
| 174 |
+
}
|
| 175 |
+
if (method === "question.waitAnswer") {
|
| 176 |
+
return await new Promise((_, reject) => {
|
| 177 |
+
extra?.signal?.addEventListener("abort", () => reject(new Error("late answer")), {
|
| 178 |
+
once: true,
|
| 179 |
+
});
|
| 180 |
+
});
|
| 181 |
+
}
|
| 182 |
+
return { status: "cancelled" };
|
| 183 |
+
},
|
| 184 |
+
);
|
| 185 |
+
const controller = new AbortController();
|
| 186 |
+
const { handler } = createFixture();
|
| 187 |
+
const response = handler(urlRequest(), { requestId: "abort", signal: controller.signal });
|
| 188 |
+
await vi.waitFor(() => expect(registered).toBe(true));
|
| 189 |
+
|
| 190 |
+
controller.abort();
|
| 191 |
+
|
| 192 |
+
await expect(response).resolves.toMatchObject({ action: "cancel" });
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
it("keeps explicitly marked Codex secrets out of Gateway question records", async () => {
|
| 196 |
+
gatewayCallMock.mockRejectedValue(new Error("secret must not reach Gateway questions"));
|
| 197 |
+
const { delivered, handler } = createFixture();
|
| 198 |
+
const response = handler(
|
| 199 |
+
formRequest(
|
| 200 |
+
{
|
| 201 |
+
token: {
|
| 202 |
+
type: "string",
|
| 203 |
+
title: "Token",
|
| 204 |
+
_meta: { codex: { isSecret: true } },
|
| 205 |
+
},
|
| 206 |
+
},
|
| 207 |
+
["token"],
|
| 208 |
+
),
|
| 209 |
+
{ requestId: "secret", signal: new AbortController().signal },
|
| 210 |
+
);
|
| 211 |
+
await vi.waitFor(() => expect(delivered.join("\n")).toContain("may show your reply"));
|
| 212 |
+
|
| 213 |
+
await expect(
|
| 214 |
+
claimPendingAgentQuestionAnswer({
|
| 215 |
+
sessionKey: "agent:codex:acp:target",
|
| 216 |
+
text: "private-value",
|
| 217 |
+
}),
|
| 218 |
+
).resolves.toBe(true);
|
| 219 |
+
|
| 220 |
+
await expect(response).resolves.toEqual({
|
| 221 |
+
action: "accept",
|
| 222 |
+
content: { token: "private-value" },
|
| 223 |
+
});
|
| 224 |
+
expect(gatewayCallMock).not.toHaveBeenCalled();
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
it("accepts an ACP form elicitation with an empty schema instead of declining it", async () => {
|
| 228 |
+
const { handler } = createFixture();
|
| 229 |
+
|
| 230 |
+
const response = handler(formRequest({}), {
|
| 231 |
+
requestId: "empty-form",
|
| 232 |
+
signal: new AbortController().signal,
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
await expect(response).resolves.toEqual({
|
| 236 |
+
action: "accept",
|
| 237 |
+
content: {},
|
| 238 |
+
});
|
| 239 |
+
});
|
| 240 |
+
});
|
src/auto-reply/reply/acp-elicitation-handler.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash } from "node:crypto";
|
| 2 |
+
import type {
|
| 3 |
+
AcpElicitationHandler,
|
| 4 |
+
AcpElicitationResponse,
|
| 5 |
+
AcpJsonRpcId,
|
| 6 |
+
} from "@openclaw/acp-core/runtime/types";
|
| 7 |
+
import { runStructuredInput } from "../../agents/harness/structured-input-execution.js";
|
| 8 |
+
import type { ReplyPayload } from "../types.js";
|
| 9 |
+
import { parseAcpElicitationRequest } from "./acp-elicitation.js";
|
| 10 |
+
|
| 11 |
+
const DEFAULT_ELICITATION_TIMEOUT_MS = 15 * 60_000;
|
| 12 |
+
const MAX_REQUEST_ID_TEXT = 128;
|
| 13 |
+
|
| 14 |
+
type AcpElicitationDelivery = {
|
| 15 |
+
deliver: (kind: "block", payload: ReplyPayload) => Promise<boolean>;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export type AcpElicitationHandlerParams = {
|
| 19 |
+
sourceSessionKey: string;
|
| 20 |
+
targetSessionKey: string;
|
| 21 |
+
outerRequestId: string;
|
| 22 |
+
agentId: string;
|
| 23 |
+
runId: string;
|
| 24 |
+
delivery: AcpElicitationDelivery;
|
| 25 |
+
isActive: () => boolean;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
function questionId(params: {
|
| 29 |
+
contextRequestId: AcpJsonRpcId;
|
| 30 |
+
correlation: unknown;
|
| 31 |
+
sourceSessionKey: string;
|
| 32 |
+
targetSessionKey: string;
|
| 33 |
+
outerRequestId: string;
|
| 34 |
+
batch: number;
|
| 35 |
+
}): string {
|
| 36 |
+
const digest = createHash("sha256").update(JSON.stringify(params)).digest("hex").slice(0, 24);
|
| 37 |
+
return `acp_${digest}_${params.batch}`;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function cancellation(message: string): AcpElicitationResponse {
|
| 41 |
+
return { action: "cancel", _meta: { message } };
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function decline(message?: string): AcpElicitationResponse {
|
| 45 |
+
return { action: "decline", ...(message ? { _meta: { message } } : {}) };
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function isContextRequestIdValid(value: AcpJsonRpcId): boolean {
|
| 49 |
+
return (
|
| 50 |
+
value === null ||
|
| 51 |
+
(typeof value === "number" && Number.isFinite(value)) ||
|
| 52 |
+
(typeof value === "string" && value.length > 0 && value.length <= MAX_REQUEST_ID_TEXT)
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/** Creates the turn-owned ACP form/URL bridge used by channel delivery. */
|
| 57 |
+
export function createAcpElicitationHandler(
|
| 58 |
+
params: AcpElicitationHandlerParams,
|
| 59 |
+
): AcpElicitationHandler {
|
| 60 |
+
const delivery = {
|
| 61 |
+
onBlockReply: async (payload: ReplyPayload) => {
|
| 62 |
+
await params.delivery.deliver("block", payload);
|
| 63 |
+
},
|
| 64 |
+
};
|
| 65 |
+
return async (request, context) => {
|
| 66 |
+
if (
|
| 67 |
+
!isContextRequestIdValid(context.requestId) ||
|
| 68 |
+
context.signal.aborted ||
|
| 69 |
+
!params.isActive()
|
| 70 |
+
) {
|
| 71 |
+
return cancellation("ACP input request is no longer active.");
|
| 72 |
+
}
|
| 73 |
+
const parsed = parseAcpElicitationRequest(request);
|
| 74 |
+
const result = await runStructuredInput({
|
| 75 |
+
input: parsed.input,
|
| 76 |
+
sessionKey: params.targetSessionKey,
|
| 77 |
+
agentId: params.agentId,
|
| 78 |
+
runId: params.runId,
|
| 79 |
+
timeoutMs: DEFAULT_ELICITATION_TIMEOUT_MS,
|
| 80 |
+
delivery,
|
| 81 |
+
signal: context.signal,
|
| 82 |
+
isActive: params.isActive,
|
| 83 |
+
questionId: (batch) =>
|
| 84 |
+
questionId({
|
| 85 |
+
contextRequestId: context.requestId,
|
| 86 |
+
correlation: parsed.correlation,
|
| 87 |
+
sourceSessionKey: params.sourceSessionKey,
|
| 88 |
+
targetSessionKey: params.targetSessionKey,
|
| 89 |
+
outerRequestId: params.outerRequestId,
|
| 90 |
+
batch,
|
| 91 |
+
}),
|
| 92 |
+
promptOptions: {
|
| 93 |
+
unsupportedIntro: "ACP input request could not be shown:",
|
| 94 |
+
urlIntro: "ACP needs confirmation:",
|
| 95 |
+
},
|
| 96 |
+
});
|
| 97 |
+
if (result.status === "answered") {
|
| 98 |
+
return parsed.input.kind === "ready" && parsed.input.plan.kind === "url"
|
| 99 |
+
? { action: "accept" }
|
| 100 |
+
: { action: "accept", content: result.content };
|
| 101 |
+
}
|
| 102 |
+
if (result.status === "declined") {
|
| 103 |
+
return decline(result.message);
|
| 104 |
+
}
|
| 105 |
+
if (result.status === "unsupported") {
|
| 106 |
+
return decline(result.message);
|
| 107 |
+
}
|
| 108 |
+
return cancellation(result.message ?? "ACP input request was cancelled.");
|
| 109 |
+
};
|
| 110 |
+
}
|
src/auto-reply/reply/acp-elicitation.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { AcpElicitationRequest } from "@openclaw/acp-core/runtime/types";
|
| 2 |
+
import {
|
| 3 |
+
compileStructuredInputForm,
|
| 4 |
+
compileStructuredInputUrl,
|
| 5 |
+
isStructuredInputRecord,
|
| 6 |
+
snapshotStructuredInput,
|
| 7 |
+
type StructuredInputCompileResult,
|
| 8 |
+
type StructuredInputRecord,
|
| 9 |
+
type StructuredInputValue,
|
| 10 |
+
} from "../../agents/harness/structured-input.js";
|
| 11 |
+
|
| 12 |
+
const MAX_CORRELATION_TEXT = 128;
|
| 13 |
+
|
| 14 |
+
type ParsedAcpElicitationRequest = {
|
| 15 |
+
input: StructuredInputCompileResult;
|
| 16 |
+
correlation?:
|
| 17 |
+
| { sessionId: string; toolCallId?: string | null }
|
| 18 |
+
| { requestId: string | number | null };
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
/** Adapts ACP wire scope and codex-acp metadata into the shared structured-input compiler. */
|
| 22 |
+
export function parseAcpElicitationRequest(
|
| 23 |
+
request: AcpElicitationRequest,
|
| 24 |
+
): ParsedAcpElicitationRequest {
|
| 25 |
+
const snapshot = snapshotStructuredInput(request);
|
| 26 |
+
if (!isStructuredInputRecord(snapshot)) {
|
| 27 |
+
return unsupported("OpenClaw declined a malformed or over-limit ACP input request.");
|
| 28 |
+
}
|
| 29 |
+
const correlation = readScope(snapshot);
|
| 30 |
+
if (typeof correlation === "string") {
|
| 31 |
+
return unsupported(correlation);
|
| 32 |
+
}
|
| 33 |
+
const mode = readAcpElicitationString(snapshot, "mode");
|
| 34 |
+
if (mode === "url") {
|
| 35 |
+
return {
|
| 36 |
+
correlation,
|
| 37 |
+
input: compileStructuredInputUrl({
|
| 38 |
+
url: readValue(snapshot, "url"),
|
| 39 |
+
elicitationId: readValue(snapshot, "elicitationId"),
|
| 40 |
+
message: readValue(snapshot, "message"),
|
| 41 |
+
fallbackMessage: "ACP provided a URL",
|
| 42 |
+
protocolName: "ACP",
|
| 43 |
+
}),
|
| 44 |
+
};
|
| 45 |
+
}
|
| 46 |
+
if (mode !== "form") {
|
| 47 |
+
return unsupported(
|
| 48 |
+
`OpenClaw does not support ACP elicitation mode ${JSON.stringify(mode ?? "unknown")}.`,
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
return {
|
| 52 |
+
correlation,
|
| 53 |
+
input: compileStructuredInputForm({
|
| 54 |
+
schema: readValue(snapshot, "requestedSchema"),
|
| 55 |
+
message: readAcpElicitationString(snapshot, "message"),
|
| 56 |
+
fallbackMessage: "ACP needs input",
|
| 57 |
+
options: {
|
| 58 |
+
protocolName: "ACP",
|
| 59 |
+
allowEmptyForm: true,
|
| 60 |
+
minimumChoiceCount: 2,
|
| 61 |
+
booleanLabels: ["True", "False"],
|
| 62 |
+
metadata: {
|
| 63 |
+
secretPath: ["_meta", "codex", "isSecret"],
|
| 64 |
+
otherAnswerPath: ["_meta", "codex", "isOtherAnswer"],
|
| 65 |
+
otherQuestionIdPath: ["_meta", "codex", "questionId"],
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
}),
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function readScope(
|
| 73 |
+
request: StructuredInputRecord,
|
| 74 |
+
): ParsedAcpElicitationRequest["correlation"] | string {
|
| 75 |
+
const sessionId = readValue(request, "sessionId");
|
| 76 |
+
const requestId = readValue(request, "requestId");
|
| 77 |
+
const hasSession = sessionId !== undefined;
|
| 78 |
+
const hasRequest = requestId !== undefined;
|
| 79 |
+
if (hasSession === hasRequest) {
|
| 80 |
+
return "OpenClaw declined an ACP input request with an invalid or ambiguous scope.";
|
| 81 |
+
}
|
| 82 |
+
if (hasSession) {
|
| 83 |
+
const normalizedSessionId = readCorrelationText(sessionId);
|
| 84 |
+
if (!normalizedSessionId) {
|
| 85 |
+
return "OpenClaw declined an ACP input request with an invalid session id.";
|
| 86 |
+
}
|
| 87 |
+
const toolCallId = readValue(request, "toolCallId");
|
| 88 |
+
const normalizedToolCallId =
|
| 89 |
+
toolCallId === undefined || toolCallId === null
|
| 90 |
+
? toolCallId
|
| 91 |
+
: readCorrelationText(toolCallId);
|
| 92 |
+
if (toolCallId !== undefined && toolCallId !== null && !normalizedToolCallId) {
|
| 93 |
+
return "OpenClaw declined an ACP input request with an invalid tool-call id.";
|
| 94 |
+
}
|
| 95 |
+
return {
|
| 96 |
+
sessionId: normalizedSessionId,
|
| 97 |
+
...(toolCallId === undefined ? {} : { toolCallId: normalizedToolCallId ?? null }),
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
if (requestId === null) {
|
| 101 |
+
return { requestId };
|
| 102 |
+
}
|
| 103 |
+
if (typeof requestId === "number") {
|
| 104 |
+
return { requestId };
|
| 105 |
+
}
|
| 106 |
+
const normalizedRequestId = readCorrelationText(requestId);
|
| 107 |
+
return normalizedRequestId
|
| 108 |
+
? { requestId: normalizedRequestId }
|
| 109 |
+
: "OpenClaw declined an ACP input request with an invalid request scope.";
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function readCorrelationText(value: unknown): string | undefined {
|
| 113 |
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_CORRELATION_TEXT) {
|
| 114 |
+
return undefined;
|
| 115 |
+
}
|
| 116 |
+
for (const character of value) {
|
| 117 |
+
const codePoint = character.codePointAt(0) ?? 0;
|
| 118 |
+
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
| 119 |
+
return undefined;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
return value;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function readValue(record: StructuredInputRecord, key: string): StructuredInputValue | undefined {
|
| 126 |
+
return Object.hasOwn(record, key) ? record[key] : undefined;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function readAcpElicitationString(record: StructuredInputRecord, key: string): string | undefined {
|
| 130 |
+
const value = readValue(record, key);
|
| 131 |
+
return typeof value === "string" ? value : undefined;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function unsupported(message: string): ParsedAcpElicitationRequest {
|
| 135 |
+
return { input: { kind: "unsupported", message } };
|
| 136 |
+
}
|
src/auto-reply/reply/acp-projector.test.ts
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tests ACP event projection into session updates and reply payloads.
|
| 2 |
+
import { describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { prefixSystemMessage } from "../../infra/system-message.js";
|
| 4 |
+
import { createAcpReplyProjector } from "./acp-projector.js";
|
| 5 |
+
import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js";
|
| 6 |
+
|
| 7 |
+
type Delivery = { kind: string; text?: string };
|
| 8 |
+
|
| 9 |
+
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
| 10 |
+
let count = 0;
|
| 11 |
+
for (const item of items) {
|
| 12 |
+
if (predicate(item)) {
|
| 13 |
+
count += 1;
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
return count;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function createProjectorHarness(
|
| 20 |
+
cfgOverrides?: Parameters<typeof createCfg>[0],
|
| 21 |
+
opts?: {
|
| 22 |
+
onProgress?: () => void;
|
| 23 |
+
shouldSendToolSummaries?: boolean;
|
| 24 |
+
shouldSendToolSummariesNow?: () => boolean;
|
| 25 |
+
shouldSendFullToolDetails?: boolean;
|
| 26 |
+
getConversationContext?: () => string | undefined;
|
| 27 |
+
},
|
| 28 |
+
) {
|
| 29 |
+
const deliveries: Delivery[] = [];
|
| 30 |
+
const projector = createAcpReplyProjector({
|
| 31 |
+
cfg: createCfg(cfgOverrides),
|
| 32 |
+
shouldSendToolSummaries: opts?.shouldSendToolSummaries ?? true,
|
| 33 |
+
shouldSendToolSummariesNow: opts?.shouldSendToolSummariesNow,
|
| 34 |
+
shouldSendFullToolDetails: opts?.shouldSendFullToolDetails ?? false,
|
| 35 |
+
deliver: async (kind, payload) => {
|
| 36 |
+
deliveries.push({ kind, text: payload.text });
|
| 37 |
+
return true;
|
| 38 |
+
},
|
| 39 |
+
getConversationContext: opts?.getConversationContext,
|
| 40 |
+
onProgress: opts?.onProgress,
|
| 41 |
+
});
|
| 42 |
+
return { deliveries, projector };
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function createStreamHarness(
|
| 46 |
+
deliveryMode: "live" | "final_only",
|
| 47 |
+
streamOverrides: Record<string, unknown> = {},
|
| 48 |
+
opts?: Parameters<typeof createProjectorHarness>[1],
|
| 49 |
+
) {
|
| 50 |
+
return createProjectorHarness(
|
| 51 |
+
{
|
| 52 |
+
acp: { enabled: true, stream: { deliveryMode, ...streamOverrides } },
|
| 53 |
+
} as Parameters<typeof createCfg>[0],
|
| 54 |
+
opts,
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function blockDeliveries(deliveries: Delivery[]) {
|
| 59 |
+
return deliveries.filter((entry) => entry.kind === "block");
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function combinedBlockText(deliveries: Delivery[]) {
|
| 63 |
+
return blockDeliveries(deliveries)
|
| 64 |
+
.map((entry) => entry.text ?? "")
|
| 65 |
+
.join("");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function expectToolCallSummary(delivery: Delivery | undefined) {
|
| 69 |
+
expect(delivery?.kind).toBe("tool");
|
| 70 |
+
expect(delivery?.text).toContain("Tool Call");
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const statusAndToolTagVisibility = { available_commands_update: true, tool_call: true };
|
| 74 |
+
|
| 75 |
+
function createFinalOnlyStatusToolHarness(opts?: Parameters<typeof createProjectorHarness>[1]) {
|
| 76 |
+
return createStreamHarness("final_only", { tagVisibility: statusAndToolTagVisibility }, opts);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function createLiveToolLifecycleHarness(params?: {
|
| 80 |
+
repeatSuppression?: boolean;
|
| 81 |
+
includeStatus?: boolean;
|
| 82 |
+
}) {
|
| 83 |
+
const { includeStatus = false, ...streamOverrides } = params ?? {};
|
| 84 |
+
return createStreamHarness("live", {
|
| 85 |
+
...streamOverrides,
|
| 86 |
+
tagVisibility: {
|
| 87 |
+
...(includeStatus ? { available_commands_update: true } : {}),
|
| 88 |
+
tool_call: true,
|
| 89 |
+
tool_call_update: true,
|
| 90 |
+
},
|
| 91 |
+
});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
type Projector = ReturnType<typeof createProjectorHarness>["projector"];
|
| 95 |
+
type ProjectorEvent = Parameters<Projector["onEvent"]>[0];
|
| 96 |
+
type EventOf<T extends ProjectorEvent["type"]> = Extract<ProjectorEvent, { type: T }>;
|
| 97 |
+
|
| 98 |
+
function emitText(
|
| 99 |
+
projector: Projector,
|
| 100 |
+
text: string,
|
| 101 |
+
overrides: Partial<Omit<EventOf<"text_delta">, "type" | "text">> = {},
|
| 102 |
+
) {
|
| 103 |
+
return projector.onEvent({ type: "text_delta", text, tag: "agent_message_chunk", ...overrides });
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function emitStatus(
|
| 107 |
+
projector: Projector,
|
| 108 |
+
text: string,
|
| 109 |
+
tag: EventOf<"status">["tag"],
|
| 110 |
+
overrides: Partial<Omit<EventOf<"status">, "type" | "text" | "tag">> = {},
|
| 111 |
+
) {
|
| 112 |
+
return projector.onEvent({ type: "status", text, tag, ...overrides });
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function emitTool(projector: Projector, event: Omit<EventOf<"tool_call">, "type">) {
|
| 116 |
+
return projector.onEvent({ type: "tool_call", ...event });
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
async function runHiddenBoundaryCase(params: {
|
| 120 |
+
streamOverrides?: Record<string, unknown>;
|
| 121 |
+
toolCallId: string;
|
| 122 |
+
includeNonTerminalUpdate?: boolean;
|
| 123 |
+
firstText?: string;
|
| 124 |
+
secondText?: string;
|
| 125 |
+
expectedText: string;
|
| 126 |
+
}) {
|
| 127 |
+
const { deliveries, projector } = createStreamHarness("live", params.streamOverrides);
|
| 128 |
+
await emitText(projector, params.firstText ?? "fallback.");
|
| 129 |
+
await emitTool(projector, {
|
| 130 |
+
tag: "tool_call",
|
| 131 |
+
toolCallId: params.toolCallId,
|
| 132 |
+
status: "in_progress",
|
| 133 |
+
title: "Run test",
|
| 134 |
+
text: "Run test (in_progress)",
|
| 135 |
+
});
|
| 136 |
+
if (params.includeNonTerminalUpdate) {
|
| 137 |
+
await emitTool(projector, {
|
| 138 |
+
tag: "tool_call_update",
|
| 139 |
+
toolCallId: params.toolCallId,
|
| 140 |
+
status: "in_progress",
|
| 141 |
+
title: "Run test",
|
| 142 |
+
text: "Run test (in_progress)",
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
await emitText(projector, params.secondText ?? "I don't");
|
| 146 |
+
await projector.flush(true);
|
| 147 |
+
|
| 148 |
+
expect(combinedBlockText(deliveries)).toBe(params.expectedText);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
describe("createAcpReplyProjector", () => {
|
| 152 |
+
it("shows execute details only in full verbose mode", async () => {
|
| 153 |
+
const summary = createLiveToolLifecycleHarness();
|
| 154 |
+
await emitTool(summary.projector, {
|
| 155 |
+
tag: "tool_call",
|
| 156 |
+
toolCallId: "call_execute_summary",
|
| 157 |
+
kind: "execute",
|
| 158 |
+
status: "in_progress",
|
| 159 |
+
title: "exec: cat /private/operator-file",
|
| 160 |
+
text: "exec: cat /private/operator-file (in_progress)",
|
| 161 |
+
});
|
| 162 |
+
expect(summary.deliveries[0]?.text).not.toContain("cat /private/operator-file");
|
| 163 |
+
expect(summary.deliveries[0]?.text).toContain("status=in_progress");
|
| 164 |
+
|
| 165 |
+
const full = createStreamHarness(
|
| 166 |
+
"live",
|
| 167 |
+
{ tagVisibility: { tool_call: true } },
|
| 168 |
+
{ shouldSendFullToolDetails: true },
|
| 169 |
+
);
|
| 170 |
+
await emitTool(full.projector, {
|
| 171 |
+
tag: "tool_call",
|
| 172 |
+
toolCallId: "call_execute_full",
|
| 173 |
+
kind: "execute",
|
| 174 |
+
status: "in_progress",
|
| 175 |
+
title: "exec: cat /private/operator-file",
|
| 176 |
+
text: "exec: cat /private/operator-file (in_progress)",
|
| 177 |
+
});
|
| 178 |
+
expect(full.deliveries[0]?.text).toContain("cat /private/operator-file");
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
it("reports progress for ACP runtime events before delivery filtering", async () => {
|
| 182 |
+
const onProgress = vi.fn();
|
| 183 |
+
const { projector } = createProjectorHarness(undefined, { onProgress });
|
| 184 |
+
|
| 185 |
+
await emitText(projector, "hidden reasoning", { stream: "thought" });
|
| 186 |
+
await emitTool(projector, {
|
| 187 |
+
tag: "tool_call",
|
| 188 |
+
toolCallId: "tool-1",
|
| 189 |
+
status: "in_progress",
|
| 190 |
+
title: "Run command",
|
| 191 |
+
text: "Run command",
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
expect(onProgress).toHaveBeenCalledTimes(2);
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
it("buffers default final-only text into one final reply", async () => {
|
| 198 |
+
const { deliveries, projector } = createProjectorHarness();
|
| 199 |
+
|
| 200 |
+
await emitText(projector, "a".repeat(70));
|
| 201 |
+
await projector.flush(true);
|
| 202 |
+
|
| 203 |
+
expect(deliveries).toEqual([{ kind: "final", text: "a".repeat(70) }]);
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
it.each(["live", "final_only"] as const)(
|
| 207 |
+
"uses finalized and refreshed owner context to redact split private prompts in %s mode",
|
| 208 |
+
async (deliveryMode) => {
|
| 209 |
+
const marker = "[Current message - respond to this]";
|
| 210 |
+
let conversationContext = "";
|
| 211 |
+
const { deliveries, projector } = createStreamHarness(
|
| 212 |
+
deliveryMode,
|
| 213 |
+
{},
|
| 214 |
+
{
|
| 215 |
+
getConversationContext: () => conversationContext,
|
| 216 |
+
},
|
| 217 |
+
);
|
| 218 |
+
conversationContext = `${marker}\nPrivate secret. Keep hidden.`;
|
| 219 |
+
|
| 220 |
+
await emitText(projector, "Visible answer before. ");
|
| 221 |
+
conversationContext = `${marker}\nPrivate updated context. Keep hidden.`;
|
| 222 |
+
await emitText(projector, `${marker}\nPrivate updated context. `);
|
| 223 |
+
await emitText(projector, "Keep hidden. Visible answer after.");
|
| 224 |
+
await projector.flush(true);
|
| 225 |
+
|
| 226 |
+
expect(deliveries.map((delivery) => delivery.text).join("")).toBe(
|
| 227 |
+
"Visible answer before. Visible answer after.",
|
| 228 |
+
);
|
| 229 |
+
},
|
| 230 |
+
);
|
| 231 |
+
|
| 232 |
+
it("rechecks the dynamic tool-summary gate for each ACP event", async () => {
|
| 233 |
+
let allowToolSummaries = false;
|
| 234 |
+
const { deliveries, projector } = createStreamHarness(
|
| 235 |
+
"live",
|
| 236 |
+
{
|
| 237 |
+
tagVisibility: {
|
| 238 |
+
tool_call: true,
|
| 239 |
+
},
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
shouldSendToolSummaries: false,
|
| 243 |
+
shouldSendToolSummariesNow: () => allowToolSummaries,
|
| 244 |
+
},
|
| 245 |
+
);
|
| 246 |
+
|
| 247 |
+
await emitTool(projector, {
|
| 248 |
+
tag: "tool_call",
|
| 249 |
+
toolCallId: "tool-1",
|
| 250 |
+
status: "in_progress",
|
| 251 |
+
title: "Run hidden command",
|
| 252 |
+
text: "Run hidden command",
|
| 253 |
+
});
|
| 254 |
+
expect(deliveries).toEqual([]);
|
| 255 |
+
|
| 256 |
+
allowToolSummaries = true;
|
| 257 |
+
await emitTool(projector, {
|
| 258 |
+
tag: "tool_call",
|
| 259 |
+
toolCallId: "tool-2",
|
| 260 |
+
status: "in_progress",
|
| 261 |
+
title: "Run visible command",
|
| 262 |
+
text: "Run visible command",
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
expectToolCallSummary(deliveries[0]);
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
it("drops buffered final-only tool summaries when the dynamic gate turns off before flush", async () => {
|
| 269 |
+
let allowToolSummaries = true;
|
| 270 |
+
const { deliveries, projector } = createFinalOnlyStatusToolHarness({
|
| 271 |
+
shouldSendToolSummariesNow: () => allowToolSummaries,
|
| 272 |
+
});
|
| 273 |
+
|
| 274 |
+
await emitStatus(projector, "available commands updated (7)", "available_commands_update");
|
| 275 |
+
await emitTool(projector, {
|
| 276 |
+
tag: "tool_call",
|
| 277 |
+
toolCallId: "tool-1",
|
| 278 |
+
status: "in_progress",
|
| 279 |
+
title: "Run hidden command",
|
| 280 |
+
text: "Run hidden command",
|
| 281 |
+
});
|
| 282 |
+
await emitText(projector, "done");
|
| 283 |
+
allowToolSummaries = false;
|
| 284 |
+
|
| 285 |
+
await projector.flush(true);
|
| 286 |
+
|
| 287 |
+
expect(deliveries).toEqual([{ kind: "final", text: "done" }]);
|
| 288 |
+
});
|
| 289 |
+
|
| 290 |
+
it("preserves final-only text boundary when a buffered tool summary is dropped", async () => {
|
| 291 |
+
let allowToolSummaries = true;
|
| 292 |
+
const { deliveries, projector } = createStreamHarness(
|
| 293 |
+
"final_only",
|
| 294 |
+
{
|
| 295 |
+
tagVisibility: {
|
| 296 |
+
tool_call: true,
|
| 297 |
+
},
|
| 298 |
+
},
|
| 299 |
+
{ shouldSendToolSummariesNow: () => allowToolSummaries },
|
| 300 |
+
);
|
| 301 |
+
|
| 302 |
+
await emitText(projector, "fallback.");
|
| 303 |
+
await emitTool(projector, {
|
| 304 |
+
tag: "tool_call",
|
| 305 |
+
toolCallId: "tool-dropped-before-flush",
|
| 306 |
+
status: "in_progress",
|
| 307 |
+
title: "Run test",
|
| 308 |
+
text: "Run test (in_progress)",
|
| 309 |
+
});
|
| 310 |
+
await emitText(projector, "I don't");
|
| 311 |
+
allowToolSummaries = false;
|
| 312 |
+
|
| 313 |
+
await projector.flush(true);
|
| 314 |
+
|
| 315 |
+
expect(deliveries).toEqual([{ kind: "final", text: "fallback.\n\nI don't" }]);
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
it("flushes staggered live text deltas after idle gaps", async () => {
|
| 319 |
+
vi.useFakeTimers();
|
| 320 |
+
try {
|
| 321 |
+
const { deliveries, projector } = createStreamHarness("live");
|
| 322 |
+
|
| 323 |
+
await emitText(projector, "A");
|
| 324 |
+
await vi.advanceTimersByTimeAsync(760);
|
| 325 |
+
await projector.flush(false);
|
| 326 |
+
|
| 327 |
+
await emitText(projector, "B");
|
| 328 |
+
await vi.advanceTimersByTimeAsync(760);
|
| 329 |
+
await projector.flush(false);
|
| 330 |
+
|
| 331 |
+
await emitText(projector, "C");
|
| 332 |
+
await vi.advanceTimersByTimeAsync(760);
|
| 333 |
+
await projector.flush(false);
|
| 334 |
+
|
| 335 |
+
expect(blockDeliveries(deliveries)).toEqual([
|
| 336 |
+
{ kind: "block", text: "A" },
|
| 337 |
+
{ kind: "block", text: "B" },
|
| 338 |
+
{ kind: "block", text: "C" },
|
| 339 |
+
]);
|
| 340 |
+
} finally {
|
| 341 |
+
vi.useRealTimers();
|
| 342 |
+
}
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
it("does not flush short live fragments mid-phrase on idle", async () => {
|
| 346 |
+
vi.useFakeTimers();
|
| 347 |
+
try {
|
| 348 |
+
const { deliveries, projector } = createStreamHarness("live");
|
| 349 |
+
|
| 350 |
+
await emitText(projector, "Yes. Send me the term(s), and I’ll run ");
|
| 351 |
+
|
| 352 |
+
await vi.advanceTimersByTimeAsync(1200);
|
| 353 |
+
expect(deliveries).toStrictEqual([]);
|
| 354 |
+
|
| 355 |
+
await emitText(projector, "`wd-cli` searches right away. ");
|
| 356 |
+
await projector.flush(false);
|
| 357 |
+
|
| 358 |
+
expect(deliveries).toEqual([
|
| 359 |
+
{
|
| 360 |
+
kind: "block",
|
| 361 |
+
text: "Yes. Send me the term(s), and I’ll run `wd-cli` searches right away. ",
|
| 362 |
+
},
|
| 363 |
+
]);
|
| 364 |
+
} finally {
|
| 365 |
+
vi.useRealTimers();
|
| 366 |
+
}
|
| 367 |
+
});
|
| 368 |
+
|
| 369 |
+
it("supports deliveryMode=final_only by buffering all projected output until done", async () => {
|
| 370 |
+
const { deliveries, projector } = createFinalOnlyStatusToolHarness();
|
| 371 |
+
|
| 372 |
+
await emitText(projector, "What");
|
| 373 |
+
await emitStatus(projector, "available commands updated (7)", "available_commands_update");
|
| 374 |
+
await emitTool(projector, {
|
| 375 |
+
tag: "tool_call",
|
| 376 |
+
toolCallId: "call_1",
|
| 377 |
+
status: "in_progress",
|
| 378 |
+
title: "List files",
|
| 379 |
+
text: "List files (in_progress)",
|
| 380 |
+
});
|
| 381 |
+
await emitText(projector, " now?");
|
| 382 |
+
expect(deliveries).toStrictEqual([]);
|
| 383 |
+
|
| 384 |
+
await projector.flush(true);
|
| 385 |
+
expect(deliveries).toHaveLength(3);
|
| 386 |
+
expect(deliveries[0]).toEqual({
|
| 387 |
+
kind: "tool",
|
| 388 |
+
text: prefixSystemMessage("available commands updated (7)"),
|
| 389 |
+
});
|
| 390 |
+
expectToolCallSummary(deliveries[1]);
|
| 391 |
+
expect(deliveries[2]).toEqual({ kind: "final", text: "What now?" });
|
| 392 |
+
});
|
| 393 |
+
|
| 394 |
+
it("flushes buffered status/tool output on error in deliveryMode=final_only", async () => {
|
| 395 |
+
const { deliveries, projector } = createFinalOnlyStatusToolHarness();
|
| 396 |
+
|
| 397 |
+
await emitStatus(projector, "available commands updated (7)", "available_commands_update");
|
| 398 |
+
await emitTool(projector, {
|
| 399 |
+
tag: "tool_call",
|
| 400 |
+
toolCallId: "call_2",
|
| 401 |
+
status: "in_progress",
|
| 402 |
+
title: "Run tests",
|
| 403 |
+
text: "Run tests (in_progress)",
|
| 404 |
+
});
|
| 405 |
+
expect(deliveries).toStrictEqual([]);
|
| 406 |
+
|
| 407 |
+
await projector.flush(true);
|
| 408 |
+
expect(deliveries).toHaveLength(2);
|
| 409 |
+
expect(deliveries[0]).toEqual({
|
| 410 |
+
kind: "tool",
|
| 411 |
+
text: prefixSystemMessage("available commands updated (7)"),
|
| 412 |
+
});
|
| 413 |
+
expectToolCallSummary(deliveries[1]);
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
it("suppresses usage_update by default and allows deduped usage when tag-visible", async () => {
|
| 417 |
+
const { deliveries: hidden, projector: hiddenProjector } = createProjectorHarness();
|
| 418 |
+
await emitStatus(hiddenProjector, "usage updated: 10/100", "usage_update", {
|
| 419 |
+
used: 10,
|
| 420 |
+
size: 100,
|
| 421 |
+
});
|
| 422 |
+
expect(hidden).toStrictEqual([]);
|
| 423 |
+
|
| 424 |
+
const { deliveries: shown, projector: shownProjector } = createStreamHarness("live", {
|
| 425 |
+
tagVisibility: {
|
| 426 |
+
usage_update: true,
|
| 427 |
+
},
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
await emitStatus(shownProjector, "usage updated: 10/100", "usage_update", {
|
| 431 |
+
used: 10,
|
| 432 |
+
size: 100,
|
| 433 |
+
});
|
| 434 |
+
await emitStatus(shownProjector, "usage updated: 10/100", "usage_update", {
|
| 435 |
+
used: 10,
|
| 436 |
+
size: 100,
|
| 437 |
+
});
|
| 438 |
+
await emitStatus(shownProjector, "usage updated: 11/100", "usage_update", {
|
| 439 |
+
used: 11,
|
| 440 |
+
size: 100,
|
| 441 |
+
});
|
| 442 |
+
|
| 443 |
+
expect(shown).toEqual([
|
| 444 |
+
{ kind: "tool", text: prefixSystemMessage("usage updated: 10/100") },
|
| 445 |
+
{ kind: "tool", text: prefixSystemMessage("usage updated: 11/100") },
|
| 446 |
+
]);
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
it("hides available_commands_update by default", async () => {
|
| 450 |
+
const { deliveries, projector } = createProjectorHarness();
|
| 451 |
+
await emitStatus(projector, "available commands updated (7)", "available_commands_update");
|
| 452 |
+
|
| 453 |
+
expect(deliveries).toStrictEqual([]);
|
| 454 |
+
});
|
| 455 |
+
|
| 456 |
+
it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
|
| 457 |
+
const { deliveries, projector } = createLiveToolLifecycleHarness();
|
| 458 |
+
|
| 459 |
+
await emitTool(projector, {
|
| 460 |
+
tag: "tool_call",
|
| 461 |
+
toolCallId: "call_1",
|
| 462 |
+
status: "in_progress",
|
| 463 |
+
title: "List files",
|
| 464 |
+
text: "List files (in_progress)",
|
| 465 |
+
});
|
| 466 |
+
await emitTool(projector, {
|
| 467 |
+
tag: "tool_call_update",
|
| 468 |
+
toolCallId: "call_1",
|
| 469 |
+
status: "in_progress",
|
| 470 |
+
title: "List files",
|
| 471 |
+
text: "List files (in_progress)",
|
| 472 |
+
});
|
| 473 |
+
await emitTool(projector, {
|
| 474 |
+
tag: "tool_call_update",
|
| 475 |
+
toolCallId: "call_1",
|
| 476 |
+
status: "completed",
|
| 477 |
+
title: "List files",
|
| 478 |
+
text: "List files (completed)",
|
| 479 |
+
});
|
| 480 |
+
await emitTool(projector, {
|
| 481 |
+
tag: "tool_call_update",
|
| 482 |
+
toolCallId: "call_1",
|
| 483 |
+
status: "completed",
|
| 484 |
+
title: "List files",
|
| 485 |
+
text: "List files (completed)",
|
| 486 |
+
});
|
| 487 |
+
|
| 488 |
+
expect(deliveries.length).toBe(2);
|
| 489 |
+
expectToolCallSummary(deliveries[0]);
|
| 490 |
+
expectToolCallSummary(deliveries[1]);
|
| 491 |
+
});
|
| 492 |
+
|
| 493 |
+
it("keeps terminal tool updates even when rendered summaries are truncated", async () => {
|
| 494 |
+
const { deliveries, projector } = createLiveToolLifecycleHarness({});
|
| 495 |
+
|
| 496 |
+
const longTitle =
|
| 497 |
+
"Run an intentionally long command title that truncates before lifecycle status is visible";
|
| 498 |
+
await emitTool(projector, {
|
| 499 |
+
tag: "tool_call",
|
| 500 |
+
toolCallId: "call_truncated_status",
|
| 501 |
+
status: "in_progress",
|
| 502 |
+
title: longTitle,
|
| 503 |
+
text: `${longTitle} (in_progress)`,
|
| 504 |
+
});
|
| 505 |
+
await emitTool(projector, {
|
| 506 |
+
tag: "tool_call_update",
|
| 507 |
+
toolCallId: "call_truncated_status",
|
| 508 |
+
status: "completed",
|
| 509 |
+
title: longTitle,
|
| 510 |
+
text: `${longTitle} (completed)`,
|
| 511 |
+
});
|
| 512 |
+
|
| 513 |
+
expect(deliveries.length).toBe(2);
|
| 514 |
+
expectToolCallSummary(deliveries[0]);
|
| 515 |
+
expectToolCallSummary(deliveries[1]);
|
| 516 |
+
});
|
| 517 |
+
|
| 518 |
+
it("renders fallback tool labels without leaking call ids as primary label", async () => {
|
| 519 |
+
const { deliveries, projector } = createLiveToolLifecycleHarness();
|
| 520 |
+
|
| 521 |
+
await emitTool(projector, {
|
| 522 |
+
tag: "tool_call",
|
| 523 |
+
toolCallId: "call_ABC123",
|
| 524 |
+
status: "in_progress",
|
| 525 |
+
text: "call_ABC123 (in_progress)",
|
| 526 |
+
});
|
| 527 |
+
|
| 528 |
+
expectToolCallSummary(deliveries[0]);
|
| 529 |
+
expect(deliveries[0]?.text).not.toContain("call_ABC123 (");
|
| 530 |
+
});
|
| 531 |
+
|
| 532 |
+
it("allows repeated status/tool summaries when repeatSuppression is disabled", async () => {
|
| 533 |
+
const { deliveries, projector } = createLiveToolLifecycleHarness({
|
| 534 |
+
repeatSuppression: false,
|
| 535 |
+
includeStatus: true,
|
| 536 |
+
});
|
| 537 |
+
|
| 538 |
+
await emitStatus(projector, "available commands updated", "available_commands_update");
|
| 539 |
+
await emitStatus(projector, "available commands updated", "available_commands_update");
|
| 540 |
+
await emitTool(projector, {
|
| 541 |
+
text: "tool call",
|
| 542 |
+
tag: "tool_call",
|
| 543 |
+
toolCallId: "x",
|
| 544 |
+
status: "in_progress",
|
| 545 |
+
});
|
| 546 |
+
await emitTool(projector, {
|
| 547 |
+
text: "tool call",
|
| 548 |
+
tag: "tool_call_update",
|
| 549 |
+
toolCallId: "x",
|
| 550 |
+
status: "in_progress",
|
| 551 |
+
});
|
| 552 |
+
await emitText(projector, "hello");
|
| 553 |
+
await projector.flush(true);
|
| 554 |
+
|
| 555 |
+
expect(countMatching(deliveries, (entry) => entry.kind === "tool")).toBe(4);
|
| 556 |
+
expect(deliveries[0]).toEqual({
|
| 557 |
+
kind: "tool",
|
| 558 |
+
text: prefixSystemMessage("available commands updated"),
|
| 559 |
+
});
|
| 560 |
+
expect(deliveries[1]).toEqual({
|
| 561 |
+
kind: "tool",
|
| 562 |
+
text: prefixSystemMessage("available commands updated"),
|
| 563 |
+
});
|
| 564 |
+
expectToolCallSummary(deliveries[2]);
|
| 565 |
+
expectToolCallSummary(deliveries[3]);
|
| 566 |
+
expect(deliveries[4]).toEqual({ kind: "block", text: "hello" });
|
| 567 |
+
});
|
| 568 |
+
|
| 569 |
+
it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
|
| 570 |
+
const { deliveries, projector } = createStreamHarness("live", {
|
| 571 |
+
tagVisibility: {
|
| 572 |
+
available_commands_update: true,
|
| 573 |
+
},
|
| 574 |
+
});
|
| 575 |
+
|
| 576 |
+
await emitStatus(projector, "available commands updated (7)", "available_commands_update");
|
| 577 |
+
await emitStatus(projector, "available commands updated (7)", "available_commands_update");
|
| 578 |
+
await emitStatus(projector, "available commands updated (8)", "available_commands_update");
|
| 579 |
+
|
| 580 |
+
expect(deliveries).toEqual([
|
| 581 |
+
{ kind: "tool", text: prefixSystemMessage("available commands updated (7)") },
|
| 582 |
+
{ kind: "tool", text: prefixSystemMessage("available commands updated (8)") },
|
| 583 |
+
]);
|
| 584 |
+
});
|
| 585 |
+
|
| 586 |
+
it("supports tagVisibility overrides for tool updates", async () => {
|
| 587 |
+
const { deliveries, projector } = createStreamHarness("live", {
|
| 588 |
+
tagVisibility: {
|
| 589 |
+
tool_call: true,
|
| 590 |
+
tool_call_update: false,
|
| 591 |
+
},
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
await emitTool(projector, {
|
| 595 |
+
tag: "tool_call",
|
| 596 |
+
toolCallId: "c1",
|
| 597 |
+
status: "in_progress",
|
| 598 |
+
title: "Run tests",
|
| 599 |
+
text: "Run tests (in_progress)",
|
| 600 |
+
});
|
| 601 |
+
await emitTool(projector, {
|
| 602 |
+
tag: "tool_call_update",
|
| 603 |
+
toolCallId: "c1",
|
| 604 |
+
status: "completed",
|
| 605 |
+
title: "Run tests",
|
| 606 |
+
text: "Run tests (completed)",
|
| 607 |
+
});
|
| 608 |
+
|
| 609 |
+
expect(deliveries.length).toBe(1);
|
| 610 |
+
expectToolCallSummary(deliveries[0]);
|
| 611 |
+
});
|
| 612 |
+
|
| 613 |
+
it("inserts a space boundary before visible text after hidden tool updates by default", async () => {
|
| 614 |
+
await runHiddenBoundaryCase({
|
| 615 |
+
toolCallId: "call_hidden_1",
|
| 616 |
+
expectedText: "fallback. I don't",
|
| 617 |
+
});
|
| 618 |
+
});
|
| 619 |
+
|
| 620 |
+
it("preserves hidden boundary when the dynamic tool-summary gate hides a visible tool event", async () => {
|
| 621 |
+
const { deliveries, projector } = createStreamHarness(
|
| 622 |
+
"live",
|
| 623 |
+
{
|
| 624 |
+
tagVisibility: {
|
| 625 |
+
tool_call: true,
|
| 626 |
+
},
|
| 627 |
+
},
|
| 628 |
+
{
|
| 629 |
+
shouldSendToolSummaries: false,
|
| 630 |
+
shouldSendToolSummariesNow: () => false,
|
| 631 |
+
},
|
| 632 |
+
);
|
| 633 |
+
|
| 634 |
+
await emitText(projector, "fallback.");
|
| 635 |
+
await emitTool(projector, {
|
| 636 |
+
tag: "tool_call",
|
| 637 |
+
toolCallId: "call_dynamic_hidden",
|
| 638 |
+
status: "in_progress",
|
| 639 |
+
title: "Run test",
|
| 640 |
+
text: "Run test (in_progress)",
|
| 641 |
+
});
|
| 642 |
+
await emitText(projector, "I don't");
|
| 643 |
+
await projector.flush(true);
|
| 644 |
+
|
| 645 |
+
expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
|
| 646 |
+
expect(deliveries.some((delivery) => delivery.kind === "tool")).toBe(false);
|
| 647 |
+
});
|
| 648 |
+
|
| 649 |
+
it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
|
| 650 |
+
await runHiddenBoundaryCase({
|
| 651 |
+
streamOverrides: {
|
| 652 |
+
tagVisibility: {
|
| 653 |
+
tool_call: false,
|
| 654 |
+
tool_call_update: false,
|
| 655 |
+
},
|
| 656 |
+
},
|
| 657 |
+
toolCallId: "hidden_boundary_1",
|
| 658 |
+
includeNonTerminalUpdate: true,
|
| 659 |
+
expectedText: "fallback. I don't",
|
| 660 |
+
});
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
it("uses the built-in space separator for hidden live boundaries", async () => {
|
| 664 |
+
await runHiddenBoundaryCase({
|
| 665 |
+
toolCallId: "call_hidden_2",
|
| 666 |
+
expectedText: "fallback. I don't",
|
| 667 |
+
});
|
| 668 |
+
});
|
| 669 |
+
|
| 670 |
+
it("does not duplicate newlines when previous visible text already ends with newline", async () => {
|
| 671 |
+
await runHiddenBoundaryCase({
|
| 672 |
+
toolCallId: "call_hidden_4",
|
| 673 |
+
firstText: "fallback.\n",
|
| 674 |
+
expectedText: "fallback.\nI don't",
|
| 675 |
+
});
|
| 676 |
+
});
|
| 677 |
+
|
| 678 |
+
it("does not insert boundary separator for hidden non-tool status updates", async () => {
|
| 679 |
+
const { deliveries, projector } = createStreamHarness("live");
|
| 680 |
+
|
| 681 |
+
await emitText(projector, "A");
|
| 682 |
+
await emitStatus(projector, "available commands updated", "available_commands_update");
|
| 683 |
+
await emitText(projector, "B");
|
| 684 |
+
await projector.flush(true);
|
| 685 |
+
|
| 686 |
+
expect(combinedBlockText(deliveries)).toBe("AB");
|
| 687 |
+
});
|
| 688 |
+
});
|
src/auto-reply/reply/acp-projector.ts
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Projects ACP runtime events into OpenClaw-visible session update records.
|
| 2 |
+
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "@openclaw/acp-core/runtime/types";
|
| 3 |
+
import {
|
| 4 |
+
normalizeOptionalLowercaseString,
|
| 5 |
+
normalizeOptionalString,
|
| 6 |
+
} from "@openclaw/normalization-core/string-coerce";
|
| 7 |
+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
| 8 |
+
import { resolveAcpToolTerminalOutcome } from "../../acp/tool-status.js";
|
| 9 |
+
import { EmbeddedBlockChunker } from "../../agents/embedded-agent-block-chunker.js";
|
| 10 |
+
import { createVerifiedConversationContextStreamFilter } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
| 11 |
+
import { formatToolSummary, resolveToolDisplay } from "../../agents/tool-display.js";
|
| 12 |
+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
| 13 |
+
import { prefixSystemMessage } from "../../infra/system-message.js";
|
| 14 |
+
import { truncateUtf16WithEllipsis as truncateText } from "../../shared/text-truncate.js";
|
| 15 |
+
import type { ReplyPayload } from "../types.js";
|
| 16 |
+
import {
|
| 17 |
+
type AcpHiddenBoundarySeparator,
|
| 18 |
+
isAcpTagVisible,
|
| 19 |
+
resolveAcpProjectionSettings,
|
| 20 |
+
resolveAcpStreamingConfig,
|
| 21 |
+
} from "./acp-stream-settings.js";
|
| 22 |
+
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
| 23 |
+
import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
|
| 24 |
+
|
| 25 |
+
const ACP_BLOCK_REPLY_TIMEOUT_MS = 15_000;
|
| 26 |
+
const ACP_LIVE_IDLE_FLUSH_FLOOR_MS = 750;
|
| 27 |
+
const ACP_LIVE_IDLE_MIN_CHARS = 80;
|
| 28 |
+
const ACP_LIVE_SOFT_FLUSH_CHARS = 220;
|
| 29 |
+
const ACP_LIVE_HARD_FLUSH_CHARS = 480;
|
| 30 |
+
|
| 31 |
+
const HIDDEN_BOUNDARY_TAGS = new Set<AcpSessionUpdateTag>(["tool_call", "tool_call_update"]);
|
| 32 |
+
|
| 33 |
+
type AcpProjectedDeliveryMeta = {
|
| 34 |
+
tag?: AcpSessionUpdateTag;
|
| 35 |
+
toolCallId?: string;
|
| 36 |
+
toolStatus?: string;
|
| 37 |
+
allowEdit?: boolean;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
type ToolLifecycleState = {
|
| 41 |
+
started: boolean;
|
| 42 |
+
terminal: boolean;
|
| 43 |
+
lastRenderedHash?: string;
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
type BufferedToolDelivery = {
|
| 47 |
+
payload: ReplyPayload;
|
| 48 |
+
meta?: AcpProjectedDeliveryMeta;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
function hashText(text: string): string {
|
| 52 |
+
return text.trim();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function normalizeToolStatus(status: string | undefined): string | undefined {
|
| 56 |
+
const normalized = normalizeOptionalLowercaseString(status);
|
| 57 |
+
return normalized || undefined;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function resolveHiddenBoundarySeparatorText(mode: AcpHiddenBoundarySeparator): string {
|
| 61 |
+
if (mode === "space") {
|
| 62 |
+
return " ";
|
| 63 |
+
}
|
| 64 |
+
if (mode === "newline") {
|
| 65 |
+
return "\n";
|
| 66 |
+
}
|
| 67 |
+
if (mode === "paragraph") {
|
| 68 |
+
return "\n\n";
|
| 69 |
+
}
|
| 70 |
+
return "";
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function shouldInsertSeparator(params: {
|
| 74 |
+
separator: string;
|
| 75 |
+
previousTail: string | undefined;
|
| 76 |
+
nextText: string;
|
| 77 |
+
}): boolean {
|
| 78 |
+
if (!params.separator) {
|
| 79 |
+
return false;
|
| 80 |
+
}
|
| 81 |
+
if (!params.nextText) {
|
| 82 |
+
return false;
|
| 83 |
+
}
|
| 84 |
+
const firstChar = params.nextText[0];
|
| 85 |
+
if (typeof firstChar === "string" && /\s/.test(firstChar)) {
|
| 86 |
+
return false;
|
| 87 |
+
}
|
| 88 |
+
const tail = params.previousTail ?? "";
|
| 89 |
+
if (!tail) {
|
| 90 |
+
return false;
|
| 91 |
+
}
|
| 92 |
+
if (params.separator === " " && /\s$/.test(tail)) {
|
| 93 |
+
return false;
|
| 94 |
+
}
|
| 95 |
+
if ((params.separator === "\n" || params.separator === "\n\n") && tail.endsWith("\n")) {
|
| 96 |
+
return false;
|
| 97 |
+
}
|
| 98 |
+
return true;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function shouldFlushLiveBufferOnBoundary(text: string): boolean {
|
| 102 |
+
if (!text) {
|
| 103 |
+
return false;
|
| 104 |
+
}
|
| 105 |
+
if (text.length >= ACP_LIVE_HARD_FLUSH_CHARS) {
|
| 106 |
+
return true;
|
| 107 |
+
}
|
| 108 |
+
if (text.endsWith("\n\n")) {
|
| 109 |
+
return true;
|
| 110 |
+
}
|
| 111 |
+
if (/[.!?][)"'`]*\s$/.test(text)) {
|
| 112 |
+
return true;
|
| 113 |
+
}
|
| 114 |
+
if (text.length >= ACP_LIVE_SOFT_FLUSH_CHARS && /\s$/.test(text)) {
|
| 115 |
+
return true;
|
| 116 |
+
}
|
| 117 |
+
return false;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function shouldFlushLiveBufferOnIdle(text: string): boolean {
|
| 121 |
+
if (!text) {
|
| 122 |
+
return false;
|
| 123 |
+
}
|
| 124 |
+
if (text.length >= ACP_LIVE_IDLE_MIN_CHARS) {
|
| 125 |
+
return true;
|
| 126 |
+
}
|
| 127 |
+
if (/[.!?][)"'`]*$/.test(text.trimEnd())) {
|
| 128 |
+
return true;
|
| 129 |
+
}
|
| 130 |
+
if (text.includes("\n")) {
|
| 131 |
+
return true;
|
| 132 |
+
}
|
| 133 |
+
return false;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function renderToolSummaryText(
|
| 137 |
+
event: Extract<AcpRuntimeEvent, { type: "tool_call" }>,
|
| 138 |
+
shouldSendFullToolDetails: boolean,
|
| 139 |
+
): string {
|
| 140 |
+
const detailParts: string[] = [];
|
| 141 |
+
const commandBearing = normalizeOptionalLowercaseString(event.kind) === "execute";
|
| 142 |
+
const title =
|
| 143 |
+
shouldSendFullToolDetails || !commandBearing ? normalizeOptionalString(event.title) : undefined;
|
| 144 |
+
if (title) {
|
| 145 |
+
detailParts.push(title);
|
| 146 |
+
}
|
| 147 |
+
const status = normalizeOptionalString(event.status);
|
| 148 |
+
if (status) {
|
| 149 |
+
detailParts.push(`status=${status}`);
|
| 150 |
+
}
|
| 151 |
+
const fallback =
|
| 152 |
+
shouldSendFullToolDetails || !commandBearing ? normalizeOptionalString(event.text) : undefined;
|
| 153 |
+
if (detailParts.length === 0 && fallback) {
|
| 154 |
+
detailParts.push(fallback);
|
| 155 |
+
}
|
| 156 |
+
const display = resolveToolDisplay({
|
| 157 |
+
name: "tool_call",
|
| 158 |
+
meta: detailParts.join(" · ") || "tool call",
|
| 159 |
+
});
|
| 160 |
+
return formatToolSummary(display);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
type AcpReplyProjector = {
|
| 164 |
+
onEvent: (event: AcpRuntimeEvent) => Promise<void>;
|
| 165 |
+
flush: (force?: boolean) => Promise<void>;
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
export function createAcpReplyProjector(params: {
|
| 169 |
+
cfg: OpenClawConfig;
|
| 170 |
+
shouldSendToolSummaries: boolean;
|
| 171 |
+
shouldSendToolSummariesNow?: () => boolean;
|
| 172 |
+
shouldSendFullToolDetails: boolean;
|
| 173 |
+
deliver: (
|
| 174 |
+
kind: ReplyDispatchKind,
|
| 175 |
+
payload: ReplyPayload,
|
| 176 |
+
meta?: AcpProjectedDeliveryMeta,
|
| 177 |
+
) => Promise<boolean>;
|
| 178 |
+
getConversationContext?: () => string | undefined;
|
| 179 |
+
onProgress?: () => void;
|
| 180 |
+
provider?: string;
|
| 181 |
+
accountId?: string;
|
| 182 |
+
}): AcpReplyProjector {
|
| 183 |
+
const settings = resolveAcpProjectionSettings(params.cfg);
|
| 184 |
+
const streaming = resolveAcpStreamingConfig({
|
| 185 |
+
cfg: params.cfg,
|
| 186 |
+
provider: params.provider,
|
| 187 |
+
accountId: params.accountId,
|
| 188 |
+
deliveryMode: settings.deliveryMode,
|
| 189 |
+
});
|
| 190 |
+
const blockReplyPipeline = createBlockReplyPipeline({
|
| 191 |
+
onBlockReply: async (payload) => {
|
| 192 |
+
await params.deliver("block", payload);
|
| 193 |
+
},
|
| 194 |
+
timeoutMs: ACP_BLOCK_REPLY_TIMEOUT_MS,
|
| 195 |
+
coalescing: settings.deliveryMode === "live" ? undefined : streaming.coalescing,
|
| 196 |
+
});
|
| 197 |
+
const chunker = new EmbeddedBlockChunker(streaming.chunking);
|
| 198 |
+
const filterConversationContext = createVerifiedConversationContextStreamFilter(
|
| 199 |
+
params.getConversationContext,
|
| 200 |
+
);
|
| 201 |
+
const liveIdleFlushMs = Math.max(streaming.coalescing.idleMs, ACP_LIVE_IDLE_FLUSH_FLOOR_MS);
|
| 202 |
+
|
| 203 |
+
let emittedOutputChars = 0;
|
| 204 |
+
let truncationNoticeEmitted = false;
|
| 205 |
+
let lastStatusHash: string | undefined;
|
| 206 |
+
let lastToolHash: string | undefined;
|
| 207 |
+
let lastUsageTuple: string | undefined;
|
| 208 |
+
let lastVisibleOutputTail: string | undefined;
|
| 209 |
+
let pendingHiddenBoundary = false;
|
| 210 |
+
let liveBufferText = "";
|
| 211 |
+
let finalOnlyOutputText = "";
|
| 212 |
+
let liveIdleTimer: NodeJS.Timeout | undefined;
|
| 213 |
+
const pendingToolDeliveries: BufferedToolDelivery[] = [];
|
| 214 |
+
const toolLifecycleById = new Map<string, ToolLifecycleState>();
|
| 215 |
+
|
| 216 |
+
const shouldSendToolSummaries = () =>
|
| 217 |
+
params.shouldSendToolSummariesNow?.() ?? params.shouldSendToolSummaries;
|
| 218 |
+
|
| 219 |
+
const clearLiveIdleTimer = () => {
|
| 220 |
+
if (!liveIdleTimer) {
|
| 221 |
+
return;
|
| 222 |
+
}
|
| 223 |
+
clearTimeout(liveIdleTimer);
|
| 224 |
+
liveIdleTimer = undefined;
|
| 225 |
+
};
|
| 226 |
+
|
| 227 |
+
const drainChunker = (force: boolean) => {
|
| 228 |
+
if (settings.deliveryMode === "final_only" && !force) {
|
| 229 |
+
return;
|
| 230 |
+
}
|
| 231 |
+
chunker.drain({
|
| 232 |
+
force,
|
| 233 |
+
emit: (chunk) => {
|
| 234 |
+
blockReplyPipeline.enqueue({ text: chunk });
|
| 235 |
+
},
|
| 236 |
+
});
|
| 237 |
+
};
|
| 238 |
+
|
| 239 |
+
const flushLiveBuffer = (opts?: { force?: boolean; idle?: boolean }) => {
|
| 240 |
+
if (settings.deliveryMode !== "live") {
|
| 241 |
+
return;
|
| 242 |
+
}
|
| 243 |
+
if (!liveBufferText) {
|
| 244 |
+
return;
|
| 245 |
+
}
|
| 246 |
+
if (opts?.idle && !shouldFlushLiveBufferOnIdle(liveBufferText)) {
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
const text = liveBufferText;
|
| 250 |
+
liveBufferText = "";
|
| 251 |
+
chunker.append(text);
|
| 252 |
+
drainChunker(opts?.force === true);
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
const scheduleLiveIdleFlush = () => {
|
| 256 |
+
if (settings.deliveryMode !== "live") {
|
| 257 |
+
return;
|
| 258 |
+
}
|
| 259 |
+
if (liveIdleFlushMs <= 0 || !liveBufferText) {
|
| 260 |
+
return;
|
| 261 |
+
}
|
| 262 |
+
clearLiveIdleTimer();
|
| 263 |
+
liveIdleTimer = setTimeout(() => {
|
| 264 |
+
flushLiveBuffer({ force: true, idle: true });
|
| 265 |
+
if (liveBufferText) {
|
| 266 |
+
scheduleLiveIdleFlush();
|
| 267 |
+
}
|
| 268 |
+
}, liveIdleFlushMs);
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
const flushBufferedToolDeliveries = async (force: boolean) => {
|
| 272 |
+
if (!(settings.deliveryMode === "final_only" && force)) {
|
| 273 |
+
return;
|
| 274 |
+
}
|
| 275 |
+
if (!shouldSendToolSummaries()) {
|
| 276 |
+
pendingToolDeliveries.length = 0;
|
| 277 |
+
return;
|
| 278 |
+
}
|
| 279 |
+
for (const entry of pendingToolDeliveries.splice(0)) {
|
| 280 |
+
await params.deliver("tool", entry.payload, entry.meta);
|
| 281 |
+
}
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
const flush = async (force = false): Promise<void> => {
|
| 285 |
+
if (settings.deliveryMode === "live") {
|
| 286 |
+
clearLiveIdleTimer();
|
| 287 |
+
flushLiveBuffer({ force: true });
|
| 288 |
+
}
|
| 289 |
+
await flushBufferedToolDeliveries(force);
|
| 290 |
+
if (settings.deliveryMode === "final_only") {
|
| 291 |
+
if (force && finalOnlyOutputText.trim().length > 0) {
|
| 292 |
+
const text = finalOnlyOutputText;
|
| 293 |
+
finalOnlyOutputText = "";
|
| 294 |
+
await params.deliver("final", { text });
|
| 295 |
+
}
|
| 296 |
+
} else {
|
| 297 |
+
drainChunker(force);
|
| 298 |
+
}
|
| 299 |
+
await blockReplyPipeline.flush({ force });
|
| 300 |
+
};
|
| 301 |
+
|
| 302 |
+
const emitSystemStatus = async (
|
| 303 |
+
text: string,
|
| 304 |
+
meta?: AcpProjectedDeliveryMeta,
|
| 305 |
+
opts?: { dedupe?: boolean },
|
| 306 |
+
) => {
|
| 307 |
+
if (!shouldSendToolSummaries()) {
|
| 308 |
+
return;
|
| 309 |
+
}
|
| 310 |
+
const bounded = truncateText(text.trim(), settings.maxSessionUpdateChars);
|
| 311 |
+
if (!bounded) {
|
| 312 |
+
return;
|
| 313 |
+
}
|
| 314 |
+
const formatted = prefixSystemMessage(bounded);
|
| 315 |
+
const hash = hashText(formatted);
|
| 316 |
+
const shouldDedupe = settings.repeatSuppression && opts?.dedupe !== false;
|
| 317 |
+
if (shouldDedupe && lastStatusHash === hash) {
|
| 318 |
+
return;
|
| 319 |
+
}
|
| 320 |
+
if (settings.deliveryMode === "final_only") {
|
| 321 |
+
pendingToolDeliveries.push({
|
| 322 |
+
payload: { text: formatted },
|
| 323 |
+
meta,
|
| 324 |
+
});
|
| 325 |
+
} else {
|
| 326 |
+
await flush(true);
|
| 327 |
+
await params.deliver("tool", { text: formatted }, meta);
|
| 328 |
+
}
|
| 329 |
+
lastStatusHash = hash;
|
| 330 |
+
};
|
| 331 |
+
|
| 332 |
+
const markHiddenToolBoundary = (event: Extract<AcpRuntimeEvent, { type: "tool_call" }>) => {
|
| 333 |
+
if (!event.tag || !HIDDEN_BOUNDARY_TAGS.has(event.tag)) {
|
| 334 |
+
return;
|
| 335 |
+
}
|
| 336 |
+
const status = normalizeToolStatus(event.status);
|
| 337 |
+
const isTerminal = resolveAcpToolTerminalOutcome(status) !== undefined;
|
| 338 |
+
pendingHiddenBoundary = pendingHiddenBoundary || event.tag === "tool_call" || isTerminal;
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
const emitToolSummary = async (event: Extract<AcpRuntimeEvent, { type: "tool_call" }>) => {
|
| 342 |
+
if (!shouldSendToolSummaries()) {
|
| 343 |
+
markHiddenToolBoundary(event);
|
| 344 |
+
return;
|
| 345 |
+
}
|
| 346 |
+
if (!isAcpTagVisible(settings, event.tag)) {
|
| 347 |
+
return;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
const renderedToolSummary = renderToolSummaryText(event, params.shouldSendFullToolDetails);
|
| 351 |
+
const toolSummary = truncateText(renderedToolSummary, settings.maxSessionUpdateChars);
|
| 352 |
+
const hash = hashText(renderedToolSummary);
|
| 353 |
+
const toolCallId = normalizeOptionalString(event.toolCallId);
|
| 354 |
+
const status = normalizeToolStatus(event.status);
|
| 355 |
+
const isTerminal = resolveAcpToolTerminalOutcome(status) !== undefined;
|
| 356 |
+
const isStart = status === "in_progress" || event.tag === "tool_call";
|
| 357 |
+
|
| 358 |
+
if (settings.repeatSuppression) {
|
| 359 |
+
if (toolCallId) {
|
| 360 |
+
const state = toolLifecycleById.get(toolCallId) ?? {
|
| 361 |
+
started: false,
|
| 362 |
+
terminal: false,
|
| 363 |
+
};
|
| 364 |
+
if (isTerminal && state.terminal) {
|
| 365 |
+
return;
|
| 366 |
+
}
|
| 367 |
+
if (isStart && state.started) {
|
| 368 |
+
return;
|
| 369 |
+
}
|
| 370 |
+
if (state.lastRenderedHash === hash) {
|
| 371 |
+
return;
|
| 372 |
+
}
|
| 373 |
+
if (isStart) {
|
| 374 |
+
state.started = true;
|
| 375 |
+
}
|
| 376 |
+
if (isTerminal) {
|
| 377 |
+
state.terminal = true;
|
| 378 |
+
}
|
| 379 |
+
state.lastRenderedHash = hash;
|
| 380 |
+
toolLifecycleById.set(toolCallId, state);
|
| 381 |
+
} else if (lastToolHash === hash) {
|
| 382 |
+
return;
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
const deliveryMeta: AcpProjectedDeliveryMeta = {
|
| 387 |
+
...(event.tag ? { tag: event.tag } : {}),
|
| 388 |
+
...(toolCallId ? { toolCallId } : {}),
|
| 389 |
+
...(status ? { toolStatus: status } : {}),
|
| 390 |
+
allowEdit: Boolean(toolCallId && event.tag === "tool_call_update"),
|
| 391 |
+
};
|
| 392 |
+
if (settings.deliveryMode === "final_only") {
|
| 393 |
+
pendingToolDeliveries.push({
|
| 394 |
+
payload: { text: toolSummary },
|
| 395 |
+
meta: deliveryMeta,
|
| 396 |
+
});
|
| 397 |
+
markHiddenToolBoundary(event);
|
| 398 |
+
} else {
|
| 399 |
+
await flush(true);
|
| 400 |
+
await params.deliver("tool", { text: toolSummary }, deliveryMeta);
|
| 401 |
+
}
|
| 402 |
+
lastToolHash = hash;
|
| 403 |
+
};
|
| 404 |
+
|
| 405 |
+
const emitTruncationNotice = async () => {
|
| 406 |
+
if (truncationNoticeEmitted) {
|
| 407 |
+
return;
|
| 408 |
+
}
|
| 409 |
+
truncationNoticeEmitted = true;
|
| 410 |
+
await emitSystemStatus(
|
| 411 |
+
"output truncated",
|
| 412 |
+
{
|
| 413 |
+
tag: "session_info_update",
|
| 414 |
+
},
|
| 415 |
+
{
|
| 416 |
+
dedupe: false,
|
| 417 |
+
},
|
| 418 |
+
);
|
| 419 |
+
};
|
| 420 |
+
|
| 421 |
+
// One projector serves one dispatch; terminal settlement belongs to tryDispatchAcpReply.
|
| 422 |
+
const onEvent = async (event: AcpRuntimeEvent): Promise<void> => {
|
| 423 |
+
params.onProgress?.();
|
| 424 |
+
if (event.type === "text_delta") {
|
| 425 |
+
if (event.stream && event.stream !== "output") {
|
| 426 |
+
return;
|
| 427 |
+
}
|
| 428 |
+
if (!isAcpTagVisible(settings, event.tag)) {
|
| 429 |
+
return;
|
| 430 |
+
}
|
| 431 |
+
let text = event.text;
|
| 432 |
+
if (!text) {
|
| 433 |
+
return;
|
| 434 |
+
}
|
| 435 |
+
if (
|
| 436 |
+
pendingHiddenBoundary &&
|
| 437 |
+
shouldInsertSeparator({
|
| 438 |
+
separator: resolveHiddenBoundarySeparatorText(settings.hiddenBoundarySeparator),
|
| 439 |
+
previousTail: lastVisibleOutputTail,
|
| 440 |
+
nextText: text,
|
| 441 |
+
})
|
| 442 |
+
) {
|
| 443 |
+
text = `${resolveHiddenBoundarySeparatorText(settings.hiddenBoundarySeparator)}${text}`;
|
| 444 |
+
}
|
| 445 |
+
pendingHiddenBoundary = false;
|
| 446 |
+
if (emittedOutputChars >= settings.maxOutputChars) {
|
| 447 |
+
await emitTruncationNotice();
|
| 448 |
+
return;
|
| 449 |
+
}
|
| 450 |
+
const remaining = settings.maxOutputChars - emittedOutputChars;
|
| 451 |
+
const accepted = remaining < text.length ? truncateUtf16Safe(text, remaining) : text;
|
| 452 |
+
if (accepted.length > 0) {
|
| 453 |
+
emittedOutputChars += accepted.length;
|
| 454 |
+
const safeText = filterConversationContext(accepted);
|
| 455 |
+
lastVisibleOutputTail = safeText.slice(-1) || lastVisibleOutputTail;
|
| 456 |
+
if (settings.deliveryMode === "live") {
|
| 457 |
+
liveBufferText += safeText;
|
| 458 |
+
if (shouldFlushLiveBufferOnBoundary(liveBufferText)) {
|
| 459 |
+
clearLiveIdleTimer();
|
| 460 |
+
flushLiveBuffer({ force: true });
|
| 461 |
+
} else {
|
| 462 |
+
scheduleLiveIdleFlush();
|
| 463 |
+
}
|
| 464 |
+
} else {
|
| 465 |
+
finalOnlyOutputText += safeText;
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
if (accepted.length < text.length) {
|
| 469 |
+
// A split code point can leave the accepted prefix shorter than the remaining budget.
|
| 470 |
+
// Exhaust it after any drop so later deltas cannot skip past omitted text.
|
| 471 |
+
emittedOutputChars = settings.maxOutputChars;
|
| 472 |
+
await emitTruncationNotice();
|
| 473 |
+
}
|
| 474 |
+
return;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
if (event.type === "status") {
|
| 478 |
+
if (!isAcpTagVisible(settings, event.tag)) {
|
| 479 |
+
return;
|
| 480 |
+
}
|
| 481 |
+
if (event.tag === "usage_update" && settings.repeatSuppression) {
|
| 482 |
+
const usageTuple =
|
| 483 |
+
typeof event.used === "number" && typeof event.size === "number"
|
| 484 |
+
? `${event.used}/${event.size}`
|
| 485 |
+
: hashText(event.text);
|
| 486 |
+
if (usageTuple === lastUsageTuple) {
|
| 487 |
+
return;
|
| 488 |
+
}
|
| 489 |
+
lastUsageTuple = usageTuple;
|
| 490 |
+
}
|
| 491 |
+
await emitSystemStatus(event.text, event.tag ? { tag: event.tag } : undefined, {
|
| 492 |
+
dedupe: true,
|
| 493 |
+
});
|
| 494 |
+
return;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
if (event.type === "tool_call") {
|
| 498 |
+
if (!isAcpTagVisible(settings, event.tag)) {
|
| 499 |
+
markHiddenToolBoundary(event);
|
| 500 |
+
return;
|
| 501 |
+
}
|
| 502 |
+
await emitToolSummary(event);
|
| 503 |
+
}
|
| 504 |
+
};
|
| 505 |
+
|
| 506 |
+
return {
|
| 507 |
+
onEvent,
|
| 508 |
+
flush,
|
| 509 |
+
};
|
| 510 |
+
}
|
src/auto-reply/reply/acp-reset-target.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Resolves ACP reset targets from sessions and command directives.
|
| 2 |
+
import {
|
| 3 |
+
normalizeLowercaseStringOrEmpty,
|
| 4 |
+
normalizeOptionalString,
|
| 5 |
+
} from "@openclaw/normalization-core/string-coerce";
|
| 6 |
+
import {
|
| 7 |
+
buildConfiguredAcpSessionKey,
|
| 8 |
+
normalizeBindingConfig,
|
| 9 |
+
type ConfiguredAcpBindingChannel,
|
| 10 |
+
} from "../../acp/persistent-bindings.types.js";
|
| 11 |
+
import { resolveConfiguredBindingRecord } from "../../channels/plugins/configured-binding-registry.js";
|
| 12 |
+
import { listAcpBindings } from "../../config/bindings.js";
|
| 13 |
+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
| 14 |
+
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
| 15 |
+
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
| 16 |
+
|
| 17 |
+
function resolveResetTargetAccountId(params: {
|
| 18 |
+
cfg: OpenClawConfig;
|
| 19 |
+
channel: string;
|
| 20 |
+
accountId?: string | null;
|
| 21 |
+
}): string {
|
| 22 |
+
const explicit = normalizeOptionalString(params.accountId) ?? "";
|
| 23 |
+
if (explicit) {
|
| 24 |
+
return explicit;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const channelCfg = (
|
| 28 |
+
params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined>
|
| 29 |
+
)[params.channel];
|
| 30 |
+
const configuredDefault = channelCfg?.defaultAccount;
|
| 31 |
+
return normalizeOptionalString(configuredDefault) ?? DEFAULT_ACCOUNT_ID;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function resolveRawConfiguredAcpSessionKey(params: {
|
| 35 |
+
cfg: OpenClawConfig;
|
| 36 |
+
channel: string;
|
| 37 |
+
accountId: string;
|
| 38 |
+
conversationId: string;
|
| 39 |
+
parentConversationId?: string;
|
| 40 |
+
}): string | undefined {
|
| 41 |
+
for (const binding of listAcpBindings(params.cfg)) {
|
| 42 |
+
const bindingChannel = normalizeLowercaseStringOrEmpty(
|
| 43 |
+
normalizeOptionalString(binding.match.channel),
|
| 44 |
+
);
|
| 45 |
+
if (!bindingChannel || bindingChannel !== params.channel) {
|
| 46 |
+
continue;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const bindingAccountId = normalizeOptionalString(binding.match.accountId) ?? "";
|
| 50 |
+
if (bindingAccountId && bindingAccountId !== "*" && bindingAccountId !== params.accountId) {
|
| 51 |
+
continue;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const peerId = normalizeOptionalString(binding.match.peer?.id) ?? "";
|
| 55 |
+
const matchedConversationId =
|
| 56 |
+
peerId === params.conversationId
|
| 57 |
+
? params.conversationId
|
| 58 |
+
: peerId && peerId === params.parentConversationId
|
| 59 |
+
? params.parentConversationId
|
| 60 |
+
: undefined;
|
| 61 |
+
if (!matchedConversationId) {
|
| 62 |
+
continue;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const acp = normalizeBindingConfig(binding.acp);
|
| 66 |
+
return buildConfiguredAcpSessionKey({
|
| 67 |
+
channel: params.channel as ConfiguredAcpBindingChannel,
|
| 68 |
+
accountId: bindingAccountId && bindingAccountId !== "*" ? bindingAccountId : params.accountId,
|
| 69 |
+
conversationId: matchedConversationId,
|
| 70 |
+
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
|
| 71 |
+
agentId: binding.agentId,
|
| 72 |
+
mode: acp.mode === "oneshot" ? "oneshot" : "persistent",
|
| 73 |
+
...(acp.cwd ? { cwd: acp.cwd } : {}),
|
| 74 |
+
...(acp.backend ? { backend: acp.backend } : {}),
|
| 75 |
+
...(acp.label ? { label: acp.label } : {}),
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return undefined;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export function resolveEffectiveResetTargetSessionKey(params: {
|
| 83 |
+
cfg: OpenClawConfig;
|
| 84 |
+
channel?: string | null;
|
| 85 |
+
accountId?: string | null;
|
| 86 |
+
conversationId?: string | null;
|
| 87 |
+
parentConversationId?: string | null;
|
| 88 |
+
activeSessionKey?: string | null;
|
| 89 |
+
allowNonAcpBindingSessionKey?: boolean;
|
| 90 |
+
skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean;
|
| 91 |
+
fallbackToActiveAcpWhenUnbound?: boolean;
|
| 92 |
+
}): string | undefined {
|
| 93 |
+
const activeSessionKey = normalizeOptionalString(params.activeSessionKey);
|
| 94 |
+
const activeAcpSessionKey =
|
| 95 |
+
activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined;
|
| 96 |
+
const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey;
|
| 97 |
+
|
| 98 |
+
const channel = normalizeLowercaseStringOrEmpty(normalizeOptionalString(params.channel));
|
| 99 |
+
const conversationId = normalizeOptionalString(params.conversationId) ?? "";
|
| 100 |
+
if (!channel || !conversationId) {
|
| 101 |
+
return activeAcpSessionKey;
|
| 102 |
+
}
|
| 103 |
+
const accountId = resolveResetTargetAccountId({
|
| 104 |
+
cfg: params.cfg,
|
| 105 |
+
channel,
|
| 106 |
+
accountId: params.accountId,
|
| 107 |
+
});
|
| 108 |
+
const parentConversationId = normalizeOptionalString(params.parentConversationId) || undefined;
|
| 109 |
+
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
|
| 110 |
+
|
| 111 |
+
const serviceBinding = getSessionBindingService().resolveByConversation({
|
| 112 |
+
channel,
|
| 113 |
+
accountId,
|
| 114 |
+
conversationId,
|
| 115 |
+
parentConversationId,
|
| 116 |
+
});
|
| 117 |
+
const serviceSessionKey =
|
| 118 |
+
serviceBinding?.targetKind === "session" ? serviceBinding.targetSessionKey.trim() : "";
|
| 119 |
+
if (serviceSessionKey) {
|
| 120 |
+
if (allowNonAcpBindingSessionKey) {
|
| 121 |
+
return serviceSessionKey;
|
| 122 |
+
}
|
| 123 |
+
return isAcpSessionKey(serviceSessionKey) ? serviceSessionKey : undefined;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
if (activeIsNonAcp && params.skipConfiguredFallbackWhenActiveSessionNonAcp) {
|
| 127 |
+
return undefined;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const configuredBinding = resolveConfiguredBindingRecord({
|
| 131 |
+
cfg: params.cfg,
|
| 132 |
+
channel,
|
| 133 |
+
accountId,
|
| 134 |
+
conversationId,
|
| 135 |
+
parentConversationId,
|
| 136 |
+
});
|
| 137 |
+
const configuredSessionKey =
|
| 138 |
+
configuredBinding?.record.targetKind === "session"
|
| 139 |
+
? configuredBinding.record.targetSessionKey.trim()
|
| 140 |
+
: "";
|
| 141 |
+
if (configuredSessionKey) {
|
| 142 |
+
if (allowNonAcpBindingSessionKey) {
|
| 143 |
+
return configuredSessionKey;
|
| 144 |
+
}
|
| 145 |
+
return isAcpSessionKey(configuredSessionKey) ? configuredSessionKey : undefined;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const rawConfiguredSessionKey = resolveRawConfiguredAcpSessionKey({
|
| 149 |
+
cfg: params.cfg,
|
| 150 |
+
channel,
|
| 151 |
+
accountId,
|
| 152 |
+
conversationId,
|
| 153 |
+
...(parentConversationId ? { parentConversationId } : {}),
|
| 154 |
+
});
|
| 155 |
+
if (rawConfiguredSessionKey) {
|
| 156 |
+
return rawConfiguredSessionKey;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
if (params.fallbackToActiveAcpWhenUnbound === false) {
|
| 160 |
+
return undefined;
|
| 161 |
+
}
|
| 162 |
+
return activeAcpSessionKey;
|
| 163 |
+
}
|
src/auto-reply/reply/acp-stream-settings.test.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Tests ACP stream settings normalization from config. */
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import {
|
| 4 |
+
isAcpTagVisible,
|
| 5 |
+
resolveAcpProjectionSettings,
|
| 6 |
+
resolveAcpStreamingConfig,
|
| 7 |
+
} from "./acp-stream-settings.js";
|
| 8 |
+
import { createAcpTestConfig } from "./test-fixtures/acp-runtime.js";
|
| 9 |
+
|
| 10 |
+
describe("acp stream settings", () => {
|
| 11 |
+
it("resolves stable defaults", () => {
|
| 12 |
+
const settings = resolveAcpProjectionSettings(createAcpTestConfig());
|
| 13 |
+
expect(settings.deliveryMode).toBe("final_only");
|
| 14 |
+
expect(settings.hiddenBoundarySeparator).toBe("paragraph");
|
| 15 |
+
expect(settings.repeatSuppression).toBe(true);
|
| 16 |
+
expect(settings.maxOutputChars).toBe(24_000);
|
| 17 |
+
expect(settings.maxSessionUpdateChars).toBe(320);
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it("applies retained stream overrides while preserving built-in tuning", () => {
|
| 21 |
+
const settings = resolveAcpProjectionSettings(
|
| 22 |
+
createAcpTestConfig({
|
| 23 |
+
acp: {
|
| 24 |
+
enabled: true,
|
| 25 |
+
stream: {
|
| 26 |
+
deliveryMode: "final_only",
|
| 27 |
+
repeatSuppression: false,
|
| 28 |
+
tagVisibility: {
|
| 29 |
+
usage_update: true,
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
}),
|
| 34 |
+
);
|
| 35 |
+
expect(settings.deliveryMode).toBe("final_only");
|
| 36 |
+
expect(settings.hiddenBoundarySeparator).toBe("paragraph");
|
| 37 |
+
expect(settings.repeatSuppression).toBe(false);
|
| 38 |
+
expect(settings.maxOutputChars).toBe(24_000);
|
| 39 |
+
expect(settings.maxSessionUpdateChars).toBe(320);
|
| 40 |
+
expect(settings.tagVisibility.usage_update).toBe(true);
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
it("accepts explicit deliveryMode=live override", () => {
|
| 44 |
+
const settings = resolveAcpProjectionSettings(
|
| 45 |
+
createAcpTestConfig({
|
| 46 |
+
acp: {
|
| 47 |
+
enabled: true,
|
| 48 |
+
stream: {
|
| 49 |
+
deliveryMode: "live",
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
}),
|
| 53 |
+
);
|
| 54 |
+
expect(settings.deliveryMode).toBe("live");
|
| 55 |
+
expect(settings.hiddenBoundarySeparator).toBe("space");
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("uses default tag visibility when no override is provided", () => {
|
| 59 |
+
const settings = resolveAcpProjectionSettings(createAcpTestConfig());
|
| 60 |
+
expect(isAcpTagVisible(settings, "tool_call")).toBe(false);
|
| 61 |
+
expect(isAcpTagVisible(settings, "tool_call_update")).toBe(false);
|
| 62 |
+
expect(isAcpTagVisible(settings, "usage_update")).toBe(false);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("respects tag visibility overrides", () => {
|
| 66 |
+
const settings = resolveAcpProjectionSettings(
|
| 67 |
+
createAcpTestConfig({
|
| 68 |
+
acp: {
|
| 69 |
+
enabled: true,
|
| 70 |
+
stream: {
|
| 71 |
+
tagVisibility: {
|
| 72 |
+
usage_update: true,
|
| 73 |
+
tool_call: false,
|
| 74 |
+
},
|
| 75 |
+
},
|
| 76 |
+
},
|
| 77 |
+
}),
|
| 78 |
+
);
|
| 79 |
+
expect(isAcpTagVisible(settings, "usage_update")).toBe(true);
|
| 80 |
+
expect(isAcpTagVisible(settings, "tool_call")).toBe(false);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it("resolves built-in ACP chunking and coalescing", () => {
|
| 84 |
+
const streaming = resolveAcpStreamingConfig({
|
| 85 |
+
cfg: createAcpTestConfig(),
|
| 86 |
+
provider: "quietchat",
|
| 87 |
+
});
|
| 88 |
+
expect(streaming.chunking.maxChars).toBe(1800);
|
| 89 |
+
expect(streaming.coalescing.idleMs).toBe(350);
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
it("applies live-mode delivery with built-in streaming tuning", () => {
|
| 93 |
+
const streaming = resolveAcpStreamingConfig({
|
| 94 |
+
cfg: createAcpTestConfig({
|
| 95 |
+
acp: {
|
| 96 |
+
enabled: true,
|
| 97 |
+
stream: {
|
| 98 |
+
deliveryMode: "live",
|
| 99 |
+
},
|
| 100 |
+
},
|
| 101 |
+
}),
|
| 102 |
+
provider: "quietchat",
|
| 103 |
+
deliveryMode: "live",
|
| 104 |
+
});
|
| 105 |
+
expect(streaming.chunking.minChars).toBe(1);
|
| 106 |
+
expect(streaming.chunking.maxChars).toBe(1800);
|
| 107 |
+
expect(streaming.coalescing.minChars).toBe(1);
|
| 108 |
+
expect(streaming.coalescing.maxChars).toBe(1800);
|
| 109 |
+
expect(streaming.coalescing.joiner).toBe("");
|
| 110 |
+
expect(streaming.coalescing.idleMs).toBe(350);
|
| 111 |
+
});
|
| 112 |
+
});
|
src/auto-reply/reply/acp-stream-settings.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** ACP streaming and projection settings derived from config. */
|
| 2 |
+
import type { AcpSessionUpdateTag } from "@openclaw/acp-core/runtime/types";
|
| 3 |
+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
| 4 |
+
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
|
| 5 |
+
|
| 6 |
+
const DEFAULT_ACP_STREAM_COALESCE_IDLE_MS = 350;
|
| 7 |
+
const DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS = 1800;
|
| 8 |
+
const DEFAULT_ACP_REPEAT_SUPPRESSION = true;
|
| 9 |
+
const DEFAULT_ACP_DELIVERY_MODE = "final_only";
|
| 10 |
+
const DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR = "paragraph";
|
| 11 |
+
const DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR_LIVE = "space";
|
| 12 |
+
const DEFAULT_ACP_MAX_OUTPUT_CHARS = 24_000;
|
| 13 |
+
const DEFAULT_ACP_MAX_SESSION_UPDATE_CHARS = 320;
|
| 14 |
+
|
| 15 |
+
const ACP_TAG_VISIBILITY_DEFAULTS: Record<AcpSessionUpdateTag, boolean> = {
|
| 16 |
+
agent_message_chunk: true,
|
| 17 |
+
tool_call: false,
|
| 18 |
+
tool_call_update: false,
|
| 19 |
+
usage_update: false,
|
| 20 |
+
available_commands_update: false,
|
| 21 |
+
current_mode_update: false,
|
| 22 |
+
config_option_update: false,
|
| 23 |
+
session_info_update: false,
|
| 24 |
+
plan: false,
|
| 25 |
+
agent_thought_chunk: false,
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
function isAcpSessionUpdateTag(tag: string): tag is AcpSessionUpdateTag {
|
| 29 |
+
return Object.hasOwn(ACP_TAG_VISIBILITY_DEFAULTS, tag);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/** ACP delivery strategy for projected assistant output. */
|
| 33 |
+
type AcpDeliveryMode = "live" | "final_only";
|
| 34 |
+
export type AcpHiddenBoundarySeparator = "none" | "space" | "newline" | "paragraph";
|
| 35 |
+
|
| 36 |
+
/** Normalized ACP projection settings consumed by stream projectors. */
|
| 37 |
+
export type AcpProjectionSettings = {
|
| 38 |
+
deliveryMode: AcpDeliveryMode;
|
| 39 |
+
hiddenBoundarySeparator: AcpHiddenBoundarySeparator;
|
| 40 |
+
repeatSuppression: boolean;
|
| 41 |
+
maxOutputChars: number;
|
| 42 |
+
maxSessionUpdateChars: number;
|
| 43 |
+
tagVisibility: Partial<Record<AcpSessionUpdateTag, boolean>>;
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
function clampBoolean(value: unknown, fallback: boolean): boolean {
|
| 47 |
+
return typeof value === "boolean" ? value : fallback;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function resolveAcpDeliveryMode(value: unknown): AcpDeliveryMode {
|
| 51 |
+
if (value === "live" || value === "final_only") {
|
| 52 |
+
return value;
|
| 53 |
+
}
|
| 54 |
+
return DEFAULT_ACP_DELIVERY_MODE;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function resolveAcpStreamCoalesceIdleMs(): number {
|
| 58 |
+
return DEFAULT_ACP_STREAM_COALESCE_IDLE_MS;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function resolveAcpStreamMaxChunkChars(): number {
|
| 62 |
+
return DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/** Resolves ACP projection settings with bounded defaults. */
|
| 66 |
+
export function resolveAcpProjectionSettings(cfg: OpenClawConfig): AcpProjectionSettings {
|
| 67 |
+
const stream = cfg.acp?.stream;
|
| 68 |
+
const deliveryMode = resolveAcpDeliveryMode(stream?.deliveryMode);
|
| 69 |
+
const hiddenBoundaryFallback: AcpHiddenBoundarySeparator =
|
| 70 |
+
deliveryMode === "live"
|
| 71 |
+
? DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR_LIVE
|
| 72 |
+
: DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR;
|
| 73 |
+
return {
|
| 74 |
+
deliveryMode,
|
| 75 |
+
hiddenBoundarySeparator: hiddenBoundaryFallback,
|
| 76 |
+
repeatSuppression: clampBoolean(stream?.repeatSuppression, DEFAULT_ACP_REPEAT_SUPPRESSION),
|
| 77 |
+
maxOutputChars: DEFAULT_ACP_MAX_OUTPUT_CHARS,
|
| 78 |
+
maxSessionUpdateChars: DEFAULT_ACP_MAX_SESSION_UPDATE_CHARS,
|
| 79 |
+
tagVisibility: stream?.tagVisibility ?? {},
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** Resolves ACP streaming chunk/coalescing settings. */
|
| 84 |
+
export function resolveAcpStreamingConfig(params: {
|
| 85 |
+
cfg: OpenClawConfig;
|
| 86 |
+
provider?: string;
|
| 87 |
+
accountId?: string;
|
| 88 |
+
deliveryMode?: AcpDeliveryMode;
|
| 89 |
+
}) {
|
| 90 |
+
const resolved = resolveEffectiveBlockStreamingConfig({
|
| 91 |
+
cfg: params.cfg,
|
| 92 |
+
provider: params.provider,
|
| 93 |
+
accountId: params.accountId,
|
| 94 |
+
maxChunkChars: resolveAcpStreamMaxChunkChars(),
|
| 95 |
+
coalesceIdleMs: resolveAcpStreamCoalesceIdleMs(),
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
// In live mode, ACP text deltas should flush promptly and never be held
|
| 99 |
+
// behind large generic min-char thresholds.
|
| 100 |
+
if (params.deliveryMode === "live") {
|
| 101 |
+
return {
|
| 102 |
+
chunking: {
|
| 103 |
+
...resolved.chunking,
|
| 104 |
+
minChars: 1,
|
| 105 |
+
},
|
| 106 |
+
coalescing: {
|
| 107 |
+
...resolved.coalescing,
|
| 108 |
+
minChars: 1,
|
| 109 |
+
// ACP delta streams already carry spacing/newlines; preserve exact text.
|
| 110 |
+
joiner: "",
|
| 111 |
+
},
|
| 112 |
+
};
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return resolved;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
export function isAcpTagVisible(settings: AcpProjectionSettings, tag: string | undefined): boolean {
|
| 119 |
+
if (!tag) {
|
| 120 |
+
return true;
|
| 121 |
+
}
|
| 122 |
+
if (!isAcpSessionUpdateTag(tag)) {
|
| 123 |
+
return true;
|
| 124 |
+
}
|
| 125 |
+
const override = settings.tagVisibility[tag];
|
| 126 |
+
if (typeof override === "boolean") {
|
| 127 |
+
return override;
|
| 128 |
+
}
|
| 129 |
+
const defaultVisibility = ACP_TAG_VISIBILITY_DEFAULTS[tag];
|
| 130 |
+
if (defaultVisibility === undefined) {
|
| 131 |
+
throw new Error(`Missing ACP visibility default for ${tag}`);
|
| 132 |
+
}
|
| 133 |
+
return defaultVisibility;
|
| 134 |
+
}
|
src/auto-reply/reply/agent-event-bridge.test.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Covers the shared CLI delivery bridge's subscription scope. Every concurrent
|
| 2 |
+
// CLI run builds up to eight of these bridges, so a bridge that subscribes
|
| 3 |
+
// globally and discards foreign runs makes per-delta cost grow with agent count.
|
| 4 |
+
import { beforeEach, describe, expect, test } from "vitest";
|
| 5 |
+
import { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
|
| 6 |
+
import { registerAgentRunContext } from "../../infra/agent-run-registry.js";
|
| 7 |
+
import { createAgentEventBridge } from "./agent-event-bridge.js";
|
| 8 |
+
|
| 9 |
+
function textBridge(runId: string, sink: string[]) {
|
| 10 |
+
return createAgentEventBridge<string>({
|
| 11 |
+
runId,
|
| 12 |
+
read: (evt) => (typeof evt.data.text === "string" ? evt.data.text : undefined),
|
| 13 |
+
deliver: async (text) => {
|
| 14 |
+
sink.push(text);
|
| 15 |
+
},
|
| 16 |
+
});
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
describe("agent event delivery bridge", () => {
|
| 20 |
+
beforeEach(() => {
|
| 21 |
+
resetAgentEventsForTest();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
test.each([1, 4, 16, 64])(
|
| 25 |
+
"isolates every delivery bridge while %s CLI runs stream interleaved events",
|
| 26 |
+
async (runs) => {
|
| 27 |
+
let predicateReads = 0;
|
| 28 |
+
const bridges = Array.from({ length: runs }, (_, runIndex) => {
|
| 29 |
+
const runId = `run-${runIndex}`;
|
| 30 |
+
registerAgentRunContext(runId, { sessionKey: `session-${runIndex}` });
|
| 31 |
+
return Array.from({ length: 8 }, (_bridge, bridgeIndex) => {
|
| 32 |
+
const delivered: string[] = [];
|
| 33 |
+
const bridge = createAgentEventBridge<string>({
|
| 34 |
+
get runId() {
|
| 35 |
+
predicateReads++;
|
| 36 |
+
return runId;
|
| 37 |
+
},
|
| 38 |
+
read: (event) => (typeof event.data.text === "string" ? event.data.text : undefined),
|
| 39 |
+
deliver: async (text) => {
|
| 40 |
+
delivered.push(text);
|
| 41 |
+
},
|
| 42 |
+
});
|
| 43 |
+
return { ...bridge, delivered, runIndex, bridgeIndex };
|
| 44 |
+
});
|
| 45 |
+
}).flat();
|
| 46 |
+
try {
|
| 47 |
+
predicateReads = 0;
|
| 48 |
+
emitAgentEvent({ runId: "run-0", stream: "assistant", data: { text: "a1" } });
|
| 49 |
+
// Source-pinned work measurement only: hoisting the filter can change this
|
| 50 |
+
// count without changing dispatch complexity. Output assertions own the test.
|
| 51 |
+
console.info("bridge predicate-read diagnostic (not latency)", {
|
| 52 |
+
runs,
|
| 53 |
+
bridgesPerRun: 8,
|
| 54 |
+
predicateReads,
|
| 55 |
+
});
|
| 56 |
+
emitAgentEvent({ runId: "run-1", stream: "assistant", data: { text: "b1" } });
|
| 57 |
+
emitAgentEvent({ runId: "run-0", stream: "assistant", data: { text: "a2" } });
|
| 58 |
+
await Promise.all(bridges.map((bridge) => bridge.drain()));
|
| 59 |
+
for (const { delivered, runIndex, bridgeIndex } of bridges) {
|
| 60 |
+
expect(delivered, `run-${runIndex}/bridge-${bridgeIndex}`).toEqual(
|
| 61 |
+
runIndex === 0 ? ["a1", "a2"] : runIndex === 1 ? ["b1"] : [],
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
} finally {
|
| 65 |
+
for (const bridge of bridges) {
|
| 66 |
+
bridge.unsubscribe();
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
test("keeps stream delivery order when a later global listener emits another event", async () => {
|
| 73 |
+
registerAgentRunContext("run-nested", { sessionKey: "session-nested" });
|
| 74 |
+
const delivered: string[] = [];
|
| 75 |
+
const bridge = textBridge("run-nested", delivered);
|
| 76 |
+
const stopGlobal = onAgentEvent((evt) => {
|
| 77 |
+
if (evt.runId === "run-nested" && evt.data.text === "outer") {
|
| 78 |
+
emitAgentEvent({ runId: "run-nested", stream: "assistant", data: { text: "inner" } });
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
try {
|
| 82 |
+
emitAgentEvent({ runId: "run-nested", stream: "assistant", data: { text: "outer" } });
|
| 83 |
+
await bridge.drain();
|
| 84 |
+
expect(delivered).toEqual(["outer", "inner"]);
|
| 85 |
+
} finally {
|
| 86 |
+
stopGlobal();
|
| 87 |
+
bridge.unsubscribe();
|
| 88 |
+
}
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
test("delivers the current event before a later global listener unsubscribes the bridge", async () => {
|
| 92 |
+
registerAgentRunContext("run-unsubscribe", { sessionKey: "session-unsubscribe" });
|
| 93 |
+
const delivered: string[] = [];
|
| 94 |
+
const bridge = textBridge("run-unsubscribe", delivered);
|
| 95 |
+
const stopGlobal = onAgentEvent((evt) => {
|
| 96 |
+
if (evt.runId === "run-unsubscribe") {
|
| 97 |
+
bridge.unsubscribe();
|
| 98 |
+
}
|
| 99 |
+
});
|
| 100 |
+
try {
|
| 101 |
+
emitAgentEvent({ runId: "run-unsubscribe", stream: "assistant", data: { text: "first" } });
|
| 102 |
+
emitAgentEvent({ runId: "run-unsubscribe", stream: "assistant", data: { text: "second" } });
|
| 103 |
+
await bridge.drain();
|
| 104 |
+
expect(delivered).toEqual(["first"]);
|
| 105 |
+
} finally {
|
| 106 |
+
stopGlobal();
|
| 107 |
+
bridge.unsubscribe();
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
test("routes a changed event identity only to later matching bridges", async () => {
|
| 112 |
+
registerAgentRunContext("run-a", { sessionKey: "session-a" });
|
| 113 |
+
registerAgentRunContext("run-b", { sessionKey: "session-b" });
|
| 114 |
+
const earlierB: string[] = [];
|
| 115 |
+
const laterB: string[] = [];
|
| 116 |
+
const laterA: string[] = [];
|
| 117 |
+
const first = textBridge("run-b", earlierB);
|
| 118 |
+
const stopGlobal = onAgentEvent((event) => {
|
| 119 |
+
event.runId = "run-b";
|
| 120 |
+
});
|
| 121 |
+
const second = textBridge("run-b", laterB);
|
| 122 |
+
const third = textBridge("run-a", laterA);
|
| 123 |
+
try {
|
| 124 |
+
emitAgentEvent({ runId: "run-a", stream: "assistant", data: { text: "changed" } });
|
| 125 |
+
await Promise.all([first.drain(), second.drain(), third.drain()]);
|
| 126 |
+
expect(earlierB).toEqual([]);
|
| 127 |
+
expect(laterB).toEqual(["changed"]);
|
| 128 |
+
expect(laterA).toEqual([]);
|
| 129 |
+
} finally {
|
| 130 |
+
stopGlobal();
|
| 131 |
+
first.unsubscribe();
|
| 132 |
+
second.unsubscribe();
|
| 133 |
+
third.unsubscribe();
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
});
|
src/auto-reply/reply/agent-event-bridge.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Generic agent-event bridge machinery shared by the CLI runner's per-stream
|
| 2 |
+
// delivery bridges (assistant, reasoning, commentary, plan).
|
| 3 |
+
import { type AgentEventPayload, onAgentEventForRun } from "../../infra/agent-events.js";
|
| 4 |
+
|
| 5 |
+
export type AgentEventDeliveryStartOrder = {
|
| 6 |
+
schedule: (deliver: () => Promise<unknown>) => Promise<void>;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export function createAgentEventDeliveryStartOrder(): AgentEventDeliveryStartOrder {
|
| 10 |
+
let startTail = Promise.resolve();
|
| 11 |
+
return {
|
| 12 |
+
schedule: async (deliver) => {
|
| 13 |
+
// Reserve at raw event receipt, then release at callback invocation. CLI streams drain
|
| 14 |
+
// independently, so waiting for callback completion here would reorder later streams.
|
| 15 |
+
const previousStart = startTail;
|
| 16 |
+
let releaseStart: (() => void) | undefined;
|
| 17 |
+
startTail = new Promise<void>((resolve) => {
|
| 18 |
+
releaseStart = resolve;
|
| 19 |
+
});
|
| 20 |
+
await previousStart;
|
| 21 |
+
let delivery: Promise<unknown>;
|
| 22 |
+
try {
|
| 23 |
+
delivery = deliver();
|
| 24 |
+
} finally {
|
| 25 |
+
releaseStart?.();
|
| 26 |
+
}
|
| 27 |
+
await delivery;
|
| 28 |
+
},
|
| 29 |
+
};
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function createAgentEventBridge<T>(params: {
|
| 33 |
+
runId: string;
|
| 34 |
+
suppressed?: boolean;
|
| 35 |
+
read: (evt: AgentEventPayload) => T | undefined;
|
| 36 |
+
deliver?: (payload: T) => Promise<unknown>;
|
| 37 |
+
startOrder?: AgentEventDeliveryStartOrder;
|
| 38 |
+
}) {
|
| 39 |
+
const deliver = params.deliver;
|
| 40 |
+
if (!deliver) {
|
| 41 |
+
return {
|
| 42 |
+
unsubscribe: () => undefined,
|
| 43 |
+
drain: async (): Promise<void> => undefined,
|
| 44 |
+
};
|
| 45 |
+
}
|
| 46 |
+
let unsubscribed = false;
|
| 47 |
+
let delivery: Promise<unknown> = Promise.resolve();
|
| 48 |
+
const rawUnsubscribe = onAgentEventForRun(params.runId, (evt) => {
|
| 49 |
+
if (evt.runId !== params.runId) {
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
if (params.suppressed) {
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
const payload = params.read(evt);
|
| 56 |
+
if (payload === undefined) {
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
if (!params.startOrder) {
|
| 60 |
+
delivery = delivery.then(() => deliver(payload)).catch(() => undefined);
|
| 61 |
+
return;
|
| 62 |
+
}
|
| 63 |
+
const scheduled = params.startOrder.schedule(() => deliver(payload)).catch(() => undefined);
|
| 64 |
+
// Start ordering stays global; each bridge still owns and drains its callback completion.
|
| 65 |
+
delivery = Promise.all([delivery, scheduled]).then(() => undefined);
|
| 66 |
+
});
|
| 67 |
+
return {
|
| 68 |
+
unsubscribe() {
|
| 69 |
+
if (unsubscribed) {
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
unsubscribed = true;
|
| 73 |
+
rawUnsubscribe();
|
| 74 |
+
},
|
| 75 |
+
async drain(): Promise<void> {
|
| 76 |
+
await delivery;
|
| 77 |
+
},
|
| 78 |
+
};
|
| 79 |
+
}
|
src/auto-reply/reply/agent-lifecycle-terminal.test.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { OAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
|
| 3 |
+
import { FailoverError } from "../../agents/failover-error.js";
|
| 4 |
+
import { renderFailoverCodeUserCopy } from "../../agents/failover/user-copy.js";
|
| 5 |
+
import * as providerFailover from "../../plugins/provider-failover.js";
|
| 6 |
+
import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
|
| 7 |
+
|
| 8 |
+
const { emitAgentEvent } = vi.hoisted(() => ({ emitAgentEvent: vi.fn() }));
|
| 9 |
+
|
| 10 |
+
vi.mock("../../infra/agent-events.js", () => ({ emitAgentEvent }));
|
| 11 |
+
|
| 12 |
+
describe("createAgentLifecycleTerminalBackstop", () => {
|
| 13 |
+
it.each([false, true])("keeps only the selected attempt receipt (retry=%s)", (retry) => {
|
| 14 |
+
emitAgentEvent.mockClear();
|
| 15 |
+
const terminal = createAgentLifecycleTerminalBackstop({
|
| 16 |
+
runId: "run",
|
| 17 |
+
getLifecycleGeneration: () => "generation",
|
| 18 |
+
resolveTerminationFields: () => ({}),
|
| 19 |
+
});
|
| 20 |
+
terminal.note({
|
| 21 |
+
stream: "lifecycle",
|
| 22 |
+
data: {
|
| 23 |
+
phase: "finishing",
|
| 24 |
+
error: "first failure",
|
| 25 |
+
assistantTranscriptIdempotencyKey: "saved-A",
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
terminal.capture("error", new Error("first failure"));
|
| 29 |
+
expect(emitAgentEvent).not.toHaveBeenCalled();
|
| 30 |
+
if (retry) {
|
| 31 |
+
// Preparation fails before the next lifecycle start can be emitted.
|
| 32 |
+
terminal.beginAttempt();
|
| 33 |
+
}
|
| 34 |
+
terminal.emit("error", new Error(retry ? "preparation failed" : "first failure"));
|
| 35 |
+
expect(emitAgentEvent).toHaveBeenCalledOnce();
|
| 36 |
+
const data = emitAgentEvent.mock.calls[0]?.[0]?.data;
|
| 37 |
+
expect(data.assistantTranscriptIdempotencyKey).toBe(retry ? undefined : "saved-A");
|
| 38 |
+
expect(data.error).toBe(retry ? "preparation failed" : "first failure");
|
| 39 |
+
expect(data.executionSettled).toBe(true);
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
it("publishes the provider-owned OAuth summary instead of the wrapped diagnostic", () => {
|
| 43 |
+
emitAgentEvent.mockClear();
|
| 44 |
+
const summary =
|
| 45 |
+
"Your refresh token has already been used to generate a new access token. Please try signing in again.";
|
| 46 |
+
const rawDiagnostic = `OAuth token refresh failed for openai: {"error":{"message":"${summary}"}}`;
|
| 47 |
+
const oauthError = new OAuthRefreshFailureError({
|
| 48 |
+
provider: "openai",
|
| 49 |
+
message: rawDiagnostic,
|
| 50 |
+
errorType: "invalid_request_error",
|
| 51 |
+
reason: "refresh_token_reused",
|
| 52 |
+
status: 401,
|
| 53 |
+
summary,
|
| 54 |
+
});
|
| 55 |
+
const error = new Error("wrapped OAuth refresh failure", { cause: oauthError });
|
| 56 |
+
const terminal = createAgentLifecycleTerminalBackstop({
|
| 57 |
+
runId: "oauth-refresh-failure",
|
| 58 |
+
getLifecycleGeneration: () => "test-generation",
|
| 59 |
+
resolveTerminationFields: () => ({}),
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
terminal.emit("error", error);
|
| 63 |
+
|
| 64 |
+
const event = emitAgentEvent.mock.calls[0]?.[0];
|
| 65 |
+
expect(event.data.error).toBe(`⚠️ ${summary}`);
|
| 66 |
+
expect(event.data.errorObservation).toEqual({
|
| 67 |
+
provider: "openai",
|
| 68 |
+
failoverReason: "refresh_token_reused",
|
| 69 |
+
providerRuntimeFailureKind: "auth_refresh",
|
| 70 |
+
providerErrorType: "invalid_request_error",
|
| 71 |
+
httpStatus: 401,
|
| 72 |
+
});
|
| 73 |
+
expect(JSON.stringify(event)).not.toContain(rawDiagnostic);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
it.each(["typed", "raw"] as const)(
|
| 77 |
+
"publishes bounded selected-profile recovery from %s failures without discovering providers",
|
| 78 |
+
(kind) => {
|
| 79 |
+
const classifyProvider = vi
|
| 80 |
+
.spyOn(providerFailover, "classifyProviderFailoverSignalWithPlugin")
|
| 81 |
+
.mockImplementation(() => {
|
| 82 |
+
throw new Error("Terminal presentation must not discover providers");
|
| 83 |
+
});
|
| 84 |
+
emitAgentEvent.mockClear();
|
| 85 |
+
try {
|
| 86 |
+
const profileId = "openai:private-profile";
|
| 87 |
+
const rawCause = `Codex app-server auth profile "${profileId}" was not found`;
|
| 88 |
+
const terminal = createAgentLifecycleTerminalBackstop({
|
| 89 |
+
runId: "missing-selected-profile",
|
| 90 |
+
sessionKey: "agent:main:test",
|
| 91 |
+
getLifecycleGeneration: () => "test-generation",
|
| 92 |
+
resolveTerminationFields: () => ({}),
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const error =
|
| 96 |
+
kind === "typed"
|
| 97 |
+
? new FailoverError(rawCause, {
|
| 98 |
+
reason: "auth",
|
| 99 |
+
code: "selected_auth_profile_unavailable",
|
| 100 |
+
profileId,
|
| 101 |
+
cause: new Error(rawCause),
|
| 102 |
+
})
|
| 103 |
+
: Object.assign(new Error(rawCause), {
|
| 104 |
+
code: "selected_auth_profile_unavailable",
|
| 105 |
+
});
|
| 106 |
+
terminal.emit("error", error);
|
| 107 |
+
|
| 108 |
+
const event = emitAgentEvent.mock.calls[0]?.[0];
|
| 109 |
+
expect(event.data.error).toBe(
|
| 110 |
+
renderFailoverCodeUserCopy("selected_auth_profile_unavailable"),
|
| 111 |
+
);
|
| 112 |
+
expect(JSON.stringify(event)).not.toContain(profileId);
|
| 113 |
+
expect(JSON.stringify(event)).not.toContain(rawCause);
|
| 114 |
+
expect(classifyProvider).not.toHaveBeenCalled();
|
| 115 |
+
} finally {
|
| 116 |
+
classifyProvider.mockRestore();
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
);
|
| 120 |
+
});
|
src/auto-reply/reply/agent-lifecycle-terminal.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
| 2 |
+
import { classifyOAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
|
| 3 |
+
import { getFailoverErrorCode } from "../../agents/failover/error.js";
|
| 4 |
+
import { renderFailoverCodeUserCopy } from "../../agents/failover/user-copy.js";
|
| 5 |
+
import { AGENT_RUN_RESTART_ABORT_STOP_REASON } from "../../agents/run-termination.js";
|
| 6 |
+
import { emitAgentEvent } from "../../infra/agent-events.js";
|
| 7 |
+
import { formatErrorMessage } from "../../infra/errors.js";
|
| 8 |
+
|
| 9 |
+
export type AgentLifecycleTerminalBackstop = {
|
| 10 |
+
beginAttempt: () => void;
|
| 11 |
+
capture: AgentLifecycleTerminalBackstop["emit"];
|
| 12 |
+
emit: (
|
| 13 |
+
phase: "end" | "error",
|
| 14 |
+
resultOrError: unknown,
|
| 15 |
+
extraData?: Record<string, unknown>,
|
| 16 |
+
) => void;
|
| 17 |
+
getDeferredError: () => string | undefined;
|
| 18 |
+
note: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type PendingLifecycleTerminal = {
|
| 22 |
+
kind: "pending";
|
| 23 |
+
deferredError?: string;
|
| 24 |
+
metadata: Record<string, unknown>;
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
type LifecycleTerminalState =
|
| 28 |
+
| PendingLifecycleTerminal
|
| 29 |
+
| { kind: "captured"; event: Parameters<typeof emitAgentEvent>[0] }
|
| 30 |
+
| { kind: "emitted" };
|
| 31 |
+
|
| 32 |
+
const DEFERRED_TERMINAL_METADATA_KEYS = [
|
| 33 |
+
"stopReason",
|
| 34 |
+
"yielded",
|
| 35 |
+
"timeoutPhase",
|
| 36 |
+
"providerStarted",
|
| 37 |
+
"aborted",
|
| 38 |
+
"livenessState",
|
| 39 |
+
"replayInvalid",
|
| 40 |
+
"errorObservation",
|
| 41 |
+
"assistantTranscriptIdempotencyKey",
|
| 42 |
+
] as const;
|
| 43 |
+
|
| 44 |
+
export function resolveAgentLifecycleTerminalMetadata(meta: unknown): Record<string, unknown> {
|
| 45 |
+
const metadata: Record<string, unknown> = {};
|
| 46 |
+
if (!meta || typeof meta !== "object") {
|
| 47 |
+
return metadata;
|
| 48 |
+
}
|
| 49 |
+
const record = meta as Record<string, unknown>;
|
| 50 |
+
for (const key of DEFERRED_TERMINAL_METADATA_KEYS) {
|
| 51 |
+
if (Object.hasOwn(record, key)) {
|
| 52 |
+
metadata[key] = record[key];
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
return metadata;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function createAgentLifecycleTerminalBackstop(params: {
|
| 59 |
+
runId: string;
|
| 60 |
+
sessionKey?: string;
|
| 61 |
+
startedAt?: number;
|
| 62 |
+
getLifecycleGeneration: () => string;
|
| 63 |
+
onTerminalEvent?: (event: Parameters<typeof emitAgentEvent>[0]) => void;
|
| 64 |
+
resolveTerminationFields: (error?: unknown) => {
|
| 65 |
+
aborted?: true;
|
| 66 |
+
stopReason?: string;
|
| 67 |
+
timeoutPhase?: string;
|
| 68 |
+
};
|
| 69 |
+
}): AgentLifecycleTerminalBackstop {
|
| 70 |
+
let state: LifecycleTerminalState = { kind: "pending", metadata: {} };
|
| 71 |
+
// Preparation can fail before a lifecycle start. Capture its real boundary
|
| 72 |
+
// without signaling readiness; an observed model start replaces it below.
|
| 73 |
+
let startedAt = params.startedAt ?? Date.now();
|
| 74 |
+
const beginAttempt = () => {
|
| 75 |
+
if (state.kind !== "emitted") {
|
| 76 |
+
state = { kind: "pending", metadata: {} };
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const note = (evt: { stream: string; data: Record<string, unknown> }) => {
|
| 81 |
+
if (state.kind === "emitted" || evt.stream !== "lifecycle") {
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
const phase = readStringValue(evt.data.phase);
|
| 85 |
+
if (phase === "start") {
|
| 86 |
+
beginAttempt();
|
| 87 |
+
if (typeof evt.data.startedAt === "number") {
|
| 88 |
+
startedAt = evt.data.startedAt;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
if (phase === "finishing" && state.kind === "pending") {
|
| 92 |
+
state.deferredError = readStringValue(evt.data.error) ?? state.deferredError;
|
| 93 |
+
Object.assign(state.metadata, resolveAgentLifecycleTerminalMetadata(evt.data));
|
| 94 |
+
}
|
| 95 |
+
if (phase === "end" || phase === "error") {
|
| 96 |
+
state = { kind: "emitted" };
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const prepareTerminal = (
|
| 101 |
+
pending: PendingLifecycleTerminal,
|
| 102 |
+
phase: "end" | "error",
|
| 103 |
+
resultOrError: unknown,
|
| 104 |
+
extraData?: Record<string, unknown>,
|
| 105 |
+
): Parameters<typeof emitAgentEvent>[0] => {
|
| 106 |
+
const terminationFields = params.resolveTerminationFields(
|
| 107 |
+
phase === "error" ? resultOrError : undefined,
|
| 108 |
+
);
|
| 109 |
+
const restartAbort = terminationFields.stopReason === AGENT_RUN_RESTART_ABORT_STOP_REASON;
|
| 110 |
+
const data: Record<string, unknown> = {
|
| 111 |
+
...pending.metadata,
|
| 112 |
+
phase: restartAbort ? "end" : phase,
|
| 113 |
+
endedAt: Date.now(),
|
| 114 |
+
startedAt,
|
| 115 |
+
};
|
| 116 |
+
if (restartAbort) {
|
| 117 |
+
data.aborted = true;
|
| 118 |
+
data.stopReason = AGENT_RUN_RESTART_ABORT_STOP_REASON;
|
| 119 |
+
} else if (phase === "error") {
|
| 120 |
+
const oauthFailure = classifyOAuthRefreshFailureError(resultOrError);
|
| 121 |
+
data.error =
|
| 122 |
+
renderFailoverCodeUserCopy(getFailoverErrorCode(resultOrError)) ??
|
| 123 |
+
(oauthFailure?.summary ? `⚠️ ${oauthFailure.summary}` : undefined) ??
|
| 124 |
+
formatErrorMessage(resultOrError);
|
| 125 |
+
if (oauthFailure?.summary) {
|
| 126 |
+
data.errorObservation = {
|
| 127 |
+
...(oauthFailure.provider ? { provider: oauthFailure.provider } : {}),
|
| 128 |
+
...(oauthFailure.reason ? { failoverReason: oauthFailure.reason } : {}),
|
| 129 |
+
providerRuntimeFailureKind: "auth_refresh",
|
| 130 |
+
...(oauthFailure.errorType ? { providerErrorType: oauthFailure.errorType } : {}),
|
| 131 |
+
...(oauthFailure.status ? { httpStatus: oauthFailure.status } : {}),
|
| 132 |
+
};
|
| 133 |
+
}
|
| 134 |
+
Object.assign(data, terminationFields);
|
| 135 |
+
} else {
|
| 136 |
+
const meta =
|
| 137 |
+
resultOrError && typeof resultOrError === "object" && "meta" in resultOrError
|
| 138 |
+
? (resultOrError as { meta?: Record<string, unknown> }).meta
|
| 139 |
+
: undefined;
|
| 140 |
+
Object.assign(data, resolveAgentLifecycleTerminalMetadata(meta));
|
| 141 |
+
if (terminationFields.aborted === true) {
|
| 142 |
+
data.aborted = true;
|
| 143 |
+
}
|
| 144 |
+
if (terminationFields.stopReason && !readStringValue(data.stopReason)) {
|
| 145 |
+
data.stopReason = terminationFields.stopReason;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
if (extraData) {
|
| 149 |
+
Object.assign(data, extraData);
|
| 150 |
+
}
|
| 151 |
+
return {
|
| 152 |
+
runId: params.runId,
|
| 153 |
+
lifecycleGeneration: params.getLifecycleGeneration(),
|
| 154 |
+
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
| 155 |
+
stream: "lifecycle",
|
| 156 |
+
data,
|
| 157 |
+
};
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const capture: AgentLifecycleTerminalBackstop["capture"] = (phase, resultOrError, extraData) => {
|
| 161 |
+
if (state.kind === "pending") {
|
| 162 |
+
// Record bounded terminal facts before awaited bookkeeping can change
|
| 163 |
+
// the abort signal, lifecycle generation, or subsequent workflow outcome.
|
| 164 |
+
const event = prepareTerminal(state, phase, resultOrError, extraData);
|
| 165 |
+
state = { kind: "captured", event };
|
| 166 |
+
}
|
| 167 |
+
};
|
| 168 |
+
const emit: AgentLifecycleTerminalBackstop["emit"] = (phase, resultOrError, extraData) => {
|
| 169 |
+
const current = state;
|
| 170 |
+
if (current.kind === "emitted") {
|
| 171 |
+
return;
|
| 172 |
+
}
|
| 173 |
+
state = { kind: "emitted" };
|
| 174 |
+
const event =
|
| 175 |
+
current.kind === "captured"
|
| 176 |
+
? current.event
|
| 177 |
+
: prepareTerminal(current, phase, resultOrError, extraData);
|
| 178 |
+
// Captured candidates can still be replaced by retries. Only publication
|
| 179 |
+
// settles execution; delivery and yielded-parent continuation remain separate.
|
| 180 |
+
const settled = { ...event, data: { ...event.data, executionSettled: true } };
|
| 181 |
+
emitAgentEvent(settled);
|
| 182 |
+
params.onTerminalEvent?.(settled);
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
beginAttempt,
|
| 187 |
+
capture,
|
| 188 |
+
emit,
|
| 189 |
+
getDeferredError: () => (state.kind === "pending" ? state.deferredError : undefined),
|
| 190 |
+
note,
|
| 191 |
+
};
|
| 192 |
+
}
|
src/auto-reply/reply/agent-runner-auth-profile.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Resolves auth profile settings that agent runner forwards to providers.
|
| 2 |
+
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
| 3 |
+
import {
|
| 4 |
+
resolveProviderIdForAuth,
|
| 5 |
+
type ProviderAuthAliasLookupParams,
|
| 6 |
+
} from "../../agents/provider-auth-aliases.js";
|
| 7 |
+
import type { FollowupRun } from "./queue.js";
|
| 8 |
+
|
| 9 |
+
/** Keeps an auth profile only when the current provider shares the primary auth scope. */
|
| 10 |
+
export function resolveProviderScopedAuthProfile(params: {
|
| 11 |
+
provider: string;
|
| 12 |
+
primaryProvider: string;
|
| 13 |
+
authProfileId?: string;
|
| 14 |
+
authProfileIdSource?: "auto" | "user";
|
| 15 |
+
config?: ProviderAuthAliasLookupParams["config"];
|
| 16 |
+
workspaceDir?: ProviderAuthAliasLookupParams["workspaceDir"];
|
| 17 |
+
}): { authProfileId?: string; authProfileIdSource?: "auto" | "user" } {
|
| 18 |
+
const aliasParams = { config: params.config, workspaceDir: params.workspaceDir };
|
| 19 |
+
const providerId = normalizeProviderId(params.provider);
|
| 20 |
+
const primaryProviderId = normalizeProviderId(params.primaryProvider);
|
| 21 |
+
const sharesAuthScope =
|
| 22 |
+
(providerId !== "" && providerId === primaryProviderId) ||
|
| 23 |
+
resolveProviderIdForAuth(params.provider, aliasParams) ===
|
| 24 |
+
resolveProviderIdForAuth(params.primaryProvider, aliasParams);
|
| 25 |
+
const authProfileId = sharesAuthScope ? params.authProfileId : undefined;
|
| 26 |
+
return {
|
| 27 |
+
authProfileId,
|
| 28 |
+
authProfileIdSource: authProfileId ? params.authProfileIdSource : undefined,
|
| 29 |
+
};
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/** Resolves the auth profile override for a queued follow-up run. */
|
| 33 |
+
export function resolveRunAuthProfile(
|
| 34 |
+
run: FollowupRun["run"],
|
| 35 |
+
provider: string,
|
| 36 |
+
params?: { config?: ProviderAuthAliasLookupParams["config"] },
|
| 37 |
+
) {
|
| 38 |
+
return resolveProviderScopedAuthProfile({
|
| 39 |
+
provider,
|
| 40 |
+
primaryProvider: run.provider,
|
| 41 |
+
authProfileId: run.authProfileId,
|
| 42 |
+
authProfileIdSource: run.authProfileIdSource,
|
| 43 |
+
config: params?.config ?? run.config,
|
| 44 |
+
workspaceDir: run.workspaceDir,
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/** Applies an auto-fallback probe's pinned auth to its fallback candidate. */
|
| 49 |
+
export function resolveFallbackCandidateRun(
|
| 50 |
+
run: FollowupRun["run"],
|
| 51 |
+
provider: string,
|
| 52 |
+
model: string,
|
| 53 |
+
): FollowupRun["run"] {
|
| 54 |
+
const probe = run.autoFallbackPrimaryProbe;
|
| 55 |
+
const isPrimaryProbeCandidate = probe && provider === probe.provider && model === probe.model;
|
| 56 |
+
if (
|
| 57 |
+
!probe ||
|
| 58 |
+
provider !== probe.fallbackProvider ||
|
| 59 |
+
isPrimaryProbeCandidate ||
|
| 60 |
+
!probe.fallbackAuthProfileId
|
| 61 |
+
) {
|
| 62 |
+
return run;
|
| 63 |
+
}
|
| 64 |
+
const candidateRun: FollowupRun["run"] = {
|
| 65 |
+
...run,
|
| 66 |
+
provider,
|
| 67 |
+
model,
|
| 68 |
+
authProfileId: probe.fallbackAuthProfileId,
|
| 69 |
+
};
|
| 70 |
+
if (probe.fallbackAuthProfileIdSource) {
|
| 71 |
+
candidateRun.authProfileIdSource = probe.fallbackAuthProfileIdSource;
|
| 72 |
+
} else {
|
| 73 |
+
delete candidateRun.authProfileIdSource;
|
| 74 |
+
}
|
| 75 |
+
return candidateRun;
|
| 76 |
+
}
|
src/auto-reply/reply/agent-runner-auto-fallback.test.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import type { SessionEntry } from "../../config/sessions.js";
|
| 3 |
+
import type { FollowupRun } from "./queue.js";
|
| 4 |
+
|
| 5 |
+
const state = vi.hoisted(() => ({
|
| 6 |
+
updateSessionEntryMock: vi.fn(),
|
| 7 |
+
}));
|
| 8 |
+
|
| 9 |
+
vi.mock("../../config/sessions/session-accessor.js", () => ({
|
| 10 |
+
updateSessionEntry: (...args: unknown[]) => state.updateSessionEntryMock(...args),
|
| 11 |
+
}));
|
| 12 |
+
|
| 13 |
+
import { clearRecoveredAutoFallbackPrimaryProbeSelection } from "./agent-runner-auto-fallback.js";
|
| 14 |
+
|
| 15 |
+
describe("clearRecoveredAutoFallbackPrimaryProbeSelection", () => {
|
| 16 |
+
beforeEach(() => {
|
| 17 |
+
state.updateSessionEntryMock.mockReset();
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it("refreshes the local selection when the persisted comparison rejects the probe", async () => {
|
| 21 |
+
const probe = {
|
| 22 |
+
provider: "anthropic",
|
| 23 |
+
model: "claude-sonnet-4-6",
|
| 24 |
+
fallbackProvider: "openai",
|
| 25 |
+
fallbackModel: "gpt-5.4",
|
| 26 |
+
};
|
| 27 |
+
const staleAutoEntry: SessionEntry = {
|
| 28 |
+
sessionId: "session",
|
| 29 |
+
updatedAt: 1,
|
| 30 |
+
providerOverride: probe.fallbackProvider,
|
| 31 |
+
modelOverride: probe.fallbackModel,
|
| 32 |
+
modelOverrideSource: "auto",
|
| 33 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 34 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 35 |
+
};
|
| 36 |
+
const newerUserEntry: SessionEntry = {
|
| 37 |
+
sessionId: "newer-session",
|
| 38 |
+
updatedAt: 2,
|
| 39 |
+
providerOverride: "openai",
|
| 40 |
+
modelOverride: "gpt-5.5",
|
| 41 |
+
modelOverrideSource: "user",
|
| 42 |
+
};
|
| 43 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 44 |
+
state.updateSessionEntryMock.mockImplementationOnce(
|
| 45 |
+
async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
|
| 46 |
+
expect(await update(newerUserEntry)).toBeNull();
|
| 47 |
+
return null;
|
| 48 |
+
},
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 52 |
+
run: {
|
| 53 |
+
provider: probe.provider,
|
| 54 |
+
model: probe.model,
|
| 55 |
+
autoFallbackPrimaryProbe: probe,
|
| 56 |
+
} as FollowupRun["run"],
|
| 57 |
+
provider: probe.provider,
|
| 58 |
+
model: probe.model,
|
| 59 |
+
sessionKey: "main",
|
| 60 |
+
activeSessionStore,
|
| 61 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 62 |
+
storePath: "/tmp/sessions.sqlite",
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
expect(activeSessionStore.main).toBe(newerUserEntry);
|
| 66 |
+
expect(activeSessionStore.main).toMatchObject({
|
| 67 |
+
sessionId: "newer-session",
|
| 68 |
+
providerOverride: "openai",
|
| 69 |
+
modelOverride: "gpt-5.5",
|
| 70 |
+
modelOverrideSource: "user",
|
| 71 |
+
});
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it("preserves an identically reselected persisted fallback generation", async () => {
|
| 75 |
+
const probe = {
|
| 76 |
+
provider: "anthropic",
|
| 77 |
+
model: "claude-sonnet-4-6",
|
| 78 |
+
fallbackProvider: "openai",
|
| 79 |
+
fallbackModel: "gpt-5.4",
|
| 80 |
+
};
|
| 81 |
+
const staleAutoEntry: SessionEntry = {
|
| 82 |
+
sessionId: "session",
|
| 83 |
+
updatedAt: 1,
|
| 84 |
+
providerOverride: probe.fallbackProvider,
|
| 85 |
+
modelOverride: probe.fallbackModel,
|
| 86 |
+
modelOverrideSource: "auto",
|
| 87 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 88 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 89 |
+
};
|
| 90 |
+
const newerAutoEntry: SessionEntry = {
|
| 91 |
+
...staleAutoEntry,
|
| 92 |
+
updatedAt: 2,
|
| 93 |
+
};
|
| 94 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 95 |
+
state.updateSessionEntryMock.mockImplementationOnce(
|
| 96 |
+
async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
|
| 97 |
+
expect(await update(newerAutoEntry)).toBeNull();
|
| 98 |
+
return null;
|
| 99 |
+
},
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 103 |
+
run: {
|
| 104 |
+
provider: probe.provider,
|
| 105 |
+
model: probe.model,
|
| 106 |
+
autoFallbackPrimaryProbe: probe,
|
| 107 |
+
} as FollowupRun["run"],
|
| 108 |
+
provider: probe.provider,
|
| 109 |
+
model: probe.model,
|
| 110 |
+
sessionKey: "main",
|
| 111 |
+
activeSessionStore,
|
| 112 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 113 |
+
storePath: "/tmp/sessions.sqlite",
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
expect(activeSessionStore.main).toBe(newerAutoEntry);
|
| 117 |
+
expect(activeSessionStore.main).toMatchObject({
|
| 118 |
+
providerOverride: probe.fallbackProvider,
|
| 119 |
+
modelOverride: probe.fallbackModel,
|
| 120 |
+
modelOverrideSource: "auto",
|
| 121 |
+
updatedAt: 2,
|
| 122 |
+
});
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("keeps a newer local selection installed while persistence is pending", async () => {
|
| 126 |
+
const probe = {
|
| 127 |
+
provider: "anthropic",
|
| 128 |
+
model: "claude-sonnet-4-6",
|
| 129 |
+
fallbackProvider: "openai",
|
| 130 |
+
fallbackModel: "gpt-5.4",
|
| 131 |
+
};
|
| 132 |
+
const staleAutoEntry: SessionEntry = {
|
| 133 |
+
sessionId: "session",
|
| 134 |
+
updatedAt: 1,
|
| 135 |
+
providerOverride: probe.fallbackProvider,
|
| 136 |
+
modelOverride: probe.fallbackModel,
|
| 137 |
+
modelOverrideSource: "auto",
|
| 138 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 139 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 140 |
+
};
|
| 141 |
+
const newerUserEntry: SessionEntry = {
|
| 142 |
+
sessionId: "session",
|
| 143 |
+
updatedAt: 2,
|
| 144 |
+
providerOverride: "openai",
|
| 145 |
+
modelOverride: "gpt-5.5",
|
| 146 |
+
modelOverrideSource: "user",
|
| 147 |
+
};
|
| 148 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 149 |
+
state.updateSessionEntryMock.mockImplementationOnce(
|
| 150 |
+
async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
|
| 151 |
+
const persistedEntry = { ...staleAutoEntry };
|
| 152 |
+
const patch = await update(persistedEntry);
|
| 153 |
+
activeSessionStore.main = newerUserEntry;
|
| 154 |
+
return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
|
| 155 |
+
},
|
| 156 |
+
);
|
| 157 |
+
|
| 158 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 159 |
+
run: {
|
| 160 |
+
provider: probe.provider,
|
| 161 |
+
model: probe.model,
|
| 162 |
+
autoFallbackPrimaryProbe: probe,
|
| 163 |
+
} as FollowupRun["run"],
|
| 164 |
+
provider: probe.provider,
|
| 165 |
+
model: probe.model,
|
| 166 |
+
sessionKey: "main",
|
| 167 |
+
activeSessionStore,
|
| 168 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 169 |
+
storePath: "/tmp/sessions.sqlite",
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
expect(activeSessionStore.main).toBe(newerUserEntry);
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
it("preserves an in-place auth selection while applying persisted probe cleanup", async () => {
|
| 176 |
+
const probe = {
|
| 177 |
+
provider: "anthropic",
|
| 178 |
+
model: "claude-sonnet-4-6",
|
| 179 |
+
fallbackProvider: "openai",
|
| 180 |
+
fallbackModel: "gpt-5.4",
|
| 181 |
+
};
|
| 182 |
+
const staleAutoEntry: SessionEntry = {
|
| 183 |
+
sessionId: "session",
|
| 184 |
+
updatedAt: 1,
|
| 185 |
+
providerOverride: probe.fallbackProvider,
|
| 186 |
+
modelOverride: probe.fallbackModel,
|
| 187 |
+
modelOverrideSource: "auto",
|
| 188 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 189 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 190 |
+
authProfileOverride: "openai:fallback",
|
| 191 |
+
authProfileOverrideSource: "auto",
|
| 192 |
+
};
|
| 193 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 194 |
+
state.updateSessionEntryMock.mockImplementationOnce(
|
| 195 |
+
async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
|
| 196 |
+
const persistedEntry = { ...staleAutoEntry };
|
| 197 |
+
const patch = await update(persistedEntry);
|
| 198 |
+
Object.assign(staleAutoEntry, {
|
| 199 |
+
authProfileOverrideSource: "user",
|
| 200 |
+
updatedAt: 3,
|
| 201 |
+
});
|
| 202 |
+
return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
|
| 203 |
+
},
|
| 204 |
+
);
|
| 205 |
+
|
| 206 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 207 |
+
run: {
|
| 208 |
+
provider: probe.provider,
|
| 209 |
+
model: probe.model,
|
| 210 |
+
autoFallbackPrimaryProbe: probe,
|
| 211 |
+
} as FollowupRun["run"],
|
| 212 |
+
provider: probe.provider,
|
| 213 |
+
model: probe.model,
|
| 214 |
+
sessionKey: "main",
|
| 215 |
+
activeSessionStore,
|
| 216 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 217 |
+
storePath: "/tmp/sessions.sqlite",
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
expect(activeSessionStore.main).not.toBe(staleAutoEntry);
|
| 221 |
+
expect(activeSessionStore.main).toMatchObject({
|
| 222 |
+
authProfileOverride: "openai:fallback",
|
| 223 |
+
authProfileOverrideSource: "user",
|
| 224 |
+
providerOverride: probe.fallbackProvider,
|
| 225 |
+
modelOverride: probe.fallbackModel,
|
| 226 |
+
});
|
| 227 |
+
expect(activeSessionStore.main.updatedAt).toBeGreaterThanOrEqual(3);
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
it("preserves a value-identical same-session cache replacement", async () => {
|
| 231 |
+
const probe = {
|
| 232 |
+
provider: "anthropic",
|
| 233 |
+
model: "claude-sonnet-4-6",
|
| 234 |
+
fallbackProvider: "openai",
|
| 235 |
+
fallbackModel: "gpt-5.4",
|
| 236 |
+
};
|
| 237 |
+
const staleAutoEntry: SessionEntry = {
|
| 238 |
+
sessionId: "session",
|
| 239 |
+
updatedAt: 1,
|
| 240 |
+
providerOverride: probe.fallbackProvider,
|
| 241 |
+
modelOverride: probe.fallbackModel,
|
| 242 |
+
modelOverrideSource: "auto",
|
| 243 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 244 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 245 |
+
authProfileOverride: "openai:fallback",
|
| 246 |
+
authProfileOverrideSource: "auto",
|
| 247 |
+
};
|
| 248 |
+
const replacementEntry: SessionEntry = {
|
| 249 |
+
...staleAutoEntry,
|
| 250 |
+
updatedAt: 3,
|
| 251 |
+
};
|
| 252 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 253 |
+
state.updateSessionEntryMock.mockImplementationOnce(
|
| 254 |
+
async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
|
| 255 |
+
const persistedEntry = { ...staleAutoEntry };
|
| 256 |
+
const patch = await update(persistedEntry);
|
| 257 |
+
activeSessionStore.main = replacementEntry;
|
| 258 |
+
return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
|
| 259 |
+
},
|
| 260 |
+
);
|
| 261 |
+
|
| 262 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 263 |
+
run: {
|
| 264 |
+
provider: probe.provider,
|
| 265 |
+
model: probe.model,
|
| 266 |
+
autoFallbackPrimaryProbe: probe,
|
| 267 |
+
} as FollowupRun["run"],
|
| 268 |
+
provider: probe.provider,
|
| 269 |
+
model: probe.model,
|
| 270 |
+
sessionKey: "main",
|
| 271 |
+
activeSessionStore,
|
| 272 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 273 |
+
storePath: "/tmp/sessions.sqlite",
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
expect(activeSessionStore.main).toBe(replacementEntry);
|
| 277 |
+
expect(activeSessionStore.main).toMatchObject({
|
| 278 |
+
authProfileOverride: "openai:fallback",
|
| 279 |
+
authProfileOverrideSource: "auto",
|
| 280 |
+
providerOverride: probe.fallbackProvider,
|
| 281 |
+
modelOverride: probe.fallbackModel,
|
| 282 |
+
updatedAt: 3,
|
| 283 |
+
});
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
it("preserves a same-session cache replacement when persistence has no row", async () => {
|
| 287 |
+
const probe = {
|
| 288 |
+
provider: "anthropic",
|
| 289 |
+
model: "claude-sonnet-4-6",
|
| 290 |
+
fallbackProvider: "openai",
|
| 291 |
+
fallbackModel: "gpt-5.4",
|
| 292 |
+
};
|
| 293 |
+
const staleAutoEntry: SessionEntry = {
|
| 294 |
+
sessionId: "session",
|
| 295 |
+
updatedAt: 1,
|
| 296 |
+
providerOverride: probe.fallbackProvider,
|
| 297 |
+
modelOverride: probe.fallbackModel,
|
| 298 |
+
modelOverrideSource: "auto",
|
| 299 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 300 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 301 |
+
};
|
| 302 |
+
const replacementEntry: SessionEntry = {
|
| 303 |
+
...staleAutoEntry,
|
| 304 |
+
updatedAt: 2,
|
| 305 |
+
};
|
| 306 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 307 |
+
state.updateSessionEntryMock.mockImplementationOnce(async () => {
|
| 308 |
+
activeSessionStore.main = replacementEntry;
|
| 309 |
+
return undefined;
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 313 |
+
run: {
|
| 314 |
+
provider: probe.provider,
|
| 315 |
+
model: probe.model,
|
| 316 |
+
autoFallbackPrimaryProbe: probe,
|
| 317 |
+
} as FollowupRun["run"],
|
| 318 |
+
provider: probe.provider,
|
| 319 |
+
model: probe.model,
|
| 320 |
+
sessionKey: "main",
|
| 321 |
+
activeSessionStore,
|
| 322 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 323 |
+
storePath: "/tmp/sessions.sqlite",
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
expect(activeSessionStore.main).toBe(replacementEntry);
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
it("preserves an in-place cache update when persistence has no row", async () => {
|
| 330 |
+
const probe = {
|
| 331 |
+
provider: "anthropic",
|
| 332 |
+
model: "claude-sonnet-4-6",
|
| 333 |
+
fallbackProvider: "openai",
|
| 334 |
+
fallbackModel: "gpt-5.4",
|
| 335 |
+
};
|
| 336 |
+
const staleAutoEntry: SessionEntry = {
|
| 337 |
+
sessionId: "session",
|
| 338 |
+
updatedAt: 1,
|
| 339 |
+
providerOverride: probe.fallbackProvider,
|
| 340 |
+
modelOverride: probe.fallbackModel,
|
| 341 |
+
modelOverrideSource: "auto",
|
| 342 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 343 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 344 |
+
authProfileOverride: "openai:fallback",
|
| 345 |
+
authProfileOverrideSource: "auto",
|
| 346 |
+
};
|
| 347 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 348 |
+
state.updateSessionEntryMock.mockImplementationOnce(async () => {
|
| 349 |
+
Object.assign(staleAutoEntry, {
|
| 350 |
+
authProfileOverrideSource: "user",
|
| 351 |
+
updatedAt: 2,
|
| 352 |
+
});
|
| 353 |
+
return undefined;
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 357 |
+
run: {
|
| 358 |
+
provider: probe.provider,
|
| 359 |
+
model: probe.model,
|
| 360 |
+
autoFallbackPrimaryProbe: probe,
|
| 361 |
+
} as FollowupRun["run"],
|
| 362 |
+
provider: probe.provider,
|
| 363 |
+
model: probe.model,
|
| 364 |
+
sessionKey: "main",
|
| 365 |
+
activeSessionStore,
|
| 366 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 367 |
+
storePath: "/tmp/sessions.sqlite",
|
| 368 |
+
});
|
| 369 |
+
|
| 370 |
+
expect(activeSessionStore.main).toBe(staleAutoEntry);
|
| 371 |
+
expect(activeSessionStore.main).toMatchObject({
|
| 372 |
+
authProfileOverride: "openai:fallback",
|
| 373 |
+
authProfileOverrideSource: "user",
|
| 374 |
+
updatedAt: 2,
|
| 375 |
+
});
|
| 376 |
+
});
|
| 377 |
+
|
| 378 |
+
it("preserves an in-place nested cache update", async () => {
|
| 379 |
+
const probe = {
|
| 380 |
+
provider: "anthropic",
|
| 381 |
+
model: "claude-sonnet-4-6",
|
| 382 |
+
fallbackProvider: "openai",
|
| 383 |
+
fallbackModel: "gpt-5.4",
|
| 384 |
+
};
|
| 385 |
+
const staleAutoEntry: SessionEntry = {
|
| 386 |
+
sessionId: "session",
|
| 387 |
+
updatedAt: 1,
|
| 388 |
+
providerOverride: probe.fallbackProvider,
|
| 389 |
+
modelOverride: probe.fallbackModel,
|
| 390 |
+
modelOverrideSource: "auto",
|
| 391 |
+
modelOverrideFallbackOriginProvider: probe.provider,
|
| 392 |
+
modelOverrideFallbackOriginModel: probe.model,
|
| 393 |
+
cliSessionBindings: {
|
| 394 |
+
codex: { sessionId: "old-cli-session" },
|
| 395 |
+
},
|
| 396 |
+
};
|
| 397 |
+
const activeSessionStore = { main: staleAutoEntry };
|
| 398 |
+
state.updateSessionEntryMock.mockImplementationOnce(
|
| 399 |
+
async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
|
| 400 |
+
const persistedEntry = structuredClone(staleAutoEntry);
|
| 401 |
+
const patch = await update(persistedEntry);
|
| 402 |
+
staleAutoEntry.cliSessionBindings!.codex!.sessionId = "new-cli-session";
|
| 403 |
+
staleAutoEntry.updatedAt = 2;
|
| 404 |
+
return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
|
| 405 |
+
},
|
| 406 |
+
);
|
| 407 |
+
|
| 408 |
+
await clearRecoveredAutoFallbackPrimaryProbeSelection({
|
| 409 |
+
run: {
|
| 410 |
+
provider: probe.provider,
|
| 411 |
+
model: probe.model,
|
| 412 |
+
autoFallbackPrimaryProbe: probe,
|
| 413 |
+
} as FollowupRun["run"],
|
| 414 |
+
provider: probe.provider,
|
| 415 |
+
model: probe.model,
|
| 416 |
+
sessionKey: "main",
|
| 417 |
+
activeSessionStore,
|
| 418 |
+
getActiveSessionEntry: () => staleAutoEntry,
|
| 419 |
+
storePath: "/tmp/sessions.sqlite",
|
| 420 |
+
});
|
| 421 |
+
|
| 422 |
+
expect(activeSessionStore.main).not.toBe(staleAutoEntry);
|
| 423 |
+
expect(activeSessionStore.main.cliSessionBindings?.codex?.sessionId).toBe("new-cli-session");
|
| 424 |
+
expect(activeSessionStore.main.updatedAt).toBeGreaterThanOrEqual(2);
|
| 425 |
+
expect(activeSessionStore.main.modelOverride).toBeUndefined();
|
| 426 |
+
});
|
| 427 |
+
});
|
src/auto-reply/reply/agent-runner-auto-fallback.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { isDeepStrictEqual } from "node:util";
|
| 2 |
+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
| 3 |
+
import {
|
| 4 |
+
clearAutoFallbackPrimaryProbeSelection,
|
| 5 |
+
entryMatchesAutoFallbackPrimaryProbe,
|
| 6 |
+
hasSessionAutoModelFallbackProvenance,
|
| 7 |
+
resolveAutoFallbackPrimaryProbe,
|
| 8 |
+
} from "../../agents/agent-scope.js";
|
| 9 |
+
import { resolvePersistedOverrideModelRef } from "../../agents/model-selection.js";
|
| 10 |
+
import type { SessionEntry } from "../../config/sessions.js";
|
| 11 |
+
import {
|
| 12 |
+
resolveCollapsedSessionAuthPinSource,
|
| 13 |
+
resolveSessionAuthProfileOverrideSource,
|
| 14 |
+
} from "../../config/sessions/auth-profile-override-provenance.js";
|
| 15 |
+
import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js";
|
| 16 |
+
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
|
| 17 |
+
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
|
| 18 |
+
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
|
| 19 |
+
import type { FollowupRun } from "./queue.js";
|
| 20 |
+
|
| 21 |
+
function sessionEntryMatchesSnapshot(entry: SessionEntry, snapshot: SessionEntry): boolean {
|
| 22 |
+
return isDeepStrictEqual(entry, snapshot);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function sessionEntryOnlyUpdatedAtChanged(entry: SessionEntry, snapshot: SessionEntry): boolean {
|
| 26 |
+
if (entry.updatedAt === snapshot.updatedAt) {
|
| 27 |
+
return false;
|
| 28 |
+
}
|
| 29 |
+
const entryWithoutUpdatedAt = { ...entry, updatedAt: snapshot.updatedAt };
|
| 30 |
+
return isDeepStrictEqual(entryWithoutUpdatedAt, snapshot);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/** Decides whether to retry after rechecking auto-fallback primary probe state. */
|
| 34 |
+
export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: {
|
| 35 |
+
run: FollowupRun["run"];
|
| 36 |
+
entry?: SessionEntry;
|
| 37 |
+
sessionKey?: string;
|
| 38 |
+
}): FollowupRun["run"] {
|
| 39 |
+
const probe = params.run.autoFallbackPrimaryProbe;
|
| 40 |
+
if (!probe || !params.sessionKey || !params.entry) {
|
| 41 |
+
return params.run;
|
| 42 |
+
}
|
| 43 |
+
const resolveEntrySelectionRun = (): FollowupRun["run"] => {
|
| 44 |
+
const entryRef = resolvePersistedOverrideModelRef({
|
| 45 |
+
defaultProvider: params.run.provider,
|
| 46 |
+
overrideProvider: params.entry?.providerOverride,
|
| 47 |
+
overrideModel: params.entry?.modelOverride,
|
| 48 |
+
});
|
| 49 |
+
const hasEntryModelOverride = Boolean(entryRef);
|
| 50 |
+
const authProfileId = normalizeOptionalString(params.entry?.authProfileOverride);
|
| 51 |
+
const fallbackRun: FollowupRun["run"] = {
|
| 52 |
+
...params.run,
|
| 53 |
+
provider: entryRef?.provider ?? params.run.provider,
|
| 54 |
+
model: entryRef?.model ?? params.run.model,
|
| 55 |
+
requestedRouteResolution: entryRef
|
| 56 |
+
? resolveSessionModelOverrideRouteResolution(params.entry)
|
| 57 |
+
: params.run.requestedRouteResolution,
|
| 58 |
+
autoFallbackPrimaryProbe: undefined,
|
| 59 |
+
};
|
| 60 |
+
if (hasEntryModelOverride) {
|
| 61 |
+
fallbackRun.hasSessionModelOverride = true;
|
| 62 |
+
fallbackRun.hasAutoFallbackProvenance =
|
| 63 |
+
hasSessionAutoModelFallbackProvenance(params.entry) || undefined;
|
| 64 |
+
} else {
|
| 65 |
+
delete fallbackRun.hasSessionModelOverride;
|
| 66 |
+
delete fallbackRun.hasAutoFallbackProvenance;
|
| 67 |
+
}
|
| 68 |
+
const modelOverrideSource = params.entry?.modelOverrideSource;
|
| 69 |
+
if (hasEntryModelOverride && modelOverrideSource && modelOverrideSource !== "default") {
|
| 70 |
+
fallbackRun.modelOverrideSource = modelOverrideSource;
|
| 71 |
+
} else {
|
| 72 |
+
delete fallbackRun.modelOverrideSource;
|
| 73 |
+
}
|
| 74 |
+
if (hasEntryModelOverride && authProfileId) {
|
| 75 |
+
fallbackRun.authProfileId = authProfileId;
|
| 76 |
+
const authProfileIdSource = resolveCollapsedSessionAuthPinSource(params.entry);
|
| 77 |
+
if (authProfileIdSource) {
|
| 78 |
+
fallbackRun.authProfileIdSource = authProfileIdSource;
|
| 79 |
+
} else {
|
| 80 |
+
delete fallbackRun.authProfileIdSource;
|
| 81 |
+
}
|
| 82 |
+
} else if (hasEntryModelOverride) {
|
| 83 |
+
delete fallbackRun.authProfileId;
|
| 84 |
+
delete fallbackRun.authProfileIdSource;
|
| 85 |
+
}
|
| 86 |
+
return fallbackRun;
|
| 87 |
+
};
|
| 88 |
+
const refreshedProbe = resolveAutoFallbackPrimaryProbe({
|
| 89 |
+
entry: params.entry,
|
| 90 |
+
sessionKey: params.sessionKey,
|
| 91 |
+
primaryProvider: probe.provider,
|
| 92 |
+
primaryModel: probe.model,
|
| 93 |
+
});
|
| 94 |
+
if (!refreshedProbe) {
|
| 95 |
+
return resolveEntrySelectionRun();
|
| 96 |
+
}
|
| 97 |
+
return {
|
| 98 |
+
...params.run,
|
| 99 |
+
provider: refreshedProbe.provider,
|
| 100 |
+
model: refreshedProbe.model,
|
| 101 |
+
requestedRouteResolution: "resolved",
|
| 102 |
+
autoFallbackPrimaryProbe: refreshedProbe,
|
| 103 |
+
};
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/** Clears a recovered primary probe without overwriting a newer session selection. */
|
| 107 |
+
export async function clearRecoveredAutoFallbackPrimaryProbeSelection(params: {
|
| 108 |
+
run: FollowupRun["run"];
|
| 109 |
+
provider: string;
|
| 110 |
+
model: string;
|
| 111 |
+
sessionKey?: string;
|
| 112 |
+
activeSessionStore?: Record<string, SessionEntry>;
|
| 113 |
+
getActiveSessionEntry: () => SessionEntry | undefined;
|
| 114 |
+
storePath?: string;
|
| 115 |
+
}): Promise<void> {
|
| 116 |
+
if (shouldPreserveUserFacingSessionStateForInputProvenance(params.run.inputProvenance)) {
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
const probe = params.run.autoFallbackPrimaryProbe;
|
| 120 |
+
if (!probe || params.provider !== probe.provider || params.model !== probe.model) {
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
if (!params.sessionKey || !params.activeSessionStore) {
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
const cachedSessionEntry = params.activeSessionStore[params.sessionKey];
|
| 127 |
+
const activeSessionEntry = cachedSessionEntry ?? params.getActiveSessionEntry();
|
| 128 |
+
if (!activeSessionEntry || !entryMatchesAutoFallbackPrimaryProbe(activeSessionEntry, probe)) {
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
const activeSessionEntryBeforeUpdate = structuredClone(activeSessionEntry);
|
| 132 |
+
if (!params.storePath) {
|
| 133 |
+
clearAutoFallbackPrimaryProbeSelection(activeSessionEntry);
|
| 134 |
+
params.activeSessionStore[params.sessionKey] = activeSessionEntry;
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
let comparedEntry: SessionEntry | undefined;
|
| 138 |
+
const updatedEntry = await updateSessionEntry(
|
| 139 |
+
{ storePath: params.storePath, sessionKey: params.sessionKey },
|
| 140 |
+
(persistedEntry) => {
|
| 141 |
+
comparedEntry = persistedEntry;
|
| 142 |
+
if (
|
| 143 |
+
persistedEntry.sessionId !== activeSessionEntryBeforeUpdate.sessionId ||
|
| 144 |
+
persistedEntry.updatedAt !== activeSessionEntryBeforeUpdate.updatedAt ||
|
| 145 |
+
!entryMatchesAutoFallbackPrimaryProbe(persistedEntry, probe)
|
| 146 |
+
) {
|
| 147 |
+
return null;
|
| 148 |
+
}
|
| 149 |
+
const shouldClearAuthProfile =
|
| 150 |
+
resolveSessionAuthProfileOverrideSource(persistedEntry) === "auto";
|
| 151 |
+
clearAutoFallbackPrimaryProbeSelection(persistedEntry);
|
| 152 |
+
return {
|
| 153 |
+
providerOverride: undefined,
|
| 154 |
+
modelOverride: undefined,
|
| 155 |
+
modelOverrideSource: undefined,
|
| 156 |
+
modelOverrideRouteResolution: undefined,
|
| 157 |
+
modelOverrideFallbackOriginProvider: undefined,
|
| 158 |
+
modelOverrideFallbackOriginModel: undefined,
|
| 159 |
+
...(shouldClearAuthProfile
|
| 160 |
+
? {
|
| 161 |
+
authProfileOverride: undefined,
|
| 162 |
+
authProfileOverrideSource: undefined,
|
| 163 |
+
authProfileOverrideCompactionCount: undefined,
|
| 164 |
+
}
|
| 165 |
+
: {}),
|
| 166 |
+
fallbackNotice: undefined,
|
| 167 |
+
updatedAt: persistedEntry.updatedAt,
|
| 168 |
+
};
|
| 169 |
+
},
|
| 170 |
+
);
|
| 171 |
+
// The persisted comparison owns selection freshness. Publish its updated
|
| 172 |
+
// result, or refresh the cache from the entry that rejected this probe.
|
| 173 |
+
const authoritativeEntry = updatedEntry ?? comparedEntry;
|
| 174 |
+
const currentCachedEntry = params.activeSessionStore[params.sessionKey];
|
| 175 |
+
// Object replacement is a new cache generation even when values match.
|
| 176 |
+
// Preserve it to avoid clearing an identically reselected fallback.
|
| 177 |
+
if (currentCachedEntry !== cachedSessionEntry) {
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
const currentEntry = currentCachedEntry ?? (cachedSessionEntry ? undefined : activeSessionEntry);
|
| 181 |
+
if (!currentEntry) {
|
| 182 |
+
return;
|
| 183 |
+
}
|
| 184 |
+
if (authoritativeEntry) {
|
| 185 |
+
if (sessionEntryMatchesSnapshot(currentEntry, activeSessionEntryBeforeUpdate)) {
|
| 186 |
+
params.activeSessionStore[params.sessionKey] = authoritativeEntry;
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
if (
|
| 190 |
+
currentEntry.sessionId !== activeSessionEntryBeforeUpdate.sessionId ||
|
| 191 |
+
sessionEntryOnlyUpdatedAtChanged(currentEntry, activeSessionEntryBeforeUpdate)
|
| 192 |
+
) {
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
params.activeSessionStore[params.sessionKey] = mergeSessionSnapshotChanges({
|
| 196 |
+
initial: activeSessionEntryBeforeUpdate,
|
| 197 |
+
next: authoritativeEntry,
|
| 198 |
+
current: currentEntry,
|
| 199 |
+
});
|
| 200 |
+
} else if (sessionEntryMatchesSnapshot(currentEntry, activeSessionEntryBeforeUpdate)) {
|
| 201 |
+
delete params.activeSessionStore[params.sessionKey];
|
| 202 |
+
}
|
| 203 |
+
}
|
src/auto-reply/reply/agent-runner-cli-candidate.ts
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
|
| 2 |
+
import {
|
| 3 |
+
cliBackendAcceptsAuthProfileForwarding,
|
| 4 |
+
resolveCliExecutionAuthProfileId,
|
| 5 |
+
} from "../../agents/cli-execution-auth.js";
|
| 6 |
+
import { buildCliMcpDelegationCapabilityBinding } from "../../agents/cli-runner/mcp-grant-context.js";
|
| 7 |
+
import {
|
| 8 |
+
clearCliSessionInStore,
|
| 9 |
+
persistCliSessionBindingResult,
|
| 10 |
+
settleCliSessionResult,
|
| 11 |
+
} from "../../agents/cli-session-store.js";
|
| 12 |
+
import {
|
| 13 |
+
getCliSessionBinding,
|
| 14 |
+
shouldClearFailedCliSessionBinding,
|
| 15 |
+
} from "../../agents/cli-session.js";
|
| 16 |
+
import { resolveDelegationCapability } from "../../agents/delegation-capability.js";
|
| 17 |
+
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js";
|
| 18 |
+
import { findModelInCatalog } from "../../agents/model-catalog-lookup.js";
|
| 19 |
+
import type { ModelFallbackResultClassification } from "../../agents/model-fallback-attempt.js";
|
| 20 |
+
import { createAgentRunSupersededAbortError } from "../../agents/run-termination.js";
|
| 21 |
+
import { withLocalSessionPlacementTurnSettlement } from "../../agents/session-placement-admission.js";
|
| 22 |
+
import { normalizeChatType } from "../../channels/chat-type.js";
|
| 23 |
+
import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
|
| 24 |
+
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
|
| 25 |
+
import {
|
| 26 |
+
getGeneratedMediaTaskIdsForSessionKey,
|
| 27 |
+
hasNewGeneratedMediaTaskForSessionKey,
|
| 28 |
+
} from "../../tasks/task-status-access.js";
|
| 29 |
+
import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
|
| 30 |
+
import { resolveRunAuthProfile } from "./agent-runner-auth-profile.js";
|
| 31 |
+
import {
|
| 32 |
+
createCliReasoningStreamBridge,
|
| 33 |
+
createCliToolSummaryTracker,
|
| 34 |
+
keepCliSessionBindingOnlyWhenReused,
|
| 35 |
+
runCliAgentWithLifecycle,
|
| 36 |
+
} from "./agent-runner-cli-dispatch.js";
|
| 37 |
+
import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js";
|
| 38 |
+
import type { AgentFallbackCandidateCommonParams } from "./agent-runner-fallback-cycle.types.js";
|
| 39 |
+
import { resolveRunModelHasVision } from "./agent-runner-run-params.js";
|
| 40 |
+
import { shouldBridgeCliPreambleEvents } from "./get-reply.types.js";
|
| 41 |
+
import { hasInboundAudio } from "./inbound-media.js";
|
| 42 |
+
import { resolveOriginMessageProvider } from "./origin-routing.js";
|
| 43 |
+
import { resolveReplyOperationTerminationFields } from "./reply-operation-abort.js";
|
| 44 |
+
|
| 45 |
+
export async function runCliFallbackCandidate(
|
| 46 |
+
params: AgentFallbackCandidateCommonParams & {
|
| 47 |
+
cliExecutionProvider: string;
|
| 48 |
+
classifyResult: (result: EmbeddedAgentRunResult) => ModelFallbackResultClassification;
|
| 49 |
+
lifecycleGeneration: string;
|
| 50 |
+
},
|
| 51 |
+
): Promise<{
|
| 52 |
+
result: Awaited<ReturnType<typeof runCliAgentWithLifecycle>>;
|
| 53 |
+
bootstrapPromptWarningSignaturesSeen: string[];
|
| 54 |
+
}> {
|
| 55 |
+
const turn = params.turn;
|
| 56 |
+
const expectedLifecycleRevision = turn.getActiveSessionEntry()?.lifecycleRevision;
|
| 57 |
+
const selectedModelEntry = findModelInCatalog(
|
| 58 |
+
params.candidateRun.thinkingCatalog ?? [],
|
| 59 |
+
params.provider,
|
| 60 |
+
params.model,
|
| 61 |
+
);
|
| 62 |
+
const modelHasVision = await resolveRunModelHasVision({
|
| 63 |
+
run: params.candidateRun,
|
| 64 |
+
provider: params.provider,
|
| 65 |
+
model: params.model,
|
| 66 |
+
});
|
| 67 |
+
const sessionKey = turn.sessionKey ?? turn.followupRun.run.sessionKey;
|
| 68 |
+
const sessionTarget =
|
| 69 |
+
sessionKey && turn.storePath
|
| 70 |
+
? {
|
| 71 |
+
agentId: turn.followupRun.run.agentId,
|
| 72 |
+
sessionId: turn.followupRun.run.sessionId,
|
| 73 |
+
sessionKey,
|
| 74 |
+
storePath: turn.storePath,
|
| 75 |
+
}
|
| 76 |
+
: undefined;
|
| 77 |
+
const cliLifecycleStartedAt = Date.now();
|
| 78 |
+
const lifecycleBackstop = createAgentLifecycleTerminalBackstop({
|
| 79 |
+
runId: params.runId,
|
| 80 |
+
sessionKey: turn.sessionKey,
|
| 81 |
+
startedAt: cliLifecycleStartedAt,
|
| 82 |
+
getLifecycleGeneration: () => params.lifecycleGeneration,
|
| 83 |
+
resolveTerminationFields: (error) =>
|
| 84 |
+
resolveReplyOperationTerminationFields(error, params.runAbortSignal, turn.replyOperation),
|
| 85 |
+
});
|
| 86 |
+
params.onLifecycleBackstop(lifecycleBackstop);
|
| 87 |
+
const allowCliAuthProfileForwarding = cliBackendAcceptsAuthProfileForwarding({
|
| 88 |
+
provider: params.cliExecutionProvider,
|
| 89 |
+
config: params.runtimeConfig,
|
| 90 |
+
agentId: params.candidateRun.agentId,
|
| 91 |
+
});
|
| 92 |
+
const hookMessageProvider = resolveOriginMessageProvider({
|
| 93 |
+
originatingChannel: turn.followupRun.originatingChannel,
|
| 94 |
+
provider: turn.sessionCtx.Provider,
|
| 95 |
+
});
|
| 96 |
+
const cliCurrentThreadId =
|
| 97 |
+
turn.followupRun.originatingThreadId ?? turn.sessionCtx.MessageThreadId;
|
| 98 |
+
const isRestartSentinelContinuation =
|
| 99 |
+
turn.sessionCtx.InputProvenance?.kind === "internal_system" &&
|
| 100 |
+
turn.sessionCtx.InputProvenance.sourceTool === "restart-sentinel";
|
| 101 |
+
const cliCurrentMessageId = isRestartSentinelContinuation
|
| 102 |
+
? turn.sessionCtx.ReplyToId
|
| 103 |
+
: (turn.sessionCtx.MessageSidFull ?? turn.sessionCtx.MessageSid);
|
| 104 |
+
const commandDetailsVisible = turn.resolvedVerboseLevel === "full";
|
| 105 |
+
const cliToolSummaryTracker = createCliToolSummaryTracker({
|
| 106 |
+
detailMode: turn.toolProgressDetail,
|
| 107 |
+
commandDetailsVisible,
|
| 108 |
+
shouldEmitToolResult: turn.shouldEmitToolResult,
|
| 109 |
+
shouldEmitToolOutput: turn.shouldEmitToolOutput,
|
| 110 |
+
deliver: async (payload) => {
|
| 111 |
+
await turn.opts?.onToolResult?.(payload);
|
| 112 |
+
},
|
| 113 |
+
});
|
| 114 |
+
// CLI backends report a tool's outcome on the result event and never repeat it,
|
| 115 |
+
// so the terminal fact has to be projected here. The embedded path gets this
|
| 116 |
+
// from the shared agent-event handler; without it a failed CLI command renders
|
| 117 |
+
// exactly like one that succeeded.
|
| 118 |
+
const deliverCliCommandOutcome = async (
|
| 119 |
+
payload: {
|
| 120 |
+
name: string | undefined;
|
| 121 |
+
phase: "start" | "update" | "result";
|
| 122 |
+
args: Record<string, unknown> | undefined;
|
| 123 |
+
toolCallId?: string;
|
| 124 |
+
isError?: boolean;
|
| 125 |
+
result?: unknown;
|
| 126 |
+
},
|
| 127 |
+
commandBearing: boolean,
|
| 128 |
+
) => {
|
| 129 |
+
const onCommandOutput = turn.opts?.onCommandOutput;
|
| 130 |
+
if (!onCommandOutput) {
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
const commandOutput = buildCommandOutputFromToolResultEvent({
|
| 134 |
+
stream: "tool",
|
| 135 |
+
data: { ...payload, commandBearing },
|
| 136 |
+
});
|
| 137 |
+
if (commandOutput) {
|
| 138 |
+
await onCommandOutput(commandOutput);
|
| 139 |
+
}
|
| 140 |
+
};
|
| 141 |
+
const bridgeCliPreambleProgress =
|
| 142 |
+
Boolean(turn.opts?.onItemEvent) && shouldBridgeCliPreambleEvents(turn.opts);
|
| 143 |
+
const bridgeCliDurableCommentary =
|
| 144 |
+
Boolean(params.presentation.blockReplyHandler) &&
|
| 145 |
+
(turn.blockStreamingEnabled || turn.opts?.commentaryPayloadsEnabled === true);
|
| 146 |
+
const toolAuthorityRoute = { provider: params.provider, model: params.model };
|
| 147 |
+
const toolAuthorityFingerprint = turn.replyOperation?.bindToolAuthorityRoute(toolAuthorityRoute);
|
| 148 |
+
const result = await params.timing.measure("cli_run", () =>
|
| 149 |
+
withLocalSessionPlacementTurnSettlement(
|
| 150 |
+
{
|
| 151 |
+
sessionId: turn.followupRun.run.sessionId,
|
| 152 |
+
sessionKey,
|
| 153 |
+
agentId: turn.followupRun.run.agentId,
|
| 154 |
+
runId: params.runId,
|
| 155 |
+
},
|
| 156 |
+
async (assertSettlementCurrent) => {
|
| 157 |
+
// Placement admission may wait behind an older turn. Snapshot placement,
|
| 158 |
+
// permission, and native resume identity only after this turn owns it.
|
| 159 |
+
const sessionEntry = sessionTarget
|
| 160 |
+
? loadSessionEntry({ ...sessionTarget, readConsistency: "latest" })
|
| 161 |
+
: turn.getActiveSessionEntry();
|
| 162 |
+
if (
|
| 163 |
+
sessionTarget &&
|
| 164 |
+
(sessionEntry?.sessionId !== sessionTarget.sessionId ||
|
| 165 |
+
sessionEntry.lifecycleRevision !== expectedLifecycleRevision)
|
| 166 |
+
) {
|
| 167 |
+
throw createAgentRunSupersededAbortError();
|
| 168 |
+
}
|
| 169 |
+
const cliSessionBinding = getCliSessionBinding(sessionEntry, params.cliExecutionProvider);
|
| 170 |
+
// The CLI owner must see explicit pins before provider scoping can discard them.
|
| 171 |
+
const authProfileId = allowCliAuthProfileForwarding
|
| 172 |
+
? resolveCliExecutionAuthProfileId({
|
| 173 |
+
cliExecutionProvider: params.cliExecutionProvider,
|
| 174 |
+
authProfileProvider: params.provider,
|
| 175 |
+
config: params.runtimeConfig,
|
| 176 |
+
agentDir: params.candidateRun.agentDir,
|
| 177 |
+
selected: params.candidateRun,
|
| 178 |
+
sessionBinding: cliSessionBinding,
|
| 179 |
+
})
|
| 180 |
+
: resolveRunAuthProfile(params.candidateRun, params.cliExecutionProvider, {
|
| 181 |
+
config: params.runtimeConfig,
|
| 182 |
+
}).authProfileId;
|
| 183 |
+
const diagnosticOwner = params.deferredLifecycle.handoffToCli();
|
| 184 |
+
const mediaTaskIdsBefore = getGeneratedMediaTaskIdsForSessionKey(turn.sessionKey);
|
| 185 |
+
let droppedCliSessionReplacement = false;
|
| 186 |
+
const candidateResult = await runCliAgentWithLifecycle({
|
| 187 |
+
runId: params.runId,
|
| 188 |
+
lifecycleGeneration: params.lifecycleGeneration,
|
| 189 |
+
provider: params.cliExecutionProvider,
|
| 190 |
+
startedAt: cliLifecycleStartedAt,
|
| 191 |
+
emitLifecycleTerminal: false,
|
| 192 |
+
onAgentRunStart: params.notifyAgentRunStart,
|
| 193 |
+
suppressAssistantBridge: turn.followupRun.run.silentExpected,
|
| 194 |
+
onActivity: () => turn.replyOperation?.recordActivity(),
|
| 195 |
+
onErrorBeforeLifecycle:
|
| 196 |
+
params.cliExecutionProvider === "claude-cli" && cliSessionBinding?.sessionId
|
| 197 |
+
? async (error) => {
|
| 198 |
+
if (
|
| 199 |
+
!shouldClearFailedCliSessionBinding({
|
| 200 |
+
error,
|
| 201 |
+
binding: cliSessionBinding,
|
| 202 |
+
hasNewGeneratedMediaTask: hasNewGeneratedMediaTaskForSessionKey(
|
| 203 |
+
turn.sessionKey,
|
| 204 |
+
mediaTaskIdsBefore,
|
| 205 |
+
),
|
| 206 |
+
})
|
| 207 |
+
) {
|
| 208 |
+
return;
|
| 209 |
+
}
|
| 210 |
+
await clearCliSessionInStore({
|
| 211 |
+
provider: params.cliExecutionProvider,
|
| 212 |
+
expectedCliSessionId: cliSessionBinding.sessionId,
|
| 213 |
+
expectedSessionId: sessionEntry?.sessionId,
|
| 214 |
+
assertCommitAllowed: assertSettlementCurrent,
|
| 215 |
+
sessionKey: turn.sessionKey,
|
| 216 |
+
sessionStore: turn.activeSessionStore,
|
| 217 |
+
storePath: turn.storePath,
|
| 218 |
+
activeSessionEntry: sessionEntry,
|
| 219 |
+
});
|
| 220 |
+
}
|
| 221 |
+
: undefined,
|
| 222 |
+
preserveProgressCallbackStartOrder: params.preserveProgressCallbackStartOrder,
|
| 223 |
+
onAssistantText: async (text) => {
|
| 224 |
+
const classified = params.presentation.classifyStreamingPartial({ text });
|
| 225 |
+
if (classified.skip || !classified.text) {
|
| 226 |
+
return;
|
| 227 |
+
}
|
| 228 |
+
const textForTyping = classified.text;
|
| 229 |
+
const sanitized = params.presentation.sanitizeStreamingText(textForTyping, false);
|
| 230 |
+
const onPartialReply = turn.opts?.onPartialReply;
|
| 231 |
+
return await params.presentation.presentWithTyping(
|
| 232 |
+
turn.typingSignals.signalTextDelta(textForTyping),
|
| 233 |
+
() =>
|
| 234 |
+
sanitized.skip || !sanitized.text || !onPartialReply
|
| 235 |
+
? false
|
| 236 |
+
: onPartialReply({ text: sanitized.text }),
|
| 237 |
+
);
|
| 238 |
+
},
|
| 239 |
+
onReasoningText: createCliReasoningStreamBridge(turn.opts?.onReasoningStream),
|
| 240 |
+
onPlanUpdate: turn.opts?.onPlanUpdate,
|
| 241 |
+
onReasoningProgress: async (payload) => {
|
| 242 |
+
await turn.opts?.onReasoningProgress?.(payload);
|
| 243 |
+
},
|
| 244 |
+
onCompactionStart: turn.opts?.onCompactionStart,
|
| 245 |
+
onCompactionEnd: turn.opts?.onCompactionEnd,
|
| 246 |
+
onToolEvent: async (payload) => {
|
| 247 |
+
if (!params.preserveProgressCallbackStartOrder) {
|
| 248 |
+
const commandBearing = await cliToolSummaryTracker.noteToolEvent(payload);
|
| 249 |
+
if (payload.phase === "result") {
|
| 250 |
+
await deliverCliCommandOutcome(payload, commandBearing);
|
| 251 |
+
return;
|
| 252 |
+
}
|
| 253 |
+
const { name, phase, args, toolCallId } = payload;
|
| 254 |
+
await Promise.all([
|
| 255 |
+
turn.typingSignals.signalToolStart(),
|
| 256 |
+
turn.opts?.onToolStart?.({
|
| 257 |
+
...(toolCallId ? { toolCallId } : {}),
|
| 258 |
+
name,
|
| 259 |
+
phase,
|
| 260 |
+
args,
|
| 261 |
+
detailMode: turn.toolProgressDetail,
|
| 262 |
+
}),
|
| 263 |
+
]);
|
| 264 |
+
return;
|
| 265 |
+
}
|
| 266 |
+
const summaryPromise = cliToolSummaryTracker.noteToolEvent(payload);
|
| 267 |
+
if (payload.phase === "result") {
|
| 268 |
+
const commandBearing = await summaryPromise;
|
| 269 |
+
await deliverCliCommandOutcome(payload, commandBearing);
|
| 270 |
+
return;
|
| 271 |
+
}
|
| 272 |
+
const { name, phase, args, toolCallId } = payload;
|
| 273 |
+
// Tool and assistant bridges drain independently. Preserve source order.
|
| 274 |
+
await Promise.all([
|
| 275 |
+
summaryPromise,
|
| 276 |
+
params.presentation.presentWithTyping(
|
| 277 |
+
turn.typingSignals.signalToolStart(),
|
| 278 |
+
async () => {
|
| 279 |
+
await turn.opts?.onToolStart?.({
|
| 280 |
+
...(toolCallId ? { toolCallId } : {}),
|
| 281 |
+
name,
|
| 282 |
+
phase,
|
| 283 |
+
args,
|
| 284 |
+
detailMode: turn.toolProgressDetail,
|
| 285 |
+
});
|
| 286 |
+
},
|
| 287 |
+
),
|
| 288 |
+
]);
|
| 289 |
+
},
|
| 290 |
+
onCommentaryText:
|
| 291 |
+
bridgeCliPreambleProgress || bridgeCliDurableCommentary
|
| 292 |
+
? async (payload) => {
|
| 293 |
+
const deliveries: unknown[] = [];
|
| 294 |
+
if (bridgeCliPreambleProgress) {
|
| 295 |
+
deliveries.push(
|
| 296 |
+
turn.opts?.onItemEvent?.({
|
| 297 |
+
itemId: payload.itemId,
|
| 298 |
+
kind: "preamble",
|
| 299 |
+
progressText: payload.text,
|
| 300 |
+
// The block bridge owns durability; this event remains a progress preview.
|
| 301 |
+
...(bridgeCliDurableCommentary ? { suppressDurableProgress: true } : {}),
|
| 302 |
+
}),
|
| 303 |
+
);
|
| 304 |
+
}
|
| 305 |
+
if (bridgeCliDurableCommentary) {
|
| 306 |
+
// Block mode treats completed CLI text as an ordinary answer block so
|
| 307 |
+
// the existing pipeline owns coalescing and final-payload dedupe.
|
| 308 |
+
deliveries.push(
|
| 309 |
+
params.presentation.blockReplyHandler?.({
|
| 310 |
+
text: payload.text,
|
| 311 |
+
...(turn.blockStreamingEnabled ? {} : { isCommentary: true }),
|
| 312 |
+
}),
|
| 313 |
+
);
|
| 314 |
+
}
|
| 315 |
+
await Promise.all(deliveries);
|
| 316 |
+
}
|
| 317 |
+
: undefined,
|
| 318 |
+
onFastModeAutoProgress: async (payload) => {
|
| 319 |
+
await turn.opts?.onToolResult?.(payload);
|
| 320 |
+
},
|
| 321 |
+
transformResult:
|
| 322 |
+
turn.followupRun.currentInboundEventKind === "room_event"
|
| 323 |
+
? (resultLocal) =>
|
| 324 |
+
keepCliSessionBindingOnlyWhenReused({
|
| 325 |
+
result: resultLocal,
|
| 326 |
+
existingSessionId: cliSessionBinding?.sessionId,
|
| 327 |
+
onDroppedReplacement: () => {
|
| 328 |
+
droppedCliSessionReplacement = true;
|
| 329 |
+
},
|
| 330 |
+
})
|
| 331 |
+
: undefined,
|
| 332 |
+
runParams: {
|
| 333 |
+
preparedRunAdmission: params.preparedRunAdmission,
|
| 334 |
+
messageActionTurnCapability: params.messageActionTurnCapability,
|
| 335 |
+
diagnosticOwner,
|
| 336 |
+
sessionId: turn.followupRun.run.sessionId,
|
| 337 |
+
sessionKey,
|
| 338 |
+
sessionTarget,
|
| 339 |
+
sessionEntry,
|
| 340 |
+
chatType:
|
| 341 |
+
normalizeChatType(turn.followupRun.originatingChatType) ??
|
| 342 |
+
normalizeChatType(turn.sessionCtx.ChatType) ??
|
| 343 |
+
params.candidateRun.chatType,
|
| 344 |
+
runtimePolicySessionKey:
|
| 345 |
+
turn.followupRun.run.runtimePolicySessionKey ?? turn.runtimePolicySessionKey,
|
| 346 |
+
agentId: turn.followupRun.run.agentId,
|
| 347 |
+
trigger: turn.isHeartbeat ? "heartbeat" : "user",
|
| 348 |
+
sessionFile: turn.followupRun.run.sessionFile,
|
| 349 |
+
workspaceDir: turn.followupRun.run.workspaceDir,
|
| 350 |
+
cwd: turn.followupRun.run.cwd,
|
| 351 |
+
config: params.runtimeConfig,
|
| 352 |
+
toolOverrides: turn.followupRun.run.toolOverrides,
|
| 353 |
+
prompt: turn.commandBody,
|
| 354 |
+
transcriptPrompt: turn.transcriptCommandBody,
|
| 355 |
+
media: turn.followupRun.media,
|
| 356 |
+
suppressNextUserMessagePersistence: params.suppressQueuedUserPersistenceForCandidate,
|
| 357 |
+
userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
|
| 358 |
+
contextEngineLogicalTurnLease: params.contextEngineLogicalTurnLease,
|
| 359 |
+
onContextEngineTurnCandidate: params.onContextEngineTurnCandidate,
|
| 360 |
+
onUserMessagePersisted: params.notifyUserMessagePersisted,
|
| 361 |
+
prepareAssistantTranscriptMessage: turn.opts?.prepareAssistantTranscriptMessage,
|
| 362 |
+
persistAssistantTranscript:
|
| 363 |
+
turn.followupRun.currentInboundEventKind !== "room_event" &&
|
| 364 |
+
turn.followupRun.run.suppressTranscriptOnlyAssistantPersistence !== true,
|
| 365 |
+
storePath: turn.storePath,
|
| 366 |
+
currentInboundEventKind: turn.followupRun.currentInboundEventKind,
|
| 367 |
+
currentInboundContext: turn.followupRun.currentInboundContext,
|
| 368 |
+
inputProvenance: turn.followupRun.run.inputProvenance,
|
| 369 |
+
// Candidate zero is the primary attempt; later candidates are
|
| 370 |
+
// fallbacks. Carry the runner-owned fact instead of inferring from
|
| 371 |
+
// this shared dispatch path, or primary CLI runs lose delegation.
|
| 372 |
+
...buildCliMcpDelegationCapabilityBinding(
|
| 373 |
+
resolveDelegationCapability({
|
| 374 |
+
fallbackActive: params.isFallbackRetry,
|
| 375 |
+
inputProvenance: turn.followupRun.run.inputProvenance,
|
| 376 |
+
disableTools: turn.opts?.disableTools,
|
| 377 |
+
toolsAllow: turn.opts?.toolsAllow,
|
| 378 |
+
}),
|
| 379 |
+
),
|
| 380 |
+
modelProvider: params.provider,
|
| 381 |
+
requesterModel: { provider: params.provider, model: params.model },
|
| 382 |
+
modelHasVision,
|
| 383 |
+
modelContextWindow: selectedModelEntry?.contextWindow,
|
| 384 |
+
modelContextTokens: selectedModelEntry?.contextTokens,
|
| 385 |
+
contextWindow: sessionEntry?.contextWindow,
|
| 386 |
+
provider: params.cliExecutionProvider,
|
| 387 |
+
execOverrides: turn.followupRun.run.execOverrides,
|
| 388 |
+
bashElevated: turn.followupRun.run.bashElevated,
|
| 389 |
+
model: params.model,
|
| 390 |
+
thinkLevel: params.candidateThinkLevel,
|
| 391 |
+
fastMode: params.candidateFastMode.fastMode,
|
| 392 |
+
fastModeStartedAtMs: params.fastModeStartedAtMs,
|
| 393 |
+
fastModeAutoOnSeconds: params.candidateFastMode.fastModeAutoOnSeconds,
|
| 394 |
+
fastModeAutoProgressState: params.fastModeAutoProgressState,
|
| 395 |
+
isFinalFallbackAttempt: params.isFinalFallbackAttempt,
|
| 396 |
+
timeoutMs: turn.followupRun.run.timeoutMs,
|
| 397 |
+
runTimeoutOverrideMs: turn.followupRun.run.runTimeoutOverrideMs,
|
| 398 |
+
runId: params.runId,
|
| 399 |
+
lane: params.runLane,
|
| 400 |
+
extraSystemPrompt: turn.followupRun.run.extraSystemPrompt,
|
| 401 |
+
sourceReplyDeliveryMode: turn.followupRun.run.sourceReplyDeliveryMode,
|
| 402 |
+
taskSuggestionDeliveryMode: turn.followupRun.run.taskSuggestionDeliveryMode,
|
| 403 |
+
// Heartbeat ambient routes are never implicit message recipients.
|
| 404 |
+
...(turn.isHeartbeat ? { requireExplicitMessageTarget: true } : {}),
|
| 405 |
+
cleanupBundleMcpOnRunEnd: turn.opts?.cleanupBundleMcpOnRunEnd,
|
| 406 |
+
silentReplyPromptMode: turn.followupRun.run.silentReplyPromptMode,
|
| 407 |
+
allowEmptyAssistantReplyAsSilent: turn.followupRun.run.allowEmptyAssistantReplyAsSilent,
|
| 408 |
+
extraSystemPromptStatic: turn.followupRun.run.extraSystemPromptStatic,
|
| 409 |
+
cliSessionBindingFacts: turn.followupRun.run.cliSessionBindingFacts,
|
| 410 |
+
ownerNumbers: turn.followupRun.run.ownerNumbers,
|
| 411 |
+
cliSessionId: cliSessionBinding?.sessionId,
|
| 412 |
+
cliSessionBinding,
|
| 413 |
+
authProfileId,
|
| 414 |
+
bootstrapContextMode: turn.opts?.bootstrapContextMode,
|
| 415 |
+
bootstrapContextRunKind: params.bootstrapContextRunKind,
|
| 416 |
+
bootstrapPromptWarningSignaturesSeen: params.bootstrapPromptWarningSignaturesSeen,
|
| 417 |
+
bootstrapPromptWarningSignature:
|
| 418 |
+
params.bootstrapPromptWarningSignaturesSeen[
|
| 419 |
+
params.bootstrapPromptWarningSignaturesSeen.length - 1
|
| 420 |
+
],
|
| 421 |
+
images: params.currentTurnImages.images,
|
| 422 |
+
imageOrder: params.currentTurnImages.imageOrder,
|
| 423 |
+
mediaImageLayout: params.currentTurnImages.mediaImageLayout,
|
| 424 |
+
skillsSnapshot: turn.followupRun.run.skillsSnapshot,
|
| 425 |
+
messageChannel: turn.followupRun.originatingChannel ?? undefined,
|
| 426 |
+
messageProvider: hookMessageProvider,
|
| 427 |
+
clientCaps: turn.followupRun.run.clientCaps,
|
| 428 |
+
gatewayUiCommandTarget: turn.followupRun.run.gatewayUiCommandTarget,
|
| 429 |
+
currentChannelId:
|
| 430 |
+
turn.followupRun.originatingTo ?? turn.sessionCtx.OriginatingTo ?? turn.sessionCtx.To,
|
| 431 |
+
senderId: turn.followupRun.run.senderId,
|
| 432 |
+
senderName: turn.followupRun.run.senderName,
|
| 433 |
+
senderUsername: turn.followupRun.run.senderUsername,
|
| 434 |
+
senderE164: turn.followupRun.run.senderE164,
|
| 435 |
+
groupId: turn.followupRun.run.groupId,
|
| 436 |
+
groupChannel: turn.followupRun.run.groupChannel,
|
| 437 |
+
groupSpace: turn.followupRun.run.groupSpace,
|
| 438 |
+
spawnedBy: turn.followupRun.run.spawnedBy,
|
| 439 |
+
chatId: turn.followupRun.originatingChatId,
|
| 440 |
+
channelContext: turn.followupRun.run.channelContext,
|
| 441 |
+
currentThreadTs: cliCurrentThreadId != null ? String(cliCurrentThreadId) : undefined,
|
| 442 |
+
currentMessageId: cliCurrentMessageId,
|
| 443 |
+
replyToMode: turn.followupRun.originatingReplyToMode ?? turn.sessionCtx.ReplyToMode,
|
| 444 |
+
currentInboundAudio: hasInboundAudio(turn.sessionCtx),
|
| 445 |
+
agentAccountId: turn.followupRun.run.agentAccountId,
|
| 446 |
+
senderIsOwner: turn.followupRun.run.senderIsOwner,
|
| 447 |
+
approvalReviewerDeviceId: turn.followupRun.run.approvalReviewerDeviceId,
|
| 448 |
+
toolsAllow: turn.opts?.toolsAllow,
|
| 449 |
+
skillWorkshopProposalRevision: params.candidateRun.skillWorkshopProposalRevision,
|
| 450 |
+
skillLibraryAuthoring: params.candidateRun.skillLibraryAuthoring,
|
| 451 |
+
disableTools: turn.opts?.disableTools,
|
| 452 |
+
toolAuthorityFingerprint,
|
| 453 |
+
abortSignal: params.runAbortSignal,
|
| 454 |
+
// Native input is already host-authored. Keep its stable delivery
|
| 455 |
+
// context out of the model-output normalization wrapper.
|
| 456 |
+
onBlockReply: turn.opts?.onBlockReply,
|
| 457 |
+
onPartialReply: turn.opts?.onPartialReply,
|
| 458 |
+
onExecutionPhase: params.signalExecutionPhaseForTyping,
|
| 459 |
+
replyOperation: turn.replyOperation,
|
| 460 |
+
},
|
| 461 |
+
});
|
| 462 |
+
if (droppedCliSessionReplacement) {
|
| 463 |
+
// The room-event transform removed native continuity; only its guarded
|
| 464 |
+
// invalidation remains, and failure must retain the returned turn.
|
| 465 |
+
return await settleCliSessionResult(candidateResult, async () => {
|
| 466 |
+
await clearCliSessionInStore({
|
| 467 |
+
provider: params.cliExecutionProvider,
|
| 468 |
+
expectedCliSessionId: cliSessionBinding?.sessionId,
|
| 469 |
+
expectedSessionId: sessionEntry?.sessionId,
|
| 470 |
+
assertCommitAllowed: assertSettlementCurrent,
|
| 471 |
+
sessionKey: turn.sessionKey,
|
| 472 |
+
sessionStore: turn.activeSessionStore,
|
| 473 |
+
storePath: turn.storePath,
|
| 474 |
+
activeSessionEntry: sessionEntry,
|
| 475 |
+
});
|
| 476 |
+
params.classifyResult(candidateResult);
|
| 477 |
+
});
|
| 478 |
+
}
|
| 479 |
+
const classification = params.classifyResult(candidateResult);
|
| 480 |
+
if (
|
| 481 |
+
(!classification || candidateResult.meta.agentMeta?.clearCliSessionBinding === true) &&
|
| 482 |
+
!shouldPreserveUserFacingSessionStateForInputProvenance(
|
| 483 |
+
turn.followupRun.run.inputProvenance,
|
| 484 |
+
)
|
| 485 |
+
) {
|
| 486 |
+
return await persistCliSessionBindingResult({
|
| 487 |
+
provider: params.cliExecutionProvider,
|
| 488 |
+
result: candidateResult,
|
| 489 |
+
sessionKey,
|
| 490 |
+
storePath: turn.storePath,
|
| 491 |
+
sessionStore: turn.activeSessionStore,
|
| 492 |
+
expectedSession: sessionEntry,
|
| 493 |
+
assertSettlementCurrent,
|
| 494 |
+
abortSignal: params.runAbortSignal,
|
| 495 |
+
});
|
| 496 |
+
}
|
| 497 |
+
return candidateResult;
|
| 498 |
+
},
|
| 499 |
+
{
|
| 500 |
+
preparedRunAdmission: params.preparedRunAdmission,
|
| 501 |
+
lifecycleGeneration: params.lifecycleGeneration,
|
| 502 |
+
abortSignal: params.runAbortSignal,
|
| 503 |
+
trigger: turn.isHeartbeat ? "heartbeat" : "user",
|
| 504 |
+
inputProvenance: turn.followupRun.run.inputProvenance,
|
| 505 |
+
},
|
| 506 |
+
),
|
| 507 |
+
);
|
| 508 |
+
return {
|
| 509 |
+
result,
|
| 510 |
+
bootstrapPromptWarningSignaturesSeen: resolveBootstrapWarningSignaturesSeen(
|
| 511 |
+
result.meta?.systemPromptReport,
|
| 512 |
+
),
|
| 513 |
+
};
|
| 514 |
+
}
|
src/auto-reply/reply/agent-runner-cli-dispatch.test.ts
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tests CLI dispatch arguments and runtime selection for agent runner turns.
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 4 |
+
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
| 5 |
+
import { withTestAdmittedRunContext } from "../../agents/admitted-run-context.test-support.js";
|
| 6 |
+
import { createCliTimeoutError } from "../../agents/cli-runner/no-output-timeout-policy.js";
|
| 7 |
+
import { clearCliSessionInStore } from "../../agents/cli-session-store.js";
|
| 8 |
+
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js";
|
| 9 |
+
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
|
| 10 |
+
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
|
| 11 |
+
import {
|
| 12 |
+
emitAgentEvent,
|
| 13 |
+
getAgentEventLifecycleGeneration,
|
| 14 |
+
onAgentEvent,
|
| 15 |
+
resetAgentEventsForTest,
|
| 16 |
+
} from "../../infra/agent-events.js";
|
| 17 |
+
import {
|
| 18 |
+
createCliToolSummaryTracker,
|
| 19 |
+
keepCliSessionBindingOnlyWhenReused,
|
| 20 |
+
runCliAgentWithLifecycle as runCliAgentWithLifecycleProduction,
|
| 21 |
+
} from "./agent-runner-cli-dispatch.js";
|
| 22 |
+
|
| 23 |
+
type ProductionLifecycleParams = Parameters<typeof runCliAgentWithLifecycleProduction>[0];
|
| 24 |
+
type RunCliAgentWithLifecycleParams = Omit<ProductionLifecycleParams, "runParams"> & {
|
| 25 |
+
runParams: Omit<ProductionLifecycleParams["runParams"], "admittedRunContext">;
|
| 26 |
+
};
|
| 27 |
+
const runCliAgentWithLifecycle = (params: RunCliAgentWithLifecycleParams) =>
|
| 28 |
+
runCliAgentWithLifecycleProduction({
|
| 29 |
+
...params,
|
| 30 |
+
runParams: withTestAdmittedRunContext(params.runParams),
|
| 31 |
+
});
|
| 32 |
+
type ReasoningTextPayload = Parameters<
|
| 33 |
+
NonNullable<RunCliAgentWithLifecycleParams["onReasoningText"]>
|
| 34 |
+
>[0];
|
| 35 |
+
type ReasoningProgressPayload = Parameters<
|
| 36 |
+
NonNullable<RunCliAgentWithLifecycleParams["onReasoningProgress"]>
|
| 37 |
+
>[0];
|
| 38 |
+
|
| 39 |
+
const cliDispatchState = vi.hoisted(() => ({
|
| 40 |
+
runCliAgentMock: vi.fn(),
|
| 41 |
+
}));
|
| 42 |
+
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
| 43 |
+
|
| 44 |
+
vi.mock("../../agents/cli-runner.js", () => ({
|
| 45 |
+
runCliAgent: (...args: unknown[]) => cliDispatchState.runCliAgentMock(...args),
|
| 46 |
+
}));
|
| 47 |
+
|
| 48 |
+
afterEach(() => {
|
| 49 |
+
vi.useRealTimers();
|
| 50 |
+
resetAgentEventsForTest();
|
| 51 |
+
cliDispatchState.runCliAgentMock.mockReset();
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
describe("runCliAgentWithLifecycle", () => {
|
| 55 |
+
it("bridges completed CLI compaction lifecycles to reply callbacks", async () => {
|
| 56 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 57 |
+
emitAgentEvent({
|
| 58 |
+
runId: params.runId,
|
| 59 |
+
stream: "compaction",
|
| 60 |
+
data: { phase: "start", backend: "claude-cli" },
|
| 61 |
+
});
|
| 62 |
+
emitAgentEvent({
|
| 63 |
+
runId: params.runId,
|
| 64 |
+
stream: "compaction",
|
| 65 |
+
data: { phase: "end", backend: "claude-cli", completed: false },
|
| 66 |
+
});
|
| 67 |
+
emitAgentEvent({
|
| 68 |
+
runId: params.runId,
|
| 69 |
+
stream: "compaction",
|
| 70 |
+
data: { phase: "start", backend: "claude-cli" },
|
| 71 |
+
});
|
| 72 |
+
emitAgentEvent({
|
| 73 |
+
runId: params.runId,
|
| 74 |
+
stream: "compaction",
|
| 75 |
+
data: { phase: "end", backend: "claude-cli", completed: true },
|
| 76 |
+
});
|
| 77 |
+
return { payloads: [], meta: { durationMs: 1 } };
|
| 78 |
+
});
|
| 79 |
+
const callbacks: string[] = [];
|
| 80 |
+
|
| 81 |
+
await runCliAgentWithLifecycle({
|
| 82 |
+
runId: "run-compaction-bridge",
|
| 83 |
+
provider: "claude-cli",
|
| 84 |
+
onCompactionStart: async () => {
|
| 85 |
+
callbacks.push("start");
|
| 86 |
+
},
|
| 87 |
+
onCompactionEnd: async (payload) => {
|
| 88 |
+
callbacks.push(payload?.completed === false ? "incomplete" : "end");
|
| 89 |
+
},
|
| 90 |
+
runParams: {
|
| 91 |
+
sessionId: "session-1",
|
| 92 |
+
sessionFile: "/tmp/session.jsonl",
|
| 93 |
+
workspaceDir: "/tmp/workspace",
|
| 94 |
+
prompt: "hello",
|
| 95 |
+
provider: "claude-cli",
|
| 96 |
+
model: "claude-opus-4-8",
|
| 97 |
+
thinkLevel: "high",
|
| 98 |
+
timeoutMs: 1_000,
|
| 99 |
+
runId: "run-compaction-bridge",
|
| 100 |
+
},
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
expect(callbacks).toEqual(["start", "incomplete", "start", "end"]);
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
it("bridges typed CLI plan events", async () => {
|
| 107 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 108 |
+
emitAgentEvent({
|
| 109 |
+
runId: params.runId,
|
| 110 |
+
stream: "plan",
|
| 111 |
+
data: {
|
| 112 |
+
phase: "update",
|
| 113 |
+
title: "Plan updated",
|
| 114 |
+
source: "codex-exec",
|
| 115 |
+
steps: [
|
| 116 |
+
{ step: "Inspect", status: "completed" },
|
| 117 |
+
{ step: "Patch", status: "pending" },
|
| 118 |
+
],
|
| 119 |
+
},
|
| 120 |
+
});
|
| 121 |
+
return { payloads: [], meta: { durationMs: 1 } };
|
| 122 |
+
});
|
| 123 |
+
const onPlanUpdate = vi.fn(async () => undefined);
|
| 124 |
+
|
| 125 |
+
await runCliAgentWithLifecycle({
|
| 126 |
+
runId: "run-plan-bridge",
|
| 127 |
+
provider: "codex-cli",
|
| 128 |
+
onPlanUpdate,
|
| 129 |
+
runParams: {
|
| 130 |
+
sessionId: "session-1",
|
| 131 |
+
sessionFile: "/tmp/session.jsonl",
|
| 132 |
+
workspaceDir: "/tmp/workspace",
|
| 133 |
+
prompt: "hello",
|
| 134 |
+
provider: "codex-cli",
|
| 135 |
+
model: "codex",
|
| 136 |
+
thinkLevel: "high",
|
| 137 |
+
timeoutMs: 1_000,
|
| 138 |
+
runId: "run-plan-bridge",
|
| 139 |
+
},
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
expect(onPlanUpdate).toHaveBeenCalledWith({
|
| 143 |
+
phase: "update",
|
| 144 |
+
title: "Plan updated",
|
| 145 |
+
explanation: undefined,
|
| 146 |
+
source: "codex-exec",
|
| 147 |
+
steps: [
|
| 148 |
+
{ step: "Inspect", status: "completed" },
|
| 149 |
+
{ step: "Patch", status: "pending" },
|
| 150 |
+
],
|
| 151 |
+
});
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it("normalizes shipped string-step plan events to pending typed steps", async () => {
|
| 155 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 156 |
+
emitAgentEvent({
|
| 157 |
+
runId: params.runId,
|
| 158 |
+
stream: "plan",
|
| 159 |
+
data: {
|
| 160 |
+
phase: "update",
|
| 161 |
+
title: "Plan updated",
|
| 162 |
+
source: "codex-exec",
|
| 163 |
+
steps: ["Inspect", " ", "Patch"],
|
| 164 |
+
},
|
| 165 |
+
});
|
| 166 |
+
return { payloads: [], meta: { durationMs: 1 } };
|
| 167 |
+
});
|
| 168 |
+
const onPlanUpdate = vi.fn(async () => undefined);
|
| 169 |
+
|
| 170 |
+
await runCliAgentWithLifecycle({
|
| 171 |
+
runId: "run-plan-legacy",
|
| 172 |
+
provider: "codex-cli",
|
| 173 |
+
onPlanUpdate,
|
| 174 |
+
runParams: {
|
| 175 |
+
sessionId: "session-1",
|
| 176 |
+
sessionFile: "/tmp/session.jsonl",
|
| 177 |
+
workspaceDir: "/tmp/workspace",
|
| 178 |
+
prompt: "hello",
|
| 179 |
+
provider: "codex-cli",
|
| 180 |
+
model: "codex",
|
| 181 |
+
thinkLevel: "high",
|
| 182 |
+
timeoutMs: 1_000,
|
| 183 |
+
runId: "run-plan-legacy",
|
| 184 |
+
},
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
expect(onPlanUpdate).toHaveBeenCalledWith(
|
| 188 |
+
expect.objectContaining({
|
| 189 |
+
steps: [
|
| 190 |
+
{ step: "Inspect", status: "pending" },
|
| 191 |
+
{ step: "Patch", status: "pending" },
|
| 192 |
+
],
|
| 193 |
+
}),
|
| 194 |
+
);
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
it("bridges thinking events to reasoning text and dedupes identical snapshots", async () => {
|
| 198 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 199 |
+
emitAgentEvent({
|
| 200 |
+
runId: params.runId,
|
| 201 |
+
stream: "thinking",
|
| 202 |
+
data: { text: "Thinking", delta: "Thinking", isReasoningSnapshot: true },
|
| 203 |
+
});
|
| 204 |
+
emitAgentEvent({
|
| 205 |
+
runId: params.runId,
|
| 206 |
+
stream: "thinking",
|
| 207 |
+
data: { text: "Thinking", delta: "", isReasoningSnapshot: true },
|
| 208 |
+
});
|
| 209 |
+
emitAgentEvent({
|
| 210 |
+
runId: params.runId,
|
| 211 |
+
stream: "thinking",
|
| 212 |
+
data: { text: "Thinking more", delta: " more", isReasoningSnapshot: true },
|
| 213 |
+
});
|
| 214 |
+
emitAgentEvent({
|
| 215 |
+
runId: params.runId,
|
| 216 |
+
stream: "assistant",
|
| 217 |
+
data: { text: "Visible answer", delta: "Visible answer" },
|
| 218 |
+
});
|
| 219 |
+
return { payloads: [{ text: "Visible answer" }], meta: { durationMs: 1 } };
|
| 220 |
+
});
|
| 221 |
+
const onReasoningText = vi.fn<(payload: ReasoningTextPayload) => Promise<void>>(
|
| 222 |
+
async () => undefined,
|
| 223 |
+
);
|
| 224 |
+
|
| 225 |
+
const result = await runCliAgentWithLifecycle({
|
| 226 |
+
runId: "run-thinking-bridge",
|
| 227 |
+
provider: "claude-cli",
|
| 228 |
+
onReasoningText,
|
| 229 |
+
runParams: {
|
| 230 |
+
sessionId: "session-1",
|
| 231 |
+
sessionFile: "/tmp/session.jsonl",
|
| 232 |
+
workspaceDir: "/tmp/workspace",
|
| 233 |
+
prompt: "hello",
|
| 234 |
+
provider: "claude-cli",
|
| 235 |
+
model: "claude",
|
| 236 |
+
thinkLevel: "high",
|
| 237 |
+
timeoutMs: 1_000,
|
| 238 |
+
runId: "run-thinking-bridge",
|
| 239 |
+
},
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
expect(onReasoningText).toHaveBeenCalledTimes(2);
|
| 243 |
+
expect(onReasoningText.mock.calls.map((call) => call[0])).toEqual([
|
| 244 |
+
{ text: "Thinking", isReasoningSnapshot: true },
|
| 245 |
+
{ text: "Thinking more", isReasoningSnapshot: true },
|
| 246 |
+
]);
|
| 247 |
+
expect(result.payloads).toEqual([
|
| 248 |
+
{ text: "Thinking more", isReasoning: true },
|
| 249 |
+
{ text: "Visible answer" },
|
| 250 |
+
]);
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
it("keeps durable reasoning when the CLI has no visible final answer", async () => {
|
| 254 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 255 |
+
emitAgentEvent({
|
| 256 |
+
runId: params.runId,
|
| 257 |
+
stream: "thinking",
|
| 258 |
+
data: { text: "Only thinking", delta: "Only thinking", isReasoningSnapshot: true },
|
| 259 |
+
});
|
| 260 |
+
emitAgentEvent({
|
| 261 |
+
runId: params.runId,
|
| 262 |
+
stream: "thinking",
|
| 263 |
+
data: { text: "Only thinking more", delta: " more", isReasoningSnapshot: true },
|
| 264 |
+
});
|
| 265 |
+
return { payloads: [], meta: { durationMs: 1 } };
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
const result = await runCliAgentWithLifecycle({
|
| 269 |
+
runId: "run-thinking-without-answer",
|
| 270 |
+
provider: "claude-cli",
|
| 271 |
+
runParams: {
|
| 272 |
+
sessionId: "session-1",
|
| 273 |
+
sessionFile: "/tmp/session.jsonl",
|
| 274 |
+
workspaceDir: "/tmp/workspace",
|
| 275 |
+
prompt: "hello",
|
| 276 |
+
provider: "claude-cli",
|
| 277 |
+
model: "claude",
|
| 278 |
+
thinkLevel: "high",
|
| 279 |
+
timeoutMs: 1_000,
|
| 280 |
+
runId: "run-thinking-without-answer",
|
| 281 |
+
},
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
expect(result.payloads).toEqual([{ text: "Only thinking more", isReasoning: true }]);
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
it("bridges thinking token progress without adding durable reasoning", async () => {
|
| 288 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 289 |
+
emitAgentEvent({
|
| 290 |
+
runId: params.runId,
|
| 291 |
+
stream: "thinking",
|
| 292 |
+
data: { progressTokens: 50 },
|
| 293 |
+
});
|
| 294 |
+
emitAgentEvent({
|
| 295 |
+
runId: params.runId,
|
| 296 |
+
stream: "thinking",
|
| 297 |
+
data: { progressTokens: 50 },
|
| 298 |
+
});
|
| 299 |
+
emitAgentEvent({
|
| 300 |
+
runId: params.runId,
|
| 301 |
+
stream: "thinking",
|
| 302 |
+
data: { progressTokens: 200 },
|
| 303 |
+
});
|
| 304 |
+
return { payloads: [{ text: "Visible answer" }], meta: { durationMs: 1 } };
|
| 305 |
+
});
|
| 306 |
+
const onReasoningProgress = vi.fn<(payload: ReasoningProgressPayload) => Promise<void>>(
|
| 307 |
+
async () => undefined,
|
| 308 |
+
);
|
| 309 |
+
|
| 310 |
+
const result = await runCliAgentWithLifecycle({
|
| 311 |
+
runId: "run-thinking-progress",
|
| 312 |
+
provider: "claude-cli",
|
| 313 |
+
onReasoningProgress,
|
| 314 |
+
runParams: {
|
| 315 |
+
sessionId: "session-1",
|
| 316 |
+
sessionFile: "/tmp/session.jsonl",
|
| 317 |
+
workspaceDir: "/tmp/workspace",
|
| 318 |
+
prompt: "hello",
|
| 319 |
+
provider: "claude-cli",
|
| 320 |
+
model: "claude",
|
| 321 |
+
thinkLevel: "high",
|
| 322 |
+
timeoutMs: 1_000,
|
| 323 |
+
runId: "run-thinking-progress",
|
| 324 |
+
},
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
expect(onReasoningProgress.mock.calls.map((call) => call[0])).toEqual([
|
| 328 |
+
{ progressTokens: 50 },
|
| 329 |
+
{ progressTokens: 200 },
|
| 330 |
+
]);
|
| 331 |
+
expect(result.payloads).toEqual([{ text: "Visible answer" }]);
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
it("stamps onActivity for every delivered bridge event, including reasoning-only progress", async () => {
|
| 335 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 336 |
+
emitAgentEvent({
|
| 337 |
+
runId: params.runId,
|
| 338 |
+
stream: "thinking",
|
| 339 |
+
data: { progressTokens: 50 },
|
| 340 |
+
});
|
| 341 |
+
emitAgentEvent({
|
| 342 |
+
runId: params.runId,
|
| 343 |
+
stream: "thinking",
|
| 344 |
+
data: { progressTokens: 50 },
|
| 345 |
+
});
|
| 346 |
+
emitAgentEvent({
|
| 347 |
+
runId: params.runId,
|
| 348 |
+
stream: "thinking",
|
| 349 |
+
data: { progressTokens: 200 },
|
| 350 |
+
});
|
| 351 |
+
return { payloads: [], meta: { durationMs: 1 } };
|
| 352 |
+
});
|
| 353 |
+
const onActivity = vi.fn();
|
| 354 |
+
const onReasoningProgress = vi.fn<(payload: ReasoningProgressPayload) => Promise<void>>(
|
| 355 |
+
async () => undefined,
|
| 356 |
+
);
|
| 357 |
+
|
| 358 |
+
await runCliAgentWithLifecycle({
|
| 359 |
+
runId: "run-activity-reasoning-progress",
|
| 360 |
+
provider: "claude-cli",
|
| 361 |
+
onActivity,
|
| 362 |
+
onReasoningProgress,
|
| 363 |
+
runParams: {
|
| 364 |
+
sessionId: "session-1",
|
| 365 |
+
sessionFile: "/tmp/session.jsonl",
|
| 366 |
+
workspaceDir: "/tmp/workspace",
|
| 367 |
+
prompt: "hello",
|
| 368 |
+
provider: "claude-cli",
|
| 369 |
+
model: "claude",
|
| 370 |
+
thinkLevel: "high",
|
| 371 |
+
timeoutMs: 1_000,
|
| 372 |
+
runId: "run-activity-reasoning-progress",
|
| 373 |
+
},
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
// A run in a long pure-reasoning stretch must keep stamping activity, or
|
| 377 |
+
// stale-takeover reclaims it while it is visibly thinking. Stamps are
|
| 378 |
+
// per-event (delivery-independent), so dedupe downstream does not matter.
|
| 379 |
+
expect(onReasoningProgress).toHaveBeenCalledTimes(2);
|
| 380 |
+
expect(onActivity).toHaveBeenCalledTimes(3);
|
| 381 |
+
});
|
| 382 |
+
|
| 383 |
+
it("keeps stamping onActivity when bridges are suppressed for silent runs", async () => {
|
| 384 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 385 |
+
emitAgentEvent({
|
| 386 |
+
runId: params.runId,
|
| 387 |
+
stream: "thinking",
|
| 388 |
+
data: { progressTokens: 50 },
|
| 389 |
+
});
|
| 390 |
+
emitAgentEvent({
|
| 391 |
+
runId: params.runId,
|
| 392 |
+
stream: "assistant",
|
| 393 |
+
data: { text: "Silent answer", delta: "Silent answer" },
|
| 394 |
+
});
|
| 395 |
+
return { payloads: [], meta: { durationMs: 1 } };
|
| 396 |
+
});
|
| 397 |
+
const onActivity = vi.fn();
|
| 398 |
+
const onAssistantText = vi.fn<(text: string) => Promise<void>>(async () => undefined);
|
| 399 |
+
const onReasoningProgress = vi.fn<(payload: ReasoningProgressPayload) => Promise<void>>(
|
| 400 |
+
async () => undefined,
|
| 401 |
+
);
|
| 402 |
+
|
| 403 |
+
await runCliAgentWithLifecycle({
|
| 404 |
+
runId: "run-activity-suppressed",
|
| 405 |
+
provider: "claude-cli",
|
| 406 |
+
suppressAssistantBridge: true,
|
| 407 |
+
onActivity,
|
| 408 |
+
onAssistantText,
|
| 409 |
+
onReasoningProgress,
|
| 410 |
+
runParams: {
|
| 411 |
+
sessionId: "session-1",
|
| 412 |
+
sessionFile: "/tmp/session.jsonl",
|
| 413 |
+
workspaceDir: "/tmp/workspace",
|
| 414 |
+
prompt: "hello",
|
| 415 |
+
provider: "claude-cli",
|
| 416 |
+
model: "claude",
|
| 417 |
+
thinkLevel: "high",
|
| 418 |
+
timeoutMs: 1_000,
|
| 419 |
+
runId: "run-activity-suppressed",
|
| 420 |
+
},
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
// silentExpected runs suppress deliveries, but their events are still real
|
| 424 |
+
// liveness evidence — without these stamps a healthy silent run would be
|
| 425 |
+
// reclaimed as run_stalled at the takeover window.
|
| 426 |
+
expect(onAssistantText).not.toHaveBeenCalled();
|
| 427 |
+
expect(onReasoningProgress).not.toHaveBeenCalled();
|
| 428 |
+
expect(onActivity).toHaveBeenCalledTimes(2);
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
it("stamps onActivity for assistant text without caller callbacks for that stream", async () => {
|
| 432 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 433 |
+
emitAgentEvent({
|
| 434 |
+
runId: params.runId,
|
| 435 |
+
stream: "thinking",
|
| 436 |
+
data: { text: "Deep thought", delta: "Deep thought", isReasoningSnapshot: true },
|
| 437 |
+
});
|
| 438 |
+
emitAgentEvent({
|
| 439 |
+
runId: params.runId,
|
| 440 |
+
stream: "assistant",
|
| 441 |
+
data: { text: "Visible answer", delta: "Visible answer" },
|
| 442 |
+
});
|
| 443 |
+
return { payloads: [{ text: "Visible answer" }], meta: { durationMs: 1 } };
|
| 444 |
+
});
|
| 445 |
+
const onActivity = vi.fn();
|
| 446 |
+
const onAssistantText = vi.fn<(text: string) => Promise<void>>(async () => undefined);
|
| 447 |
+
|
| 448 |
+
await runCliAgentWithLifecycle({
|
| 449 |
+
runId: "run-activity-assistant",
|
| 450 |
+
provider: "claude-cli",
|
| 451 |
+
onActivity,
|
| 452 |
+
onAssistantText,
|
| 453 |
+
runParams: {
|
| 454 |
+
sessionId: "session-1",
|
| 455 |
+
sessionFile: "/tmp/session.jsonl",
|
| 456 |
+
workspaceDir: "/tmp/workspace",
|
| 457 |
+
prompt: "hello",
|
| 458 |
+
provider: "claude-cli",
|
| 459 |
+
model: "claude",
|
| 460 |
+
thinkLevel: "high",
|
| 461 |
+
timeoutMs: 1_000,
|
| 462 |
+
runId: "run-activity-assistant",
|
| 463 |
+
},
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
// Every real event stamps, independent of which callbacks are registered.
|
| 467 |
+
expect(onAssistantText).toHaveBeenCalledTimes(1);
|
| 468 |
+
expect(onActivity).toHaveBeenCalledTimes(2);
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
it("does not add a durable reasoning payload when the CLI emits no thinking", async () => {
|
| 472 |
+
cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
|
| 473 |
+
payloads: [{ text: "Visible answer" }],
|
| 474 |
+
meta: { durationMs: 1 },
|
| 475 |
+
} satisfies EmbeddedAgentRunResult);
|
| 476 |
+
|
| 477 |
+
const result = await runCliAgentWithLifecycle({
|
| 478 |
+
runId: "run-no-thinking",
|
| 479 |
+
provider: "claude-cli",
|
| 480 |
+
runParams: {
|
| 481 |
+
sessionId: "session-1",
|
| 482 |
+
sessionFile: "/tmp/session.jsonl",
|
| 483 |
+
workspaceDir: "/tmp/workspace",
|
| 484 |
+
prompt: "hello",
|
| 485 |
+
provider: "claude-cli",
|
| 486 |
+
model: "claude",
|
| 487 |
+
thinkLevel: "high",
|
| 488 |
+
timeoutMs: 1_000,
|
| 489 |
+
runId: "run-no-thinking",
|
| 490 |
+
},
|
| 491 |
+
});
|
| 492 |
+
|
| 493 |
+
expect(result.payloads).toEqual([{ text: "Visible answer" }]);
|
| 494 |
+
});
|
| 495 |
+
|
| 496 |
+
it("keeps the captured lifecycle generation on start and terminal events", async () => {
|
| 497 |
+
const events: Array<{
|
| 498 |
+
stream?: string;
|
| 499 |
+
lifecycleGeneration?: string;
|
| 500 |
+
agentId?: string;
|
| 501 |
+
data?: Record<string, unknown>;
|
| 502 |
+
}> = [];
|
| 503 |
+
const lifecycleGeneration = getAgentEventLifecycleGeneration();
|
| 504 |
+
const stop = onAgentEvent((event) => {
|
| 505 |
+
if (event.runId === "run-before-restart") {
|
| 506 |
+
events.push(event);
|
| 507 |
+
}
|
| 508 |
+
});
|
| 509 |
+
cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
|
| 510 |
+
payloads: [],
|
| 511 |
+
meta: { durationMs: 1 },
|
| 512 |
+
} satisfies EmbeddedAgentRunResult);
|
| 513 |
+
|
| 514 |
+
try {
|
| 515 |
+
await runCliAgentWithLifecycle({
|
| 516 |
+
runId: "run-before-restart",
|
| 517 |
+
lifecycleGeneration,
|
| 518 |
+
startedAt: 1_000,
|
| 519 |
+
provider: "claude-cli",
|
| 520 |
+
runParams: {
|
| 521 |
+
sessionId: "session-1",
|
| 522 |
+
agentId: "support",
|
| 523 |
+
sessionFile: "/tmp/session.jsonl",
|
| 524 |
+
workspaceDir: "/tmp/workspace",
|
| 525 |
+
prompt: "hello",
|
| 526 |
+
provider: "claude-cli",
|
| 527 |
+
model: "claude",
|
| 528 |
+
thinkLevel: "off",
|
| 529 |
+
timeoutMs: 1_000,
|
| 530 |
+
runId: "run-before-restart",
|
| 531 |
+
},
|
| 532 |
+
});
|
| 533 |
+
} finally {
|
| 534 |
+
stop();
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
const lifecycleEvents = events.filter((event) => event.stream === "lifecycle");
|
| 538 |
+
expect(lifecycleEvents).toHaveLength(2);
|
| 539 |
+
expect(
|
| 540 |
+
lifecycleEvents.every((event) => event.lifecycleGeneration === lifecycleGeneration),
|
| 541 |
+
).toBe(true);
|
| 542 |
+
expect(lifecycleEvents.every((event) => event.agentId === "support")).toBe(true);
|
| 543 |
+
expect(lifecycleEvents.every((event) => event.data?.startedAt === 1_000)).toBe(true);
|
| 544 |
+
});
|
| 545 |
+
|
| 546 |
+
it("preserves restart ownership when the CLI resolves after cancellation", async () => {
|
| 547 |
+
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
|
| 548 |
+
const stop = onAgentEvent((event) => {
|
| 549 |
+
if (event.runId === "run-restart") {
|
| 550 |
+
events.push(event);
|
| 551 |
+
}
|
| 552 |
+
});
|
| 553 |
+
const controller = new AbortController();
|
| 554 |
+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async () => {
|
| 555 |
+
controller.abort(createAgentRunRestartAbortError());
|
| 556 |
+
return {
|
| 557 |
+
payloads: [{ text: "stale result" }],
|
| 558 |
+
meta: { durationMs: 1 },
|
| 559 |
+
} satisfies EmbeddedAgentRunResult;
|
| 560 |
+
});
|
| 561 |
+
|
| 562 |
+
await expect(
|
| 563 |
+
runCliAgentWithLifecycle({
|
| 564 |
+
runId: "run-restart",
|
| 565 |
+
provider: "claude-cli",
|
| 566 |
+
runParams: {
|
| 567 |
+
sessionId: "session-1",
|
| 568 |
+
sessionFile: "/tmp/session.jsonl",
|
| 569 |
+
workspaceDir: "/tmp/workspace",
|
| 570 |
+
prompt: "hello",
|
| 571 |
+
provider: "claude-cli",
|
| 572 |
+
model: "claude",
|
| 573 |
+
thinkLevel: "off",
|
| 574 |
+
timeoutMs: 1_000,
|
| 575 |
+
runId: "run-restart",
|
| 576 |
+
abortSignal: controller.signal,
|
| 577 |
+
},
|
| 578 |
+
}),
|
| 579 |
+
).rejects.toThrow("agent run aborted for restart");
|
| 580 |
+
stop();
|
| 581 |
+
|
| 582 |
+
const terminal = events.find(
|
| 583 |
+
(event) => event.stream === "lifecycle" && event.data?.phase === "error",
|
| 584 |
+
);
|
| 585 |
+
expect(terminal?.data).toMatchObject({
|
| 586 |
+
aborted: true,
|
| 587 |
+
stopReason: "restart",
|
| 588 |
+
});
|
| 589 |
+
expect(events.some((event) => event.stream === "assistant")).toBe(false);
|
| 590 |
+
});
|
| 591 |
+
|
| 592 |
+
it("attributes a structured CLI watchdog timeout on the terminal event", async () => {
|
| 593 |
+
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
|
| 594 |
+
const stop = onAgentEvent((event) => {
|
| 595 |
+
if (event.runId === "run-timeout") {
|
| 596 |
+
events.push(event);
|
| 597 |
+
}
|
| 598 |
+
});
|
| 599 |
+
cliDispatchState.runCliAgentMock.mockRejectedValueOnce(
|
| 600 |
+
createCliTimeoutError(
|
| 601 |
+
{ provider: "claude-cli", model: "claude", sessionId: "session-1" },
|
| 602 |
+
{
|
| 603 |
+
mode: "no-output",
|
| 604 |
+
timeoutSeconds: 1,
|
| 605 |
+
observedActivity: false,
|
| 606 |
+
activeToolCount: 0,
|
| 607 |
+
backgroundTaskCount: 0,
|
| 608 |
+
},
|
| 609 |
+
),
|
| 610 |
+
);
|
| 611 |
+
|
| 612 |
+
await expect(
|
| 613 |
+
runCliAgentWithLifecycle({
|
| 614 |
+
runId: "run-timeout",
|
| 615 |
+
provider: "claude-cli",
|
| 616 |
+
runParams: {
|
| 617 |
+
sessionId: "session-1",
|
| 618 |
+
sessionFile: "/tmp/session.jsonl",
|
| 619 |
+
workspaceDir: "/tmp/workspace",
|
| 620 |
+
prompt: "hello",
|
| 621 |
+
provider: "claude-cli",
|
| 622 |
+
model: "claude",
|
| 623 |
+
thinkLevel: "off",
|
| 624 |
+
timeoutMs: 1_000,
|
| 625 |
+
runId: "run-timeout",
|
| 626 |
+
},
|
| 627 |
+
}),
|
| 628 |
+
).rejects.toThrow("CLI produced no output");
|
| 629 |
+
stop();
|
| 630 |
+
|
| 631 |
+
expect(
|
| 632 |
+
events.find((event) => event.stream === "lifecycle" && event.data?.phase === "error")?.data,
|
| 633 |
+
).toMatchObject({
|
| 634 |
+
stopReason: "timeout",
|
| 635 |
+
timeoutPhase: "provider",
|
| 636 |
+
});
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
it("propagates yielded result metadata on lifecycle end", async () => {
|
| 640 |
+
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
|
| 641 |
+
const stop = onAgentEvent((event) => {
|
| 642 |
+
if (event.runId === "run-yielded") {
|
| 643 |
+
events.push(event);
|
| 644 |
+
}
|
| 645 |
+
});
|
| 646 |
+
cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
|
| 647 |
+
payloads: [],
|
| 648 |
+
meta: {
|
| 649 |
+
durationMs: 1,
|
| 650 |
+
yielded: true,
|
| 651 |
+
livenessState: "paused",
|
| 652 |
+
stopReason: "end_turn",
|
| 653 |
+
},
|
| 654 |
+
} satisfies EmbeddedAgentRunResult);
|
| 655 |
+
|
| 656 |
+
try {
|
| 657 |
+
await runCliAgentWithLifecycle({
|
| 658 |
+
runId: "run-yielded",
|
| 659 |
+
provider: "claude-cli",
|
| 660 |
+
runParams: {
|
| 661 |
+
sessionId: "session-1",
|
| 662 |
+
sessionFile: "/tmp/session.jsonl",
|
| 663 |
+
workspaceDir: "/tmp/workspace",
|
| 664 |
+
prompt: "hello",
|
| 665 |
+
provider: "claude-cli",
|
| 666 |
+
model: "claude",
|
| 667 |
+
thinkLevel: "off",
|
| 668 |
+
timeoutMs: 1_000,
|
| 669 |
+
runId: "run-yielded",
|
| 670 |
+
},
|
| 671 |
+
});
|
| 672 |
+
} finally {
|
| 673 |
+
stop();
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
const terminal = events.find(
|
| 677 |
+
(event) => event.stream === "lifecycle" && event.data?.phase === "end",
|
| 678 |
+
);
|
| 679 |
+
expect(terminal?.data).toMatchObject({
|
| 680 |
+
yielded: true,
|
| 681 |
+
livenessState: "paused",
|
| 682 |
+
stopReason: "end_turn",
|
| 683 |
+
});
|
| 684 |
+
});
|
| 685 |
+
});
|
| 686 |
+
|
| 687 |
+
describe("keepCliSessionBindingOnlyWhenReused", () => {
|
| 688 |
+
it("keeps the first room-event CLI binding when no binding exists yet", () => {
|
| 689 |
+
const result = {
|
| 690 |
+
payloads: [],
|
| 691 |
+
meta: {
|
| 692 |
+
durationMs: 1,
|
| 693 |
+
agentMeta: {
|
| 694 |
+
sessionId: "new-cli-session",
|
| 695 |
+
provider: "claude-cli",
|
| 696 |
+
model: "claude-opus-4-8",
|
| 697 |
+
cliSessionBinding: {
|
| 698 |
+
sessionId: "new-cli-session",
|
| 699 |
+
authProfileId: "profile",
|
| 700 |
+
},
|
| 701 |
+
},
|
| 702 |
+
},
|
| 703 |
+
} satisfies EmbeddedAgentRunResult;
|
| 704 |
+
|
| 705 |
+
expect(keepCliSessionBindingOnlyWhenReused({ result })).toBe(result);
|
| 706 |
+
});
|
| 707 |
+
|
| 708 |
+
it("drops a replacement room-event CLI binding when an existing binding was reused", () => {
|
| 709 |
+
const onDroppedReplacement = vi.fn();
|
| 710 |
+
const result = keepCliSessionBindingOnlyWhenReused({
|
| 711 |
+
existingSessionId: "existing-cli-session",
|
| 712 |
+
onDroppedReplacement,
|
| 713 |
+
result: {
|
| 714 |
+
payloads: [],
|
| 715 |
+
meta: {
|
| 716 |
+
durationMs: 1,
|
| 717 |
+
agentMeta: {
|
| 718 |
+
sessionId: "replacement-cli-session",
|
| 719 |
+
provider: "claude-cli",
|
| 720 |
+
model: "claude-opus-4-8",
|
| 721 |
+
cliSessionBinding: {
|
| 722 |
+
sessionId: "replacement-cli-session",
|
| 723 |
+
authProfileId: "profile",
|
| 724 |
+
},
|
| 725 |
+
},
|
| 726 |
+
},
|
| 727 |
+
} satisfies EmbeddedAgentRunResult,
|
| 728 |
+
});
|
| 729 |
+
|
| 730 |
+
expect(onDroppedReplacement).toHaveBeenCalledOnce();
|
| 731 |
+
expect(result.meta.agentMeta?.sessionId).toBe("");
|
| 732 |
+
expect(result.meta.agentMeta?.cliSessionBinding).toBeUndefined();
|
| 733 |
+
});
|
| 734 |
+
});
|
| 735 |
+
|
| 736 |
+
describe("clearCliSessionInStore", () => {
|
| 737 |
+
it.each(["current", "closed"])(
|
| 738 |
+
"clears active and stored entries only for a %s owner",
|
| 739 |
+
async (owner) => {
|
| 740 |
+
const activeEntry = {
|
| 741 |
+
sessionId: "openclaw-active",
|
| 742 |
+
updatedAt: 1,
|
| 743 |
+
cliSessionBindings: { "claude-cli": { sessionId: "stale-session" } },
|
| 744 |
+
cliSessionIds: { "claude-cli": "stale-session" },
|
| 745 |
+
claudeCliSessionId: "stale-session",
|
| 746 |
+
};
|
| 747 |
+
const storedEntry = structuredClone(activeEntry);
|
| 748 |
+
const storePath = path.join(tempDirs.make("cli-session-cleanup-"), "sessions.json");
|
| 749 |
+
await replaceSessionEntry({ storePath, sessionKey: "main" }, structuredClone(activeEntry));
|
| 750 |
+
|
| 751 |
+
let open = true;
|
| 752 |
+
const clear = clearCliSessionInStore({
|
| 753 |
+
provider: "claude-cli",
|
| 754 |
+
expectedCliSessionId: "stale-session",
|
| 755 |
+
expectedSessionId: activeEntry.sessionId,
|
| 756 |
+
sessionKey: "main",
|
| 757 |
+
sessionStore: { main: storedEntry },
|
| 758 |
+
storePath,
|
| 759 |
+
activeSessionEntry: activeEntry,
|
| 760 |
+
assertCommitAllowed: () => {
|
| 761 |
+
if (!open) {
|
| 762 |
+
throw new Error("owner closed");
|
| 763 |
+
}
|
| 764 |
+
},
|
| 765 |
+
});
|
| 766 |
+
if (owner === "closed") {
|
| 767 |
+
open = false;
|
| 768 |
+
await expect(clear).rejects.toThrow("owner closed");
|
| 769 |
+
for (const entry of [
|
| 770 |
+
activeEntry,
|
| 771 |
+
storedEntry,
|
| 772 |
+
loadSessionEntry({ storePath, sessionKey: "main" }),
|
| 773 |
+
]) {
|
| 774 |
+
expect(entry?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("stale-session");
|
| 775 |
+
}
|
| 776 |
+
return;
|
| 777 |
+
}
|
| 778 |
+
await clear;
|
| 779 |
+
|
| 780 |
+
for (const entry of [activeEntry, storedEntry]) {
|
| 781 |
+
expect(entry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
| 782 |
+
expect(entry.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
| 783 |
+
expect(entry.claudeCliSessionId).toBeUndefined();
|
| 784 |
+
expect(entry.updatedAt).toBeGreaterThan(1);
|
| 785 |
+
}
|
| 786 |
+
const persisted = loadSessionEntry({ storePath, sessionKey: "main" });
|
| 787 |
+
expect(persisted?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
| 788 |
+
expect(persisted?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
| 789 |
+
expect(persisted?.claudeCliSessionId).toBeUndefined();
|
| 790 |
+
},
|
| 791 |
+
);
|
| 792 |
+
|
| 793 |
+
it("does not clear a replacement binding adopted by another turn", async () => {
|
| 794 |
+
const entry = {
|
| 795 |
+
sessionId: "openclaw-active",
|
| 796 |
+
updatedAt: 1,
|
| 797 |
+
cliSessionBindings: { "claude-cli": { sessionId: "replacement-session" } },
|
| 798 |
+
cliSessionIds: { "claude-cli": "replacement-session" },
|
| 799 |
+
claudeCliSessionId: "replacement-session",
|
| 800 |
+
};
|
| 801 |
+
|
| 802 |
+
await clearCliSessionInStore({
|
| 803 |
+
provider: "claude-cli",
|
| 804 |
+
expectedCliSessionId: "stale-session",
|
| 805 |
+
activeSessionEntry: entry,
|
| 806 |
+
});
|
| 807 |
+
|
| 808 |
+
expect(entry.cliSessionBindings["claude-cli"].sessionId).toBe("replacement-session");
|
| 809 |
+
expect(entry.cliSessionIds["claude-cli"]).toBe("replacement-session");
|
| 810 |
+
expect(entry.claudeCliSessionId).toBe("replacement-session");
|
| 811 |
+
expect(entry.updatedAt).toBe(1);
|
| 812 |
+
});
|
| 813 |
+
});
|
| 814 |
+
|
| 815 |
+
describe("createCliToolSummaryTracker", () => {
|
| 816 |
+
const startEvent = {
|
| 817 |
+
name: "exec",
|
| 818 |
+
phase: "start" as const,
|
| 819 |
+
args: { command: "date -u" },
|
| 820 |
+
toolCallId: "tool-1",
|
| 821 |
+
};
|
| 822 |
+
const resultEvent = {
|
| 823 |
+
name: "exec",
|
| 824 |
+
phase: "result" as const,
|
| 825 |
+
args: undefined,
|
| 826 |
+
toolCallId: "tool-1",
|
| 827 |
+
isError: false,
|
| 828 |
+
result: { content: [{ type: "text", text: "Wed Jun 10 2026" }] },
|
| 829 |
+
};
|
| 830 |
+
|
| 831 |
+
it.each(["exec", "server.exec"])(
|
| 832 |
+
"delivers a safe %s summary using metadata captured at start",
|
| 833 |
+
async (name) => {
|
| 834 |
+
const deliver = vi.fn();
|
| 835 |
+
const tracker = createCliToolSummaryTracker({
|
| 836 |
+
commandDetailsVisible: false,
|
| 837 |
+
shouldEmitToolResult: () => true,
|
| 838 |
+
shouldEmitToolOutput: () => false,
|
| 839 |
+
deliver,
|
| 840 |
+
});
|
| 841 |
+
await tracker.noteToolEvent({ ...startEvent, name });
|
| 842 |
+
const commandBearing = await tracker.noteToolEvent({ ...resultEvent, name });
|
| 843 |
+
expect(commandBearing).toBe(true);
|
| 844 |
+
expect(deliver).toHaveBeenCalledTimes(1);
|
| 845 |
+
const payload = deliver.mock.calls[0]?.[0] as { text: string; isError?: boolean };
|
| 846 |
+
expect(payload.text).toContain(name === "exec" ? "Exec" : "Server.exec");
|
| 847 |
+
expect(payload.text).not.toContain("date -u");
|
| 848 |
+
expect(payload.text).not.toContain("Wed Jun 10 2026");
|
| 849 |
+
expect(payload.isError).toBeUndefined();
|
| 850 |
+
},
|
| 851 |
+
);
|
| 852 |
+
|
| 853 |
+
it("appends the tool output block when full verbose output is enabled", async () => {
|
| 854 |
+
const deliver = vi.fn();
|
| 855 |
+
const tracker = createCliToolSummaryTracker({
|
| 856 |
+
commandDetailsVisible: true,
|
| 857 |
+
shouldEmitToolResult: () => true,
|
| 858 |
+
shouldEmitToolOutput: () => true,
|
| 859 |
+
deliver,
|
| 860 |
+
});
|
| 861 |
+
await tracker.noteToolEvent(startEvent);
|
| 862 |
+
await tracker.noteToolEvent(resultEvent);
|
| 863 |
+
const payload = deliver.mock.calls[0]?.[0] as { text: string };
|
| 864 |
+
expect(payload.text).toContain("```txt");
|
| 865 |
+
expect(payload.text).toContain("Wed Jun 10 2026");
|
| 866 |
+
});
|
| 867 |
+
|
| 868 |
+
it("renders top-level structured CLI results in full verbose output", async () => {
|
| 869 |
+
const deliver = vi.fn();
|
| 870 |
+
const tracker = createCliToolSummaryTracker({
|
| 871 |
+
commandDetailsVisible: true,
|
| 872 |
+
shouldEmitToolResult: () => true,
|
| 873 |
+
shouldEmitToolOutput: () => true,
|
| 874 |
+
deliver,
|
| 875 |
+
});
|
| 876 |
+
await tracker.noteToolEvent(startEvent);
|
| 877 |
+
await tracker.noteToolEvent({
|
| 878 |
+
...resultEvent,
|
| 879 |
+
result: [{ type: "web_search_result", title: "OpenClaw", url: "https://example.com" }],
|
| 880 |
+
});
|
| 881 |
+
|
| 882 |
+
const payload = deliver.mock.calls[0]?.[0] as { text: string };
|
| 883 |
+
expect(payload.text).toContain('"type":"web_search_result"');
|
| 884 |
+
expect(payload.text).toContain('"title":"OpenClaw"');
|
| 885 |
+
});
|
| 886 |
+
|
| 887 |
+
it("emits nothing while tool summaries are disabled", async () => {
|
| 888 |
+
const deliver = vi.fn();
|
| 889 |
+
const tracker = createCliToolSummaryTracker({
|
| 890 |
+
commandDetailsVisible: false,
|
| 891 |
+
shouldEmitToolResult: () => false,
|
| 892 |
+
shouldEmitToolOutput: () => false,
|
| 893 |
+
deliver,
|
| 894 |
+
});
|
| 895 |
+
await tracker.noteToolEvent(startEvent);
|
| 896 |
+
await tracker.noteToolEvent(resultEvent);
|
| 897 |
+
expect(deliver).not.toHaveBeenCalled();
|
| 898 |
+
});
|
| 899 |
+
|
| 900 |
+
it("leaves plan tools to the authoritative plan event instead of summarizing arguments", async () => {
|
| 901 |
+
const deliver = vi.fn();
|
| 902 |
+
const tracker = createCliToolSummaryTracker({
|
| 903 |
+
commandDetailsVisible: true,
|
| 904 |
+
shouldEmitToolResult: () => true,
|
| 905 |
+
shouldEmitToolOutput: () => true,
|
| 906 |
+
deliver,
|
| 907 |
+
});
|
| 908 |
+
await tracker.noteToolEvent({
|
| 909 |
+
name: "progress_card",
|
| 910 |
+
phase: "start",
|
| 911 |
+
args: {
|
| 912 |
+
markdown: '<progress aria-label="CI · 2/3" value="2" max="3"></progress>',
|
| 913 |
+
},
|
| 914 |
+
toolCallId: "plan-1",
|
| 915 |
+
});
|
| 916 |
+
await tracker.noteToolEvent({
|
| 917 |
+
name: "progress_card",
|
| 918 |
+
phase: "result",
|
| 919 |
+
args: undefined,
|
| 920 |
+
toolCallId: "plan-1",
|
| 921 |
+
isError: false,
|
| 922 |
+
result: { content: [{ type: "text", text: "Progress card updated" }] },
|
| 923 |
+
});
|
| 924 |
+
|
| 925 |
+
expect(deliver).not.toHaveBeenCalled();
|
| 926 |
+
});
|
| 927 |
+
|
| 928 |
+
it.each([false, true])(
|
| 929 |
+
"keeps card errors visible without arguments (full output: %s)",
|
| 930 |
+
async (fullOutput) => {
|
| 931 |
+
const deliver = vi.fn();
|
| 932 |
+
const tracker = createCliToolSummaryTracker({
|
| 933 |
+
commandDetailsVisible: false,
|
| 934 |
+
shouldEmitToolResult: () => true,
|
| 935 |
+
shouldEmitToolOutput: () => fullOutput,
|
| 936 |
+
deliver,
|
| 937 |
+
});
|
| 938 |
+
await tracker.noteToolEvent({
|
| 939 |
+
name: "progress_card",
|
| 940 |
+
phase: "start",
|
| 941 |
+
args: { markdown: '<progress aria-label="private" value="1" max="2"></progress>' },
|
| 942 |
+
toolCallId: "plan-error",
|
| 943 |
+
});
|
| 944 |
+
await tracker.noteToolEvent({
|
| 945 |
+
name: undefined,
|
| 946 |
+
phase: "result",
|
| 947 |
+
args: undefined,
|
| 948 |
+
toolCallId: "plan-error",
|
| 949 |
+
isError: true,
|
| 950 |
+
result: { content: [{ type: "text", text: "write failed" }] },
|
| 951 |
+
});
|
| 952 |
+
|
| 953 |
+
expect(deliver).toHaveBeenCalledWith({
|
| 954 |
+
text: fullOutput ? "🗺️ Progress Card\n```txt\nwrite failed\n```" : "🗺️ Progress Card",
|
| 955 |
+
isError: true,
|
| 956 |
+
});
|
| 957 |
+
},
|
| 958 |
+
);
|
| 959 |
+
|
| 960 |
+
it("propagates tool errors on the summary payload", async () => {
|
| 961 |
+
const deliver = vi.fn();
|
| 962 |
+
const tracker = createCliToolSummaryTracker({
|
| 963 |
+
commandDetailsVisible: false,
|
| 964 |
+
shouldEmitToolResult: () => true,
|
| 965 |
+
shouldEmitToolOutput: () => false,
|
| 966 |
+
deliver,
|
| 967 |
+
});
|
| 968 |
+
await tracker.noteToolEvent(startEvent);
|
| 969 |
+
await tracker.noteToolEvent({ ...resultEvent, isError: true });
|
| 970 |
+
const payload = deliver.mock.calls[0]?.[0] as { isError?: boolean };
|
| 971 |
+
expect(payload.isError).toBe(true);
|
| 972 |
+
});
|
| 973 |
+
|
| 974 |
+
it("summarizes results without a tracked start event", async () => {
|
| 975 |
+
const deliver = vi.fn();
|
| 976 |
+
const tracker = createCliToolSummaryTracker({
|
| 977 |
+
commandDetailsVisible: false,
|
| 978 |
+
shouldEmitToolResult: () => true,
|
| 979 |
+
shouldEmitToolOutput: () => false,
|
| 980 |
+
deliver,
|
| 981 |
+
});
|
| 982 |
+
await tracker.noteToolEvent({ ...resultEvent, toolCallId: "unseen" });
|
| 983 |
+
expect(deliver).toHaveBeenCalledTimes(1);
|
| 984 |
+
});
|
| 985 |
+
});
|
| 986 |
+
|
| 987 |
+
describe("runCliAgentWithLifecycle fast auto progress", () => {
|
| 988 |
+
it("emits auto-off after the first CLI tool boundary past the threshold", async () => {
|
| 989 |
+
vi.useFakeTimers();
|
| 990 |
+
vi.setSystemTime(1_000);
|
| 991 |
+
const events: Array<{ stream: string; data: Record<string, unknown> }> = [];
|
| 992 |
+
const stop = onAgentEvent((evt) => {
|
| 993 |
+
if (evt.runId === "run-fast-cli") {
|
| 994 |
+
events.push({ stream: evt.stream, data: evt.data });
|
| 995 |
+
}
|
| 996 |
+
});
|
| 997 |
+
const progressPayloads: string[] = [];
|
| 998 |
+
cliDispatchState.runCliAgentMock.mockImplementation(async () => {
|
| 999 |
+
emitAgentEvent({
|
| 1000 |
+
runId: "run-fast-cli",
|
| 1001 |
+
stream: "tool",
|
| 1002 |
+
data: { phase: "start", name: "bash", toolCallId: "call-1" },
|
| 1003 |
+
});
|
| 1004 |
+
vi.setSystemTime(7_100);
|
| 1005 |
+
emitAgentEvent({
|
| 1006 |
+
runId: "run-fast-cli",
|
| 1007 |
+
stream: "tool",
|
| 1008 |
+
data: { phase: "result", name: "bash", toolCallId: "call-1" },
|
| 1009 |
+
});
|
| 1010 |
+
return {
|
| 1011 |
+
payloads: [{ text: "done" }],
|
| 1012 |
+
meta: {
|
| 1013 |
+
durationMs: 7_100,
|
| 1014 |
+
agentMeta: { sessionId: "session-1", provider: "codex-cli", model: "gpt-5.5" },
|
| 1015 |
+
},
|
| 1016 |
+
} satisfies EmbeddedAgentRunResult;
|
| 1017 |
+
});
|
| 1018 |
+
|
| 1019 |
+
await runCliAgentWithLifecycle({
|
| 1020 |
+
runId: "run-fast-cli",
|
| 1021 |
+
provider: "codex-cli",
|
| 1022 |
+
runParams: {
|
| 1023 |
+
sessionId: "session-1",
|
| 1024 |
+
sessionKey: "agent:main:cli-fast",
|
| 1025 |
+
sessionFile: "/tmp/session.jsonl",
|
| 1026 |
+
workspaceDir: "/tmp/workspace",
|
| 1027 |
+
prompt: "run one tool",
|
| 1028 |
+
provider: "codex-cli",
|
| 1029 |
+
model: "gpt-5.5",
|
| 1030 |
+
timeoutMs: 60_000,
|
| 1031 |
+
runId: "run-fast-cli",
|
| 1032 |
+
fastMode: "auto",
|
| 1033 |
+
fastModeStartedAtMs: 1_000,
|
| 1034 |
+
fastModeAutoOnSeconds: 5,
|
| 1035 |
+
},
|
| 1036 |
+
onFastModeAutoProgress: async (payload) => {
|
| 1037 |
+
if (payload.text) {
|
| 1038 |
+
progressPayloads.push(payload.text);
|
| 1039 |
+
}
|
| 1040 |
+
},
|
| 1041 |
+
});
|
| 1042 |
+
stop();
|
| 1043 |
+
|
| 1044 |
+
const summaries = events
|
| 1045 |
+
.filter((event) => event.stream === "item")
|
| 1046 |
+
.map((event) => event.data.summary);
|
| 1047 |
+
expect(summaries).toContain("💨Fast: auto-off(6s>=5s)");
|
| 1048 |
+
expect(summaries).toContain("💨Fast: auto-on");
|
| 1049 |
+
expect(progressPayloads).toEqual(["💨Fast: auto-off(6s>=5s)", "💨Fast: auto-on"]);
|
| 1050 |
+
});
|
| 1051 |
+
});
|
src/auto-reply/reply/agent-runner-cli-dispatch.ts
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Builds CLI runtime dispatch inputs for agent runner executions.
|
| 2 |
+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
| 3 |
+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
| 4 |
+
import { runCliAgent } from "../../agents/cli-runner.js";
|
| 5 |
+
import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
|
| 6 |
+
import type { MediaImageLayout } from "../../agents/embedded-agent-runner/run/prompt-image-metadata.js";
|
| 7 |
+
import { extractToolResultText } from "../../agents/embedded-agent-tool-results.js";
|
| 8 |
+
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent.js";
|
| 9 |
+
import {
|
| 10 |
+
DEFAULT_FAST_MODE_AUTO_ON_SECONDS,
|
| 11 |
+
formatFastModeAutoProgressText,
|
| 12 |
+
resolveFastModeForElapsed,
|
| 13 |
+
type FastModeAutoProgressState,
|
| 14 |
+
} from "../../agents/fast-mode.js";
|
| 15 |
+
import {
|
| 16 |
+
isAgentRunRestartAbortReason,
|
| 17 |
+
resolveAgentRunAbortLifecycleFields,
|
| 18 |
+
resolveAgentRunErrorLifecycleFields,
|
| 19 |
+
} from "../../agents/run-termination.js";
|
| 20 |
+
import { inferToolMetaFromArgsCore, isCommandBearingToolCall } from "../../agents/tool-display.js";
|
| 21 |
+
import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
|
| 22 |
+
import type { AgentEventPayload } from "../../infra/agent-events.js";
|
| 23 |
+
import { emitAgentEvent, withAgentRunLifecycleGeneration } from "../../infra/agent-events.js";
|
| 24 |
+
import { isAgentPlanProgressToolName } from "../../session-cards/progress-card-channel-summary.js";
|
| 25 |
+
import { FAST_MODE_AUTO_PROGRESS_KIND, type ReplyPayload } from "../reply-payload.js";
|
| 26 |
+
import { formatToolAggregate } from "../tool-meta.js";
|
| 27 |
+
import type { GetReplyOptions } from "../types.js";
|
| 28 |
+
import {
|
| 29 |
+
type AgentEventDeliveryStartOrder,
|
| 30 |
+
createAgentEventBridge,
|
| 31 |
+
createAgentEventDeliveryStartOrder,
|
| 32 |
+
} from "./agent-event-bridge.js";
|
| 33 |
+
import { resolveAgentLifecycleTerminalMetadata } from "./agent-lifecycle-terminal.js";
|
| 34 |
+
|
| 35 |
+
type RunCliAgentInternalParams = RunCliAgentParams & {
|
| 36 |
+
mediaImageLayout?: MediaImageLayout;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
type AgentEventBridge = {
|
| 40 |
+
unsubscribe: () => void;
|
| 41 |
+
drain: () => Promise<void>;
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
async function stopAgentEventBridges(bridges: readonly AgentEventBridge[]): Promise<void> {
|
| 45 |
+
for (const bridge of bridges) {
|
| 46 |
+
bridge.unsubscribe();
|
| 47 |
+
}
|
| 48 |
+
for (const bridge of bridges) {
|
| 49 |
+
await bridge.drain();
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function createAssistantTextBridge(params: {
|
| 54 |
+
runId: string;
|
| 55 |
+
suppressed?: boolean;
|
| 56 |
+
deliver?: (text: string) => Promise<boolean | void>;
|
| 57 |
+
startOrder?: AgentEventDeliveryStartOrder;
|
| 58 |
+
}) {
|
| 59 |
+
let lastText: string | undefined;
|
| 60 |
+
return createAgentEventBridge({
|
| 61 |
+
runId: params.runId,
|
| 62 |
+
suppressed: params.suppressed,
|
| 63 |
+
deliver: params.deliver,
|
| 64 |
+
startOrder: params.startOrder,
|
| 65 |
+
read: (evt) => {
|
| 66 |
+
if (evt.stream !== "assistant") {
|
| 67 |
+
return undefined;
|
| 68 |
+
}
|
| 69 |
+
const text = typeof evt.data.text === "string" ? evt.data.text : undefined;
|
| 70 |
+
if (text === undefined || text === lastText) {
|
| 71 |
+
return undefined;
|
| 72 |
+
}
|
| 73 |
+
lastText = text;
|
| 74 |
+
return text;
|
| 75 |
+
},
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
type ReasoningTextPayload = {
|
| 80 |
+
text: string;
|
| 81 |
+
isReasoningSnapshot?: boolean;
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
type ReasoningProgressPayload = {
|
| 85 |
+
progressTokens: number;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
export function createCliReasoningStreamBridge(
|
| 89 |
+
onReasoningStream: GetReplyOptions["onReasoningStream"] | undefined,
|
| 90 |
+
): ((payload: ReasoningTextPayload) => Promise<void>) | undefined {
|
| 91 |
+
if (!onReasoningStream) {
|
| 92 |
+
return undefined;
|
| 93 |
+
}
|
| 94 |
+
return async ({ text, isReasoningSnapshot }) => {
|
| 95 |
+
await onReasoningStream({
|
| 96 |
+
text,
|
| 97 |
+
...(isReasoningSnapshot ? { isReasoningSnapshot } : {}),
|
| 98 |
+
requiresReasoningProgressOptIn: true,
|
| 99 |
+
});
|
| 100 |
+
};
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function createReasoningTextBridge(params: {
|
| 104 |
+
runId: string;
|
| 105 |
+
suppressed?: boolean;
|
| 106 |
+
deliver?: (payload: ReasoningTextPayload) => Promise<void>;
|
| 107 |
+
startOrder?: AgentEventDeliveryStartOrder;
|
| 108 |
+
}) {
|
| 109 |
+
let lastText: string | undefined;
|
| 110 |
+
return createAgentEventBridge({
|
| 111 |
+
runId: params.runId,
|
| 112 |
+
suppressed: params.suppressed,
|
| 113 |
+
deliver: params.deliver,
|
| 114 |
+
startOrder: params.startOrder,
|
| 115 |
+
read: (evt) => {
|
| 116 |
+
if (evt.stream !== "thinking") {
|
| 117 |
+
return undefined;
|
| 118 |
+
}
|
| 119 |
+
const text = typeof evt.data.text === "string" ? evt.data.text : undefined;
|
| 120 |
+
if (text === undefined || text === lastText) {
|
| 121 |
+
return undefined;
|
| 122 |
+
}
|
| 123 |
+
lastText = text;
|
| 124 |
+
return {
|
| 125 |
+
text,
|
| 126 |
+
...(evt.data.isReasoningSnapshot === true ? { isReasoningSnapshot: true } : {}),
|
| 127 |
+
};
|
| 128 |
+
},
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function createReasoningProgressBridge(params: {
|
| 133 |
+
runId: string;
|
| 134 |
+
suppressed?: boolean;
|
| 135 |
+
deliver?: (payload: ReasoningProgressPayload) => Promise<void>;
|
| 136 |
+
startOrder?: AgentEventDeliveryStartOrder;
|
| 137 |
+
}) {
|
| 138 |
+
let lastProgressTokens: number | undefined;
|
| 139 |
+
return createAgentEventBridge({
|
| 140 |
+
runId: params.runId,
|
| 141 |
+
suppressed: params.suppressed,
|
| 142 |
+
deliver: params.deliver,
|
| 143 |
+
startOrder: params.startOrder,
|
| 144 |
+
read: (evt) => {
|
| 145 |
+
if (evt.stream !== "thinking") {
|
| 146 |
+
return undefined;
|
| 147 |
+
}
|
| 148 |
+
const progressTokens = evt.data.progressTokens;
|
| 149 |
+
if (
|
| 150 |
+
typeof progressTokens !== "number" ||
|
| 151 |
+
!Number.isFinite(progressTokens) ||
|
| 152 |
+
progressTokens <= 0 ||
|
| 153 |
+
progressTokens === lastProgressTokens
|
| 154 |
+
) {
|
| 155 |
+
return undefined;
|
| 156 |
+
}
|
| 157 |
+
lastProgressTokens = progressTokens;
|
| 158 |
+
return { progressTokens };
|
| 159 |
+
},
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
type CommentaryTextPayload = {
|
| 164 |
+
text: string;
|
| 165 |
+
itemId?: string;
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
function readCommentaryTextPayload(evt: AgentEventPayload): CommentaryTextPayload | undefined {
|
| 169 |
+
if (evt.stream !== "item" || evt.data.kind !== "preamble") {
|
| 170 |
+
return undefined;
|
| 171 |
+
}
|
| 172 |
+
const text = typeof evt.data.progressText === "string" ? evt.data.progressText.trim() : "";
|
| 173 |
+
if (!text) {
|
| 174 |
+
return undefined;
|
| 175 |
+
}
|
| 176 |
+
return {
|
| 177 |
+
text,
|
| 178 |
+
...(typeof evt.data.itemId === "string" ? { itemId: evt.data.itemId } : {}),
|
| 179 |
+
};
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
type CliToolEventPayload = {
|
| 183 |
+
name: string | undefined;
|
| 184 |
+
phase: "start" | "update" | "result";
|
| 185 |
+
args: Record<string, unknown> | undefined;
|
| 186 |
+
toolCallId?: string;
|
| 187 |
+
isError?: boolean;
|
| 188 |
+
result?: unknown;
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
export function keepCliSessionBindingOnlyWhenReused(params: {
|
| 192 |
+
result: EmbeddedAgentRunResult;
|
| 193 |
+
existingSessionId?: string;
|
| 194 |
+
onDroppedReplacement?: () => void;
|
| 195 |
+
}): EmbeddedAgentRunResult {
|
| 196 |
+
const existingSessionId = normalizeOptionalString(params.existingSessionId);
|
| 197 |
+
const agentMeta = params.result.meta.agentMeta;
|
| 198 |
+
const returnedSessionId = normalizeOptionalString(agentMeta?.cliSessionBinding?.sessionId);
|
| 199 |
+
const shouldClearStoredSession = agentMeta?.clearCliSessionBinding === true;
|
| 200 |
+
if (
|
| 201 |
+
agentMeta === undefined ||
|
| 202 |
+
(!shouldClearStoredSession && existingSessionId === undefined) ||
|
| 203 |
+
returnedSessionId === existingSessionId
|
| 204 |
+
) {
|
| 205 |
+
return params.result;
|
| 206 |
+
}
|
| 207 |
+
if (returnedSessionId || shouldClearStoredSession) {
|
| 208 |
+
params.onDroppedReplacement?.();
|
| 209 |
+
}
|
| 210 |
+
return {
|
| 211 |
+
...params.result,
|
| 212 |
+
meta: {
|
| 213 |
+
...params.result.meta,
|
| 214 |
+
agentMeta: {
|
| 215 |
+
...agentMeta,
|
| 216 |
+
sessionId: "",
|
| 217 |
+
cliSessionBinding: undefined,
|
| 218 |
+
clearCliSessionBinding: undefined,
|
| 219 |
+
},
|
| 220 |
+
},
|
| 221 |
+
};
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function createToolEventBridge(params: {
|
| 225 |
+
runId: string;
|
| 226 |
+
suppressed?: boolean;
|
| 227 |
+
deliver?: (payload: CliToolEventPayload) => Promise<void>;
|
| 228 |
+
startOrder?: AgentEventDeliveryStartOrder;
|
| 229 |
+
}) {
|
| 230 |
+
return createAgentEventBridge({
|
| 231 |
+
runId: params.runId,
|
| 232 |
+
suppressed: params.suppressed,
|
| 233 |
+
deliver: params.deliver,
|
| 234 |
+
startOrder: params.startOrder,
|
| 235 |
+
read: (evt) => {
|
| 236 |
+
if (evt.stream !== "tool") {
|
| 237 |
+
return undefined;
|
| 238 |
+
}
|
| 239 |
+
const phaseValue = evt.data.phase;
|
| 240 |
+
if (phaseValue !== "start" && phaseValue !== "update" && phaseValue !== "result") {
|
| 241 |
+
return undefined;
|
| 242 |
+
}
|
| 243 |
+
const phase: CliToolEventPayload["phase"] =
|
| 244 |
+
phaseValue === "start" ? "start" : phaseValue === "update" ? "update" : "result";
|
| 245 |
+
return {
|
| 246 |
+
name: typeof evt.data.name === "string" ? evt.data.name : undefined,
|
| 247 |
+
phase,
|
| 248 |
+
args: isRecord(evt.data.args) ? evt.data.args : undefined,
|
| 249 |
+
toolCallId: typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : undefined,
|
| 250 |
+
...(phase === "result"
|
| 251 |
+
? {
|
| 252 |
+
isError: evt.data.isError === true,
|
| 253 |
+
result: evt.data.result,
|
| 254 |
+
}
|
| 255 |
+
: {}),
|
| 256 |
+
};
|
| 257 |
+
},
|
| 258 |
+
});
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/**
|
| 262 |
+
* Tracks CLI tool start/result events and renders the same durable tool
|
| 263 |
+
* summaries the embedded runner emits: a formatToolAggregate line per result
|
| 264 |
+
* (args-derived meta captured at start), plus the output block under full
|
| 265 |
+
* verbose. Keeps CLI runs at tool-summary parity with embedded runs.
|
| 266 |
+
*/
|
| 267 |
+
export function createCliToolSummaryTracker(params: {
|
| 268 |
+
detailMode?: "explain" | "raw";
|
| 269 |
+
commandDetailsVisible: boolean;
|
| 270 |
+
shouldEmitToolResult: () => boolean;
|
| 271 |
+
shouldEmitToolOutput: () => boolean;
|
| 272 |
+
deliver: (payload: { text: string; isError?: boolean }) => Promise<void> | void;
|
| 273 |
+
}) {
|
| 274 |
+
const toolByCallId = new Map<string, { name: string; meta?: string; commandBearing: boolean }>();
|
| 275 |
+
return {
|
| 276 |
+
noteToolEvent: async (payload: CliToolEventPayload): Promise<boolean> => {
|
| 277 |
+
if (payload.phase === "start") {
|
| 278 |
+
if (payload.toolCallId && payload.name) {
|
| 279 |
+
toolByCallId.set(payload.toolCallId, {
|
| 280 |
+
name: payload.name,
|
| 281 |
+
meta: inferToolMetaFromArgsCore(payload.name, payload.args, {
|
| 282 |
+
detailMode: params.detailMode ?? "explain",
|
| 283 |
+
}),
|
| 284 |
+
commandBearing: isCommandBearingToolCall(payload.name, payload.args),
|
| 285 |
+
});
|
| 286 |
+
}
|
| 287 |
+
return false;
|
| 288 |
+
}
|
| 289 |
+
if (payload.phase !== "result") {
|
| 290 |
+
return false;
|
| 291 |
+
}
|
| 292 |
+
const storedTool = payload.toolCallId ? toolByCallId.get(payload.toolCallId) : undefined;
|
| 293 |
+
const toolName = payload.name ?? storedTool?.name;
|
| 294 |
+
const meta =
|
| 295 |
+
params.commandDetailsVisible || !storedTool?.commandBearing ? storedTool?.meta : undefined;
|
| 296 |
+
if (payload.toolCallId) {
|
| 297 |
+
toolByCallId.delete(payload.toolCallId);
|
| 298 |
+
}
|
| 299 |
+
if (payload.isError !== true && isAgentPlanProgressToolName(toolName)) {
|
| 300 |
+
return false;
|
| 301 |
+
}
|
| 302 |
+
if (!params.shouldEmitToolResult()) {
|
| 303 |
+
return storedTool?.commandBearing === true;
|
| 304 |
+
}
|
| 305 |
+
const aggregate = formatToolAggregate(toolName, meta ? [meta] : undefined, {
|
| 306 |
+
markdown: true,
|
| 307 |
+
});
|
| 308 |
+
let text = aggregate;
|
| 309 |
+
if (params.shouldEmitToolOutput()) {
|
| 310 |
+
const output = extractToolResultText(payload.result)?.trim();
|
| 311 |
+
if (output) {
|
| 312 |
+
text = `${aggregate}\n\`\`\`txt\n${output}\n\`\`\``;
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
if (!text.trim()) {
|
| 316 |
+
return storedTool?.commandBearing === true;
|
| 317 |
+
}
|
| 318 |
+
await params.deliver({ text, ...(payload.isError === true ? { isError: true } : {}) });
|
| 319 |
+
return storedTool?.commandBearing === true;
|
| 320 |
+
},
|
| 321 |
+
};
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
function createCommentaryEventBridge(params: {
|
| 325 |
+
runId: string;
|
| 326 |
+
suppressed?: boolean;
|
| 327 |
+
deliver?: (payload: CommentaryTextPayload) => Promise<void>;
|
| 328 |
+
startOrder?: AgentEventDeliveryStartOrder;
|
| 329 |
+
}) {
|
| 330 |
+
return createAgentEventBridge({
|
| 331 |
+
runId: params.runId,
|
| 332 |
+
suppressed: params.suppressed,
|
| 333 |
+
deliver: params.deliver,
|
| 334 |
+
startOrder: params.startOrder,
|
| 335 |
+
read: readCommentaryTextPayload,
|
| 336 |
+
});
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
function createPlanUpdateBridge(params: {
|
| 340 |
+
runId: string;
|
| 341 |
+
suppressed?: boolean;
|
| 342 |
+
deliver?: GetReplyOptions["onPlanUpdate"];
|
| 343 |
+
startOrder?: AgentEventDeliveryStartOrder;
|
| 344 |
+
}) {
|
| 345 |
+
const deliver = params.deliver;
|
| 346 |
+
return createAgentEventBridge({
|
| 347 |
+
runId: params.runId,
|
| 348 |
+
suppressed: params.suppressed,
|
| 349 |
+
// GetReplyOptions callbacks may return void; the bridge awaits promises.
|
| 350 |
+
deliver: deliver
|
| 351 |
+
? async (payload: Parameters<NonNullable<GetReplyOptions["onPlanUpdate"]>>[0]) => {
|
| 352 |
+
await deliver(payload);
|
| 353 |
+
}
|
| 354 |
+
: undefined,
|
| 355 |
+
startOrder: params.startOrder,
|
| 356 |
+
read: (evt) => {
|
| 357 |
+
if (evt.stream !== "plan") {
|
| 358 |
+
return undefined;
|
| 359 |
+
}
|
| 360 |
+
return {
|
| 361 |
+
phase: normalizeOptionalString(evt.data.phase),
|
| 362 |
+
title: normalizeOptionalString(evt.data.title),
|
| 363 |
+
explanation: normalizeOptionalString(evt.data.explanation),
|
| 364 |
+
...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
|
| 365 |
+
steps: normalizeAgentPlanSteps(evt.data.steps),
|
| 366 |
+
source: normalizeOptionalString(evt.data.source),
|
| 367 |
+
};
|
| 368 |
+
},
|
| 369 |
+
});
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
function createToolBoundaryBridge(params: {
|
| 373 |
+
runId: string;
|
| 374 |
+
suppressed?: boolean;
|
| 375 |
+
deliver?: () => Promise<void>;
|
| 376 |
+
}) {
|
| 377 |
+
return createAgentEventBridge({
|
| 378 |
+
runId: params.runId,
|
| 379 |
+
suppressed: params.suppressed,
|
| 380 |
+
deliver: params.deliver,
|
| 381 |
+
read: (evt) => {
|
| 382 |
+
if (evt.stream !== "tool") {
|
| 383 |
+
return undefined;
|
| 384 |
+
}
|
| 385 |
+
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
| 386 |
+
return ["completed", "end", "error", "result"].includes(phase) ? true : undefined;
|
| 387 |
+
},
|
| 388 |
+
});
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
type RunCliAgentWithLifecycleParams = {
|
| 392 |
+
runId: string;
|
| 393 |
+
lifecycleGeneration?: string;
|
| 394 |
+
provider: string;
|
| 395 |
+
runParams: RunCliAgentInternalParams;
|
| 396 |
+
startedAt?: number;
|
| 397 |
+
emitLifecycleTerminal?: boolean;
|
| 398 |
+
onAgentRunStart?: () => void;
|
| 399 |
+
suppressAssistantBridge?: boolean;
|
| 400 |
+
/**
|
| 401 |
+
* Stamped before every delivered CLI progress event (assistant, reasoning,
|
| 402 |
+
* tool, commentary, fast-mode). Callers wire this to the reply operation's
|
| 403 |
+
* activity evidence; per-callback stamps at call sites drift and a missed
|
| 404 |
+
* stamp lets stale-takeover reclaim a healthy run.
|
| 405 |
+
*/
|
| 406 |
+
onActivity?: () => void;
|
| 407 |
+
preserveProgressCallbackStartOrder?: boolean;
|
| 408 |
+
onAssistantText?: (text: string) => Promise<boolean | void>;
|
| 409 |
+
onReasoningText?: (payload: ReasoningTextPayload) => Promise<void>;
|
| 410 |
+
onReasoningProgress?: (payload: ReasoningProgressPayload) => Promise<void>;
|
| 411 |
+
onCompactionStart?: GetReplyOptions["onCompactionStart"];
|
| 412 |
+
onCompactionEnd?: GetReplyOptions["onCompactionEnd"];
|
| 413 |
+
onToolEvent?: (payload: CliToolEventPayload) => Promise<void>;
|
| 414 |
+
onCommentaryText?: (payload: CommentaryTextPayload) => Promise<void>;
|
| 415 |
+
onPlanUpdate?: GetReplyOptions["onPlanUpdate"];
|
| 416 |
+
onFastModeAutoProgress?: (payload: ReplyPayload) => Promise<void>;
|
| 417 |
+
onErrorBeforeLifecycle?: (err: unknown) => Promise<void>;
|
| 418 |
+
transformResult?: (result: EmbeddedAgentRunResult) => EmbeddedAgentRunResult;
|
| 419 |
+
};
|
| 420 |
+
|
| 421 |
+
export function runCliAgentWithLifecycle(
|
| 422 |
+
params: RunCliAgentWithLifecycleParams,
|
| 423 |
+
): Promise<EmbeddedAgentRunResult> {
|
| 424 |
+
if (!params.lifecycleGeneration) {
|
| 425 |
+
return runCliAgentWithLifecycleInternal(params);
|
| 426 |
+
}
|
| 427 |
+
return withAgentRunLifecycleGeneration(params.lifecycleGeneration, () =>
|
| 428 |
+
runCliAgentWithLifecycleInternal(params),
|
| 429 |
+
);
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
async function runCliAgentWithLifecycleInternal(
|
| 433 |
+
params: RunCliAgentWithLifecycleParams,
|
| 434 |
+
): Promise<EmbeddedAgentRunResult> {
|
| 435 |
+
const startedAt = params.startedAt ?? Date.now();
|
| 436 |
+
const fastModeStartedAtMs = params.runParams.fastModeStartedAtMs ?? startedAt;
|
| 437 |
+
const fastModeAutoOnSeconds =
|
| 438 |
+
params.runParams.fastModeAutoOnSeconds ?? DEFAULT_FAST_MODE_AUTO_ON_SECONDS;
|
| 439 |
+
const fastModeAutoProgressState: FastModeAutoProgressState = params.runParams
|
| 440 |
+
.fastModeAutoProgressState ?? {
|
| 441 |
+
offAnnounced: false,
|
| 442 |
+
resetAnnounced: false,
|
| 443 |
+
};
|
| 444 |
+
const emitFastModeAutoProgress = async (payload: {
|
| 445 |
+
enabled: boolean;
|
| 446 |
+
elapsedSeconds: number;
|
| 447 |
+
fastAutoOnSeconds?: number;
|
| 448 |
+
}) => {
|
| 449 |
+
const summary = formatFastModeAutoProgressText(payload);
|
| 450 |
+
emitAgentEvent({
|
| 451 |
+
runId: params.runId,
|
| 452 |
+
stream: "item",
|
| 453 |
+
data: {
|
| 454 |
+
kind: "status",
|
| 455 |
+
title: "Fast",
|
| 456 |
+
phase: "update",
|
| 457 |
+
summary,
|
| 458 |
+
},
|
| 459 |
+
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
|
| 460 |
+
});
|
| 461 |
+
try {
|
| 462 |
+
await params.onFastModeAutoProgress?.({
|
| 463 |
+
text: summary,
|
| 464 |
+
channelData: { openclawProgressKind: FAST_MODE_AUTO_PROGRESS_KIND },
|
| 465 |
+
});
|
| 466 |
+
} catch {
|
| 467 |
+
// Progress hints are best-effort; a channel failure must not fail the agent turn.
|
| 468 |
+
}
|
| 469 |
+
};
|
| 470 |
+
const maybeAnnounceFastModeAutoOff = async () => {
|
| 471 |
+
if (params.runParams.fastMode !== "auto" || fastModeAutoProgressState.offAnnounced) {
|
| 472 |
+
return;
|
| 473 |
+
}
|
| 474 |
+
const next = resolveFastModeForElapsed({
|
| 475 |
+
mode: "auto",
|
| 476 |
+
startedAtMs: fastModeStartedAtMs,
|
| 477 |
+
fastAutoOnSeconds: fastModeAutoOnSeconds,
|
| 478 |
+
});
|
| 479 |
+
if (next.enabled) {
|
| 480 |
+
return;
|
| 481 |
+
}
|
| 482 |
+
fastModeAutoProgressState.offAnnounced = true;
|
| 483 |
+
await emitFastModeAutoProgress(next);
|
| 484 |
+
};
|
| 485 |
+
const maybeEmitFastModeAutoReset = async () => {
|
| 486 |
+
if (
|
| 487 |
+
params.runParams.fastMode !== "auto" ||
|
| 488 |
+
!fastModeAutoProgressState.offAnnounced ||
|
| 489 |
+
fastModeAutoProgressState.resetAnnounced
|
| 490 |
+
) {
|
| 491 |
+
return;
|
| 492 |
+
}
|
| 493 |
+
fastModeAutoProgressState.resetAnnounced = true;
|
| 494 |
+
await emitFastModeAutoProgress({
|
| 495 |
+
enabled: true,
|
| 496 |
+
elapsedSeconds: 0,
|
| 497 |
+
fastAutoOnSeconds: fastModeAutoOnSeconds,
|
| 498 |
+
});
|
| 499 |
+
};
|
| 500 |
+
const emitLifecycleTerminal = params.emitLifecycleTerminal ?? true;
|
| 501 |
+
params.onAgentRunStart?.();
|
| 502 |
+
emitAgentEvent({
|
| 503 |
+
runId: params.runId,
|
| 504 |
+
...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
|
| 505 |
+
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
|
| 506 |
+
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
|
| 507 |
+
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
|
| 508 |
+
stream: "lifecycle",
|
| 509 |
+
data: {
|
| 510 |
+
phase: "start",
|
| 511 |
+
startedAt,
|
| 512 |
+
},
|
| 513 |
+
});
|
| 514 |
+
// One delivery-independent activity seam for every CLI agent event.
|
| 515 |
+
// Suppressed (silentExpected) runs still emit real events and must keep
|
| 516 |
+
// stamping, or a healthy silent stream looks stale to the takeover window.
|
| 517 |
+
const activityBridge = params.onActivity
|
| 518 |
+
? createAgentEventBridge<Record<string, never>>({
|
| 519 |
+
runId: params.runId,
|
| 520 |
+
read: () => ({}),
|
| 521 |
+
deliver: async () => {
|
| 522 |
+
params.onActivity?.();
|
| 523 |
+
},
|
| 524 |
+
})
|
| 525 |
+
: undefined;
|
| 526 |
+
const progressStartOrder = params.preserveProgressCallbackStartOrder
|
| 527 |
+
? createAgentEventDeliveryStartOrder()
|
| 528 |
+
: undefined;
|
| 529 |
+
const assistantBridge = createAssistantTextBridge({
|
| 530 |
+
runId: params.runId,
|
| 531 |
+
suppressed: params.suppressAssistantBridge,
|
| 532 |
+
deliver: params.onAssistantText,
|
| 533 |
+
startOrder: progressStartOrder,
|
| 534 |
+
});
|
| 535 |
+
let finalReasoningText: string | undefined;
|
| 536 |
+
const reasoningBridge = createReasoningTextBridge({
|
| 537 |
+
runId: params.runId,
|
| 538 |
+
suppressed: params.suppressAssistantBridge,
|
| 539 |
+
startOrder: progressStartOrder,
|
| 540 |
+
deliver: async (payload: ReasoningTextPayload) => {
|
| 541 |
+
finalReasoningText = normalizeOptionalString(payload.text);
|
| 542 |
+
await params.onReasoningText?.(payload);
|
| 543 |
+
},
|
| 544 |
+
});
|
| 545 |
+
const reasoningProgressBridge = createReasoningProgressBridge({
|
| 546 |
+
runId: params.runId,
|
| 547 |
+
suppressed: params.suppressAssistantBridge,
|
| 548 |
+
deliver: params.onReasoningProgress,
|
| 549 |
+
startOrder: progressStartOrder,
|
| 550 |
+
});
|
| 551 |
+
const compactionBridge = createAgentEventBridge<
|
| 552 |
+
{ phase: "start" } | { completed: boolean; phase: "end" }
|
| 553 |
+
>({
|
| 554 |
+
runId: params.runId,
|
| 555 |
+
suppressed: params.suppressAssistantBridge,
|
| 556 |
+
startOrder: progressStartOrder,
|
| 557 |
+
deliver: async (event) => {
|
| 558 |
+
if (event.phase === "start") {
|
| 559 |
+
await params.onCompactionStart?.();
|
| 560 |
+
} else {
|
| 561 |
+
await params.onCompactionEnd?.({ completed: event.completed });
|
| 562 |
+
}
|
| 563 |
+
},
|
| 564 |
+
read: (evt) => {
|
| 565 |
+
if (evt.stream !== "compaction") {
|
| 566 |
+
return undefined;
|
| 567 |
+
}
|
| 568 |
+
if (evt.data.phase === "start") {
|
| 569 |
+
return { phase: "start" };
|
| 570 |
+
}
|
| 571 |
+
return evt.data.phase === "end"
|
| 572 |
+
? { phase: "end", completed: evt.data.completed === true }
|
| 573 |
+
: undefined;
|
| 574 |
+
},
|
| 575 |
+
});
|
| 576 |
+
const toolBridge = createToolEventBridge({
|
| 577 |
+
runId: params.runId,
|
| 578 |
+
suppressed: params.suppressAssistantBridge,
|
| 579 |
+
deliver: params.onToolEvent,
|
| 580 |
+
startOrder: progressStartOrder,
|
| 581 |
+
});
|
| 582 |
+
const commentaryBridge = createCommentaryEventBridge({
|
| 583 |
+
runId: params.runId,
|
| 584 |
+
suppressed: params.suppressAssistantBridge,
|
| 585 |
+
deliver: params.onCommentaryText,
|
| 586 |
+
startOrder: progressStartOrder,
|
| 587 |
+
});
|
| 588 |
+
const planBridge = createPlanUpdateBridge({
|
| 589 |
+
runId: params.runId,
|
| 590 |
+
suppressed: params.suppressAssistantBridge,
|
| 591 |
+
deliver: params.onPlanUpdate,
|
| 592 |
+
startOrder: progressStartOrder,
|
| 593 |
+
});
|
| 594 |
+
const toolBoundaryBridge = createToolBoundaryBridge({
|
| 595 |
+
runId: params.runId,
|
| 596 |
+
suppressed: params.suppressAssistantBridge,
|
| 597 |
+
deliver: maybeAnnounceFastModeAutoOff,
|
| 598 |
+
});
|
| 599 |
+
const bridges = [
|
| 600 |
+
activityBridge,
|
| 601 |
+
assistantBridge,
|
| 602 |
+
reasoningBridge,
|
| 603 |
+
reasoningProgressBridge,
|
| 604 |
+
compactionBridge,
|
| 605 |
+
toolBridge,
|
| 606 |
+
commentaryBridge,
|
| 607 |
+
planBridge,
|
| 608 |
+
toolBoundaryBridge,
|
| 609 |
+
].filter((bridge): bridge is AgentEventBridge => bridge !== undefined);
|
| 610 |
+
let lifecycleTerminalEmitted = false;
|
| 611 |
+
try {
|
| 612 |
+
const rawResult = await runCliAgent({
|
| 613 |
+
...params.runParams,
|
| 614 |
+
emitCommentaryText: params.runParams.emitCommentaryText ?? Boolean(params.onCommentaryText),
|
| 615 |
+
});
|
| 616 |
+
const restartAbortReason = params.runParams.abortSignal?.reason;
|
| 617 |
+
if (isAgentRunRestartAbortReason(restartAbortReason)) {
|
| 618 |
+
throw restartAbortReason;
|
| 619 |
+
}
|
| 620 |
+
const result = params.transformResult?.(rawResult) ?? rawResult;
|
| 621 |
+
await stopAgentEventBridges(bridges);
|
| 622 |
+
|
| 623 |
+
const cliText = normalizeOptionalString(result.payloads?.[0]?.text);
|
| 624 |
+
const durableReasoningText = normalizeOptionalString(finalReasoningText);
|
| 625 |
+
const resultWithReasoning = durableReasoningText
|
| 626 |
+
? {
|
| 627 |
+
...result,
|
| 628 |
+
payloads: [{ text: durableReasoningText, isReasoning: true }, ...(result.payloads ?? [])],
|
| 629 |
+
}
|
| 630 |
+
: result;
|
| 631 |
+
if (cliText) {
|
| 632 |
+
emitAgentEvent({
|
| 633 |
+
runId: params.runId,
|
| 634 |
+
stream: "assistant",
|
| 635 |
+
data: { text: cliText },
|
| 636 |
+
});
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
if (emitLifecycleTerminal) {
|
| 640 |
+
emitAgentEvent({
|
| 641 |
+
runId: params.runId,
|
| 642 |
+
...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
|
| 643 |
+
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
|
| 644 |
+
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
|
| 645 |
+
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
|
| 646 |
+
stream: "lifecycle",
|
| 647 |
+
data: {
|
| 648 |
+
phase: "end",
|
| 649 |
+
startedAt,
|
| 650 |
+
endedAt: Date.now(),
|
| 651 |
+
...resolveAgentLifecycleTerminalMetadata(result.meta),
|
| 652 |
+
...resolveAgentRunAbortLifecycleFields(params.runParams.abortSignal),
|
| 653 |
+
},
|
| 654 |
+
});
|
| 655 |
+
lifecycleTerminalEmitted = true;
|
| 656 |
+
}
|
| 657 |
+
return resultWithReasoning;
|
| 658 |
+
} catch (err) {
|
| 659 |
+
await stopAgentEventBridges(bridges);
|
| 660 |
+
await params.onErrorBeforeLifecycle?.(err);
|
| 661 |
+
if (emitLifecycleTerminal) {
|
| 662 |
+
emitAgentEvent({
|
| 663 |
+
runId: params.runId,
|
| 664 |
+
...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
|
| 665 |
+
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
|
| 666 |
+
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
|
| 667 |
+
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
|
| 668 |
+
stream: "lifecycle",
|
| 669 |
+
data: {
|
| 670 |
+
phase: "error",
|
| 671 |
+
startedAt,
|
| 672 |
+
endedAt: Date.now(),
|
| 673 |
+
error: String(err),
|
| 674 |
+
...resolveAgentRunErrorLifecycleFields(err, params.runParams.abortSignal),
|
| 675 |
+
},
|
| 676 |
+
});
|
| 677 |
+
lifecycleTerminalEmitted = true;
|
| 678 |
+
}
|
| 679 |
+
throw err;
|
| 680 |
+
} finally {
|
| 681 |
+
for (const bridge of bridges) {
|
| 682 |
+
bridge.unsubscribe();
|
| 683 |
+
}
|
| 684 |
+
if (params.runParams.isFinalFallbackAttempt !== false) {
|
| 685 |
+
await maybeEmitFastModeAutoReset();
|
| 686 |
+
}
|
| 687 |
+
if (emitLifecycleTerminal && !lifecycleTerminalEmitted) {
|
| 688 |
+
emitAgentEvent({
|
| 689 |
+
runId: params.runId,
|
| 690 |
+
...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
|
| 691 |
+
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
|
| 692 |
+
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
|
| 693 |
+
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
|
| 694 |
+
stream: "lifecycle",
|
| 695 |
+
data: {
|
| 696 |
+
phase: "error",
|
| 697 |
+
startedAt,
|
| 698 |
+
endedAt: Date.now(),
|
| 699 |
+
error: "CLI run completed without lifecycle terminal event",
|
| 700 |
+
...resolveAgentRunAbortLifecycleFields(params.runParams.abortSignal),
|
| 701 |
+
},
|
| 702 |
+
});
|
| 703 |
+
}
|
| 704 |
+
}
|
| 705 |
+
}
|
src/auto-reply/reply/agent-runner-command-output.test.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js";
|
| 3 |
+
|
| 4 |
+
const BASH_ARGS = { command: "nope-not-a-command", description: "run missing binary" };
|
| 5 |
+
|
| 6 |
+
function buildFromCliResult(overrides: Record<string, unknown>) {
|
| 7 |
+
return buildCommandOutputFromToolResultEvent({
|
| 8 |
+
stream: "tool",
|
| 9 |
+
data: { phase: "result", name: "Bash", toolCallId: "call-1", args: BASH_ARGS, ...overrides },
|
| 10 |
+
});
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
describe("buildCommandOutputFromToolResultEvent", () => {
|
| 14 |
+
it("reports a CLI command failure whose result is only text", () => {
|
| 15 |
+
// CLI backends report the outcome plus raw content, never a structured
|
| 16 |
+
// record, so requiring a structured field dropped the failure entirely.
|
| 17 |
+
const built = buildFromCliResult({
|
| 18 |
+
isError: true,
|
| 19 |
+
result: "bash: nope-not-a-command: command not found",
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
expect(built?.status).toBe("failed");
|
| 23 |
+
expect(built?.output).toBe("bash: nope-not-a-command: command not found");
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
it("reads the outcome from streamed text blocks", () => {
|
| 27 |
+
const built = buildFromCliResult({
|
| 28 |
+
isError: true,
|
| 29 |
+
result: [
|
| 30 |
+
{ type: "text", text: "line one" },
|
| 31 |
+
{ type: "text", text: "line two" },
|
| 32 |
+
],
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
expect(built?.status).toBe("failed");
|
| 36 |
+
expect(built?.output).toBe("line one\nline two");
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
it("describes the command that ran instead of what it printed", () => {
|
| 40 |
+
const built = buildFromCliResult({ isError: true, result: "some noisy stderr" });
|
| 41 |
+
|
| 42 |
+
// The title drives the visible progress line; without it the line would
|
| 43 |
+
// replace the request with the tool's output.
|
| 44 |
+
expect(built?.title).toContain("nope-not-a-command");
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it("marks a successful CLI command completed", () => {
|
| 48 |
+
expect(buildFromCliResult({ isError: false, result: "alpha" })?.status).toBe("completed");
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it("projects a safe terminal state from a namespaced command-bearing result", () => {
|
| 52 |
+
const built = buildCommandOutputFromToolResultEvent({
|
| 53 |
+
stream: "tool",
|
| 54 |
+
data: {
|
| 55 |
+
phase: "result",
|
| 56 |
+
name: "server.exec",
|
| 57 |
+
toolCallId: "call-1",
|
| 58 |
+
commandBearing: true,
|
| 59 |
+
isError: false,
|
| 60 |
+
},
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
expect(built).toMatchObject({
|
| 64 |
+
name: "server.exec",
|
| 65 |
+
status: "completed",
|
| 66 |
+
toolCallId: "call-1",
|
| 67 |
+
});
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("prefers an explicit status and structured fields when present", () => {
|
| 71 |
+
const built = buildCommandOutputFromToolResultEvent({
|
| 72 |
+
stream: "tool",
|
| 73 |
+
data: {
|
| 74 |
+
phase: "result",
|
| 75 |
+
name: "exec",
|
| 76 |
+
toolCallId: "call-1",
|
| 77 |
+
title: "false",
|
| 78 |
+
isError: true,
|
| 79 |
+
result: { exitCode: 2, output: "structured output", status: "exit 2" },
|
| 80 |
+
},
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
expect(built).toMatchObject({
|
| 84 |
+
status: "exit 2",
|
| 85 |
+
exitCode: 2,
|
| 86 |
+
output: "structured output",
|
| 87 |
+
title: "false",
|
| 88 |
+
});
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
it("ignores events that carry no outcome and no content", () => {
|
| 92 |
+
expect(
|
| 93 |
+
buildCommandOutputFromToolResultEvent({
|
| 94 |
+
stream: "tool",
|
| 95 |
+
data: { phase: "result", name: "Bash", toolCallId: "call-1" },
|
| 96 |
+
}),
|
| 97 |
+
).toBeUndefined();
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
it("ignores non-command tools and non-result phases", () => {
|
| 101 |
+
expect(
|
| 102 |
+
buildCommandOutputFromToolResultEvent({
|
| 103 |
+
stream: "tool",
|
| 104 |
+
data: { phase: "result", name: "Read", toolCallId: "call-1", isError: true },
|
| 105 |
+
}),
|
| 106 |
+
).toBeUndefined();
|
| 107 |
+
expect(buildFromCliResult({ phase: "start", isError: true })).toBeUndefined();
|
| 108 |
+
});
|
| 109 |
+
});
|
src/auto-reply/reply/agent-runner-command-output.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { asFiniteNumber as readFiniteNumberValue } from "@openclaw/normalization-core/number-coercion";
|
| 2 |
+
import { asOptionalRecord as readRecordValue } from "@openclaw/normalization-core/record-coerce";
|
| 3 |
+
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
| 4 |
+
import type { EmbeddedAgentEvent } from "../../agents/embedded-agent-subscribe.shared-types.js";
|
| 5 |
+
import { inferToolMetaFromArgsCore } from "../../agents/tool-display.js";
|
| 6 |
+
import type { GetReplyOptions } from "../types.js";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* CLI backends report a tool result as its raw content: a string, or the text
|
| 10 |
+
* blocks the harness streamed. Structured runners send a record instead, so the
|
| 11 |
+
* command projection has to read both or every CLI command result is dropped.
|
| 12 |
+
*/
|
| 13 |
+
function readToolResultText(value: unknown): string | undefined {
|
| 14 |
+
const direct = readStringValue(value);
|
| 15 |
+
if (direct !== undefined) {
|
| 16 |
+
return direct;
|
| 17 |
+
}
|
| 18 |
+
if (!Array.isArray(value)) {
|
| 19 |
+
return undefined;
|
| 20 |
+
}
|
| 21 |
+
const text = value
|
| 22 |
+
.map((block) => readStringValue(readRecordValue(block)?.text))
|
| 23 |
+
.filter((part): part is string => part !== undefined)
|
| 24 |
+
.join("\n")
|
| 25 |
+
.trim();
|
| 26 |
+
return text || undefined;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function readNullableNumberValue(value: unknown): number | null | undefined {
|
| 30 |
+
if (value === null) {
|
| 31 |
+
return null;
|
| 32 |
+
}
|
| 33 |
+
return readFiniteNumberValue(value);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function isCommandToolName(name: string | undefined): boolean {
|
| 37 |
+
const normalized = name?.trim().toLowerCase();
|
| 38 |
+
return normalized === "exec" || normalized === "bash" || normalized === "shell";
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** Projects a completed command-tool event into the channel command-output contract. */
|
| 42 |
+
export function buildCommandOutputFromToolResultEvent(
|
| 43 |
+
evt: EmbeddedAgentEvent,
|
| 44 |
+
): Parameters<NonNullable<GetReplyOptions["onCommandOutput"]>>[0] | undefined {
|
| 45 |
+
if (evt.stream !== "tool" || evt.data.phase !== "result") {
|
| 46 |
+
return undefined;
|
| 47 |
+
}
|
| 48 |
+
const name = evt.data.name;
|
| 49 |
+
const commandBearing = evt.data.commandBearing === true;
|
| 50 |
+
if (!name || (!commandBearing && !isCommandToolName(name))) {
|
| 51 |
+
return undefined;
|
| 52 |
+
}
|
| 53 |
+
const result = readRecordValue(evt.data.result);
|
| 54 |
+
const details = readRecordValue(result?.details);
|
| 55 |
+
const output =
|
| 56 |
+
evt.data.output ??
|
| 57 |
+
readStringValue(result?.output) ??
|
| 58 |
+
readStringValue(details?.output) ??
|
| 59 |
+
readToolResultText(evt.data.result);
|
| 60 |
+
const explicitStatus =
|
| 61 |
+
evt.data.status ?? readStringValue(result?.status) ?? readStringValue(details?.status);
|
| 62 |
+
const exitCode = readNullableNumberValue(
|
| 63 |
+
result?.exitCode ?? details?.exitCode ?? evt.data.exitCode,
|
| 64 |
+
);
|
| 65 |
+
const durationMs = readFiniteNumberValue(
|
| 66 |
+
result?.durationMs ?? details?.durationMs ?? evt.data.durationMs,
|
| 67 |
+
);
|
| 68 |
+
const cwd = evt.data.cwd;
|
| 69 |
+
const errorStatus =
|
| 70 |
+
evt.data.isError === true ? "failed" : evt.data.isError === false ? "completed" : undefined;
|
| 71 |
+
// A bare result carries no outcome of its own: runners that report one send a
|
| 72 |
+
// separate command_output event, and synthesizing here would duplicate it.
|
| 73 |
+
// A CLI result is different because its content *is* the outcome, which
|
| 74 |
+
// readToolResultText surfaces as output above.
|
| 75 |
+
const hasConcreteCommandResult =
|
| 76 |
+
output !== undefined ||
|
| 77 |
+
explicitStatus !== undefined ||
|
| 78 |
+
exitCode !== undefined ||
|
| 79 |
+
durationMs !== undefined ||
|
| 80 |
+
cwd !== undefined ||
|
| 81 |
+
(commandBearing && typeof evt.data.isError === "boolean") ||
|
| 82 |
+
(result !== undefined && Object.keys(result).length > 0);
|
| 83 |
+
if (!hasConcreteCommandResult) {
|
| 84 |
+
return undefined;
|
| 85 |
+
}
|
| 86 |
+
// Keep the line describing the command, not its output: without a title the
|
| 87 |
+
// terminal line would replace the request with whatever the tool printed.
|
| 88 |
+
const args = evt.data.args;
|
| 89 |
+
const title =
|
| 90 |
+
evt.data.title ??
|
| 91 |
+
(args ? inferToolMetaFromArgsCore(name, args, { detailMode: "explain" }) : undefined);
|
| 92 |
+
return {
|
| 93 |
+
itemId: evt.data.itemId,
|
| 94 |
+
phase: "end",
|
| 95 |
+
title,
|
| 96 |
+
toolCallId: evt.data.toolCallId,
|
| 97 |
+
name,
|
| 98 |
+
output,
|
| 99 |
+
status: explicitStatus ?? errorStatus,
|
| 100 |
+
exitCode,
|
| 101 |
+
durationMs,
|
| 102 |
+
cwd,
|
| 103 |
+
};
|
| 104 |
+
}
|
src/auto-reply/reply/agent-runner-compaction-accounting.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
CompactionAccountingFact,
|
| 3 |
+
CompactionAccountingTarget,
|
| 4 |
+
} from "../../agents/embedded-agent-runner/run/internal-params.js";
|
| 5 |
+
import type { AgentTurnCompaction } from "./agent-runner-execution.types.js";
|
| 6 |
+
|
| 7 |
+
export function hasSameCompactionWriter(
|
| 8 |
+
previous: CompactionAccountingTarget | undefined,
|
| 9 |
+
current: CompactionAccountingTarget,
|
| 10 |
+
): boolean {
|
| 11 |
+
// Physical session IDs may rotate while the binding and retained writer stay fixed.
|
| 12 |
+
return (
|
| 13 |
+
previous !== undefined &&
|
| 14 |
+
previous.agentId === current.agentId &&
|
| 15 |
+
previous.sessionKey === current.sessionKey &&
|
| 16 |
+
previous.storePath === current.storePath &&
|
| 17 |
+
previous.lifecycleRevision === current.lifecycleRevision &&
|
| 18 |
+
previous.activeWriterRunId === current.activeWriterRunId
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/** A later opaque candidate may lose freshness, but must never fabricate it. */
|
| 23 |
+
export function invalidateTurnCompactionContext(compaction: AgentTurnCompaction): void {
|
| 24 |
+
compaction.durable = compaction.durable.map((fact) => ({
|
| 25 |
+
...fact,
|
| 26 |
+
currentContextSnapshot: { tokens: undefined },
|
| 27 |
+
}));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Fold same-writer facts; only an ordered snapshot may refresh context. */
|
| 31 |
+
export function recordTurnCompaction(
|
| 32 |
+
compaction: AgentTurnCompaction,
|
| 33 |
+
fact: CompactionAccountingFact,
|
| 34 |
+
): void {
|
| 35 |
+
if (fact.count < 0) {
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
compaction.count += fact.count;
|
| 39 |
+
if (fact.kind !== "durable") {
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
const index = compaction.durable.findIndex(({ target }) =>
|
| 43 |
+
hasSameCompactionWriter(target, fact.target),
|
| 44 |
+
);
|
| 45 |
+
const previous = compaction.durable[index];
|
| 46 |
+
if (!previous && fact.count === 0) {
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
if (previous) {
|
| 50 |
+
compaction.durable.splice(index, 1);
|
| 51 |
+
}
|
| 52 |
+
// Custody without an observation cannot erase the prior candidate's explicit invalidation.
|
| 53 |
+
compaction.durable.push({
|
| 54 |
+
...fact,
|
| 55 |
+
count: (previous?.count ?? 0) + fact.count,
|
| 56 |
+
currentContextSnapshot: fact.currentContextSnapshot ?? previous?.currentContextSnapshot,
|
| 57 |
+
});
|
| 58 |
+
}
|
src/auto-reply/reply/agent-runner-context-recovery.test.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import type { ModelDefinitionConfig } from "../../config/types.models.js";
|
| 3 |
+
import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
|
| 4 |
+
|
| 5 |
+
function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig {
|
| 6 |
+
return {
|
| 7 |
+
id,
|
| 8 |
+
name: id,
|
| 9 |
+
reasoning: false,
|
| 10 |
+
input: ["text"],
|
| 11 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 12 |
+
contextWindow: contextTokens,
|
| 13 |
+
contextTokens,
|
| 14 |
+
maxTokens: 4096,
|
| 15 |
+
};
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
describe("buildContextOverflowRecoveryText", () => {
|
| 19 |
+
it("uses the built-in recovery hint without heartbeat model evidence", () => {
|
| 20 |
+
const text = buildContextOverflowRecoveryText({
|
| 21 |
+
cfg: {},
|
| 22 |
+
primaryProvider: "openrouter",
|
| 23 |
+
primaryModel: "qwen3.6-plus",
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
expect(text).toContain("fresh session or using a model with a larger context window");
|
| 27 |
+
expect(text).not.toContain("reserveTokensFloor");
|
| 28 |
+
expect(text).not.toContain("heartbeat model bleed");
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
it("keeps the preserved-session copy with the built-in recovery hint", () => {
|
| 32 |
+
const text = buildContextOverflowRecoveryText({
|
| 33 |
+
preserveSessionMapping: true,
|
| 34 |
+
cfg: {},
|
| 35 |
+
primaryProvider: "openrouter",
|
| 36 |
+
primaryModel: "qwen3.6-plus",
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
expect(text).toContain("kept this conversation mapped to the current session");
|
| 40 |
+
expect(text).toContain("fresh session or using a model with a larger context window");
|
| 41 |
+
expect(text).not.toContain("reset our conversation");
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
it("does not use stale heartbeat hints for a different explicit runtime model", () => {
|
| 45 |
+
const text = buildContextOverflowRecoveryText({
|
| 46 |
+
cfg: {
|
| 47 |
+
agents: {
|
| 48 |
+
defaults: {
|
| 49 |
+
heartbeat: { model: "ollama/qwen3.5-9b-32k:latest" },
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
primaryProvider: "openrouter",
|
| 54 |
+
primaryModel: "qwen3.6-plus",
|
| 55 |
+
runtimeProvider: "custom",
|
| 56 |
+
runtimeModel: "uncataloged-32k",
|
| 57 |
+
activeSessionEntry: {
|
| 58 |
+
sessionId: "session",
|
| 59 |
+
updatedAt: 1,
|
| 60 |
+
modelProvider: "ollama",
|
| 61 |
+
model: "qwen3.5-9b-32k:latest",
|
| 62 |
+
contextTokens: 32_768,
|
| 63 |
+
},
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
expect(text).toContain("fresh session or using a model with a larger context window");
|
| 67 |
+
expect(text).not.toContain("heartbeat model bleed");
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("points to heartbeat model bleed when the session model matches heartbeat.model", () => {
|
| 71 |
+
const text = buildContextOverflowRecoveryText({
|
| 72 |
+
cfg: {
|
| 73 |
+
models: {
|
| 74 |
+
providers: {
|
| 75 |
+
openrouter: {
|
| 76 |
+
baseUrl: "https://openrouter.test",
|
| 77 |
+
models: [makeTestModel("qwen3.6-plus", 1_000_000)],
|
| 78 |
+
},
|
| 79 |
+
ollama: {
|
| 80 |
+
baseUrl: "http://ollama.test",
|
| 81 |
+
models: [makeTestModel("qwen3.5-9b-32k:latest", 32_768)],
|
| 82 |
+
},
|
| 83 |
+
},
|
| 84 |
+
},
|
| 85 |
+
agents: {
|
| 86 |
+
defaults: {
|
| 87 |
+
heartbeat: { model: "ollama/qwen3.5-9b-32k:latest" },
|
| 88 |
+
},
|
| 89 |
+
},
|
| 90 |
+
},
|
| 91 |
+
agentId: "agent",
|
| 92 |
+
primaryProvider: "openrouter",
|
| 93 |
+
primaryModel: "qwen3.6-plus",
|
| 94 |
+
activeSessionEntry: {
|
| 95 |
+
sessionId: "session",
|
| 96 |
+
updatedAt: 1,
|
| 97 |
+
modelProvider: "ollama",
|
| 98 |
+
model: "qwen3.5-9b-32k:latest",
|
| 99 |
+
contextTokens: 32_768,
|
| 100 |
+
},
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
expect(text).toContain("ollama/qwen3.5-9b-32k:latest (32k context)");
|
| 104 |
+
expect(text).toContain("openrouter/qwen3.6-plus");
|
| 105 |
+
expect(text).toContain("heartbeat model bleed");
|
| 106 |
+
expect(text).toContain("heartbeat.isolatedSession");
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
it("uses the stored context window for an uncataloged heartbeat model", () => {
|
| 110 |
+
const text = buildContextOverflowRecoveryText({
|
| 111 |
+
cfg: {
|
| 112 |
+
models: {
|
| 113 |
+
providers: {
|
| 114 |
+
openrouter: {
|
| 115 |
+
baseUrl: "https://openrouter.test",
|
| 116 |
+
models: [makeTestModel("qwen3.6-plus", 1_000_000)],
|
| 117 |
+
},
|
| 118 |
+
},
|
| 119 |
+
},
|
| 120 |
+
agents: {
|
| 121 |
+
defaults: {
|
| 122 |
+
heartbeat: { model: "ollama/custom-32k" },
|
| 123 |
+
},
|
| 124 |
+
},
|
| 125 |
+
},
|
| 126 |
+
agentId: "agent",
|
| 127 |
+
primaryProvider: "openrouter",
|
| 128 |
+
primaryModel: "qwen3.6-plus",
|
| 129 |
+
activeSessionEntry: {
|
| 130 |
+
sessionId: "session",
|
| 131 |
+
updatedAt: 1,
|
| 132 |
+
modelProvider: "ollama",
|
| 133 |
+
model: "custom-32k",
|
| 134 |
+
contextTokens: 32_768,
|
| 135 |
+
},
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
expect(text).toContain("ollama/custom-32k (32k context)");
|
| 139 |
+
expect(text).not.toContain("ollama/custom-32k (98k context)");
|
| 140 |
+
expect(text).toContain("heartbeat model bleed");
|
| 141 |
+
});
|
| 142 |
+
});
|
src/auto-reply/reply/agent-runner-context-recovery.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
normalizeLowercaseStringOrEmpty,
|
| 3 |
+
normalizeOptionalString,
|
| 4 |
+
} from "@openclaw/normalization-core/string-coerce";
|
| 5 |
+
import { resolveAgentConfig } from "../../agents/agent-scope-config.js";
|
| 6 |
+
import { resolveContextTokensForModel } from "../../agents/context.js";
|
| 7 |
+
import { resolveModelRefFromString } from "../../agents/model-selection.js";
|
| 8 |
+
import type { SessionEntry } from "../../config/sessions.js";
|
| 9 |
+
import type { FollowupRun } from "./queue.js";
|
| 10 |
+
|
| 11 |
+
function buildContextOverflowResetHint(): string {
|
| 12 |
+
return "\n\nTry starting a fresh session or using a model with a larger context window.";
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
type ModelRefLike = {
|
| 16 |
+
provider: string;
|
| 17 |
+
model: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
function resolveAgentHeartbeatModelRaw(params: {
|
| 21 |
+
cfg: FollowupRun["run"]["config"];
|
| 22 |
+
agentId?: string;
|
| 23 |
+
}): string | undefined {
|
| 24 |
+
const defaultModel = normalizeOptionalString(params.cfg.agents?.defaults?.heartbeat?.model);
|
| 25 |
+
const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
|
| 26 |
+
const agentModel = agentId
|
| 27 |
+
? normalizeOptionalString(resolveAgentConfig(params.cfg, agentId)?.heartbeat?.model)
|
| 28 |
+
: undefined;
|
| 29 |
+
return agentModel ?? defaultModel;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function normalizeModelRefForCompare(ref: ModelRefLike | undefined) {
|
| 33 |
+
if (!ref) {
|
| 34 |
+
return undefined;
|
| 35 |
+
}
|
| 36 |
+
const provider = normalizeLowercaseStringOrEmpty(ref.provider);
|
| 37 |
+
const model = normalizeLowercaseStringOrEmpty(ref.model);
|
| 38 |
+
return provider && model ? { provider, model } : undefined;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function modelRefsEqual(left: ModelRefLike | undefined, right: ModelRefLike | undefined) {
|
| 42 |
+
const normalizedLeft = normalizeModelRefForCompare(left);
|
| 43 |
+
const normalizedRight = normalizeModelRefForCompare(right);
|
| 44 |
+
return (
|
| 45 |
+
normalizedLeft !== undefined &&
|
| 46 |
+
normalizedRight !== undefined &&
|
| 47 |
+
normalizedLeft.provider === normalizedRight.provider &&
|
| 48 |
+
normalizedLeft.model === normalizedRight.model
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function formatContextWindowLabel(tokens: number): string {
|
| 53 |
+
if (tokens >= 1_000_000) {
|
| 54 |
+
return `${Math.round((tokens / 1_000_000) * 10) / 10}M`;
|
| 55 |
+
}
|
| 56 |
+
return `${Math.round(tokens / 1024)}k`;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function normalizePositiveContextTokens(value: unknown): number | undefined {
|
| 60 |
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
| 61 |
+
return undefined;
|
| 62 |
+
}
|
| 63 |
+
return Math.floor(value);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function resolveContextWindowForHint(params: {
|
| 67 |
+
cfg: FollowupRun["run"]["config"];
|
| 68 |
+
agentId?: string;
|
| 69 |
+
ref: ModelRefLike;
|
| 70 |
+
activeSessionEntry?: SessionEntry;
|
| 71 |
+
}) {
|
| 72 |
+
const sessionContextTokens = normalizePositiveContextTokens(
|
| 73 |
+
params.activeSessionEntry?.contextTokens,
|
| 74 |
+
);
|
| 75 |
+
const modelContextTokens = resolveContextTokensForModel({
|
| 76 |
+
cfg: params.cfg,
|
| 77 |
+
provider: params.ref.provider,
|
| 78 |
+
model: params.ref.model,
|
| 79 |
+
allowAsyncLoad: false,
|
| 80 |
+
});
|
| 81 |
+
return modelContextTokens ?? sessionContextTokens;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function resolveHeartbeatBleedHint(params: {
|
| 85 |
+
cfg: FollowupRun["run"]["config"];
|
| 86 |
+
agentId?: string;
|
| 87 |
+
primaryProvider?: string;
|
| 88 |
+
primaryModel?: string;
|
| 89 |
+
activeSessionEntry?: SessionEntry;
|
| 90 |
+
}): string | undefined {
|
| 91 |
+
const primaryProvider = normalizeOptionalString(params.primaryProvider);
|
| 92 |
+
const primaryModel = normalizeOptionalString(params.primaryModel);
|
| 93 |
+
const runtimeProvider = normalizeOptionalString(params.activeSessionEntry?.modelProvider);
|
| 94 |
+
const runtimeModel = normalizeOptionalString(params.activeSessionEntry?.model);
|
| 95 |
+
if (!primaryProvider || !primaryModel || !runtimeProvider || !runtimeModel) {
|
| 96 |
+
return undefined;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const primaryRef = { provider: primaryProvider, model: primaryModel };
|
| 100 |
+
const runtimeRef = { provider: runtimeProvider, model: runtimeModel };
|
| 101 |
+
if (modelRefsEqual(primaryRef, runtimeRef)) {
|
| 102 |
+
return undefined;
|
| 103 |
+
}
|
| 104 |
+
const heartbeatModelRaw = resolveAgentHeartbeatModelRaw({
|
| 105 |
+
cfg: params.cfg,
|
| 106 |
+
agentId: params.agentId,
|
| 107 |
+
});
|
| 108 |
+
const heartbeatRef = heartbeatModelRaw
|
| 109 |
+
? resolveModelRefFromString({
|
| 110 |
+
cfg: params.cfg,
|
| 111 |
+
raw: heartbeatModelRaw,
|
| 112 |
+
defaultProvider: primaryProvider,
|
| 113 |
+
})?.ref
|
| 114 |
+
: undefined;
|
| 115 |
+
if (!modelRefsEqual(runtimeRef, heartbeatRef)) {
|
| 116 |
+
return undefined;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const runtimeWindow = resolveContextWindowForHint({
|
| 120 |
+
cfg: params.cfg,
|
| 121 |
+
agentId: params.agentId,
|
| 122 |
+
ref: runtimeRef,
|
| 123 |
+
activeSessionEntry: params.activeSessionEntry,
|
| 124 |
+
});
|
| 125 |
+
const primaryWindow = resolveContextWindowForHint({
|
| 126 |
+
cfg: params.cfg,
|
| 127 |
+
agentId: params.agentId,
|
| 128 |
+
ref: primaryRef,
|
| 129 |
+
});
|
| 130 |
+
if (
|
| 131 |
+
typeof runtimeWindow === "number" &&
|
| 132 |
+
typeof primaryWindow === "number" &&
|
| 133 |
+
runtimeWindow >= primaryWindow
|
| 134 |
+
) {
|
| 135 |
+
return undefined;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const runtimeLabel =
|
| 139 |
+
typeof runtimeWindow === "number" && runtimeWindow > 0
|
| 140 |
+
? ` (${formatContextWindowLabel(runtimeWindow)} context)`
|
| 141 |
+
: "";
|
| 142 |
+
return (
|
| 143 |
+
`\n\nThe previous heartbeat turn left this session on ${runtimeProvider}/${runtimeModel}` +
|
| 144 |
+
`${runtimeLabel} instead of ${primaryProvider}/${primaryModel}. This matches the configured ` +
|
| 145 |
+
"`heartbeat.model`, so the overflow is likely heartbeat model bleed rather than a " +
|
| 146 |
+
"compaction-buffer problem. Set `heartbeat.isolatedSession: true`, enable " +
|
| 147 |
+
"`heartbeat.lightContext: true`, or use a heartbeat model with a larger context window."
|
| 148 |
+
);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/** Builds recovery instructions for context-overflow failures. */
|
| 152 |
+
export function buildContextOverflowRecoveryText(params: {
|
| 153 |
+
duringCompaction?: boolean;
|
| 154 |
+
preserveSessionMapping?: boolean;
|
| 155 |
+
cfg: FollowupRun["run"]["config"];
|
| 156 |
+
agentId?: string;
|
| 157 |
+
primaryProvider?: string;
|
| 158 |
+
primaryModel?: string;
|
| 159 |
+
runtimeProvider?: string;
|
| 160 |
+
runtimeModel?: string;
|
| 161 |
+
activeSessionEntry?: SessionEntry;
|
| 162 |
+
}): string {
|
| 163 |
+
const prefix = params.preserveSessionMapping
|
| 164 |
+
? "⚠️ Auto-compaction could not recover this turn. I kept this conversation mapped to the current session. Please try again, use /compact, or use /new to start a fresh session."
|
| 165 |
+
: params.duringCompaction
|
| 166 |
+
? "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again."
|
| 167 |
+
: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.";
|
| 168 |
+
const explicitRuntimeMatchesSession =
|
| 169 |
+
!params.runtimeProvider ||
|
| 170 |
+
!params.runtimeModel ||
|
| 171 |
+
(params.runtimeProvider === params.activeSessionEntry?.modelProvider &&
|
| 172 |
+
params.runtimeModel === params.activeSessionEntry?.model);
|
| 173 |
+
const heartbeatBleedHint = explicitRuntimeMatchesSession
|
| 174 |
+
? resolveHeartbeatBleedHint({
|
| 175 |
+
cfg: params.cfg,
|
| 176 |
+
agentId: params.agentId,
|
| 177 |
+
primaryProvider: params.primaryProvider,
|
| 178 |
+
primaryModel: params.primaryModel,
|
| 179 |
+
activeSessionEntry: params.activeSessionEntry,
|
| 180 |
+
})
|
| 181 |
+
: undefined;
|
| 182 |
+
return prefix + (heartbeatBleedHint ?? buildContextOverflowResetHint());
|
| 183 |
+
}
|
src/auto-reply/reply/agent-runner-core.test.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, onTestFinished } from "vitest";
|
| 2 |
+
import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
|
| 3 |
+
import { resolveFallbackTransition } from "../fallback-state.js";
|
| 4 |
+
import type { TemplateContext } from "../templating.js";
|
| 5 |
+
import {
|
| 6 |
+
buildSilentFallbackFailurePayload,
|
| 7 |
+
resolveAdmittedRunSessionFile,
|
| 8 |
+
resolveReplyRunDeliveryContext,
|
| 9 |
+
} from "./agent-runner-core.js";
|
| 10 |
+
import type { RuntimeFallbackAttempt } from "./agent-runner-execution.types.js";
|
| 11 |
+
|
| 12 |
+
describe("resolveAdmittedRunSessionFile", () => {
|
| 13 |
+
it("uses the scoped session key when one is available", () => {
|
| 14 |
+
expect(
|
| 15 |
+
resolveAdmittedRunSessionFile({
|
| 16 |
+
agentId: "main",
|
| 17 |
+
sessionId: "session",
|
| 18 |
+
sessionFile: "legacy-target",
|
| 19 |
+
sessionKey: " agent:main:session ",
|
| 20 |
+
storePath: "/tmp/sessions.json",
|
| 21 |
+
}),
|
| 22 |
+
).toBe("agent:main:session");
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
it("preserves the admitted fallback when a persisted run has no session key", () => {
|
| 26 |
+
expect(
|
| 27 |
+
resolveAdmittedRunSessionFile({
|
| 28 |
+
agentId: "main",
|
| 29 |
+
sessionId: "session",
|
| 30 |
+
sessionFile: "legacy-target",
|
| 31 |
+
storePath: "/tmp/sessions.json",
|
| 32 |
+
}),
|
| 33 |
+
).toBe("legacy-target");
|
| 34 |
+
});
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
describe("resolveReplyRunDeliveryContext", () => {
|
| 38 |
+
it.each([
|
| 39 |
+
{ name: "numeric message topic", messageThreadId: 99, threadId: 99 },
|
| 40 |
+
{ name: "numeric transport topic", transportThreadId: 99, threadId: 99 },
|
| 41 |
+
{ name: "message topic precedence", messageThreadId: 99, transportThreadId: 77, threadId: 99 },
|
| 42 |
+
{ name: "string message topic", messageThreadId: "99", threadId: "99" },
|
| 43 |
+
{ name: "session identity fallback", threadId: "12345:99" },
|
| 44 |
+
])("preserves the $name", ({ messageThreadId, transportThreadId, threadId }) => {
|
| 45 |
+
expect(
|
| 46 |
+
resolveReplyRunDeliveryContext({
|
| 47 |
+
cfg: {},
|
| 48 |
+
sessionCtx: {
|
| 49 |
+
Provider: "telegram",
|
| 50 |
+
OriginatingChannel: "telegram",
|
| 51 |
+
OriginatingTo: "telegram:12345",
|
| 52 |
+
AccountId: "work",
|
| 53 |
+
MessageThreadId: messageThreadId,
|
| 54 |
+
TransportThreadId: transportThreadId,
|
| 55 |
+
SessionKey: "agent:main:telegram:direct:12345:thread:12345:99",
|
| 56 |
+
} as TemplateContext,
|
| 57 |
+
sessionKey: "agent:main:telegram:direct:12345:thread:12345:99",
|
| 58 |
+
}),
|
| 59 |
+
).toEqual({
|
| 60 |
+
channel: "telegram",
|
| 61 |
+
to: "telegram:12345",
|
| 62 |
+
accountId: "work",
|
| 63 |
+
threadId,
|
| 64 |
+
});
|
| 65 |
+
});
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
describe("buildSilentFallbackFailurePayload", () => {
|
| 69 |
+
const selected = { provider: "openrouter", model: "z-ai/glm-5.3" };
|
| 70 |
+
const other = { provider: "amazon-bedrock", model: "claude-haiku" };
|
| 71 |
+
const transition = resolveFallbackTransition({
|
| 72 |
+
selectedProvider: selected.provider,
|
| 73 |
+
selectedModel: selected.model,
|
| 74 |
+
activeProvider: other.provider,
|
| 75 |
+
activeModel: other.model,
|
| 76 |
+
attempts: [],
|
| 77 |
+
});
|
| 78 |
+
const base = {
|
| 79 |
+
fallbackTransition: transition,
|
| 80 |
+
fallbackFailureKnown: true,
|
| 81 |
+
cfg: {},
|
| 82 |
+
isHeartbeat: false,
|
| 83 |
+
hasSuccessfulTerminalDelivery: false,
|
| 84 |
+
};
|
| 85 |
+
const attempt = (
|
| 86 |
+
reason: RuntimeFallbackAttempt["reason"],
|
| 87 |
+
identity: Partial<RuntimeFallbackAttempt> = {},
|
| 88 |
+
): RuntimeFallbackAttempt => ({ ...selected, error: "provider failed", reason, ...identity });
|
| 89 |
+
|
| 90 |
+
it.each(["timeout", "server_error", "overloaded", "tls_certificate"] as const)(
|
| 91 |
+
"uses unreachable wording for selected-backend %s evidence",
|
| 92 |
+
(reason) => {
|
| 93 |
+
const payload = buildSilentFallbackFailurePayload({
|
| 94 |
+
...base,
|
| 95 |
+
fallbackAttempts: [attempt(reason)],
|
| 96 |
+
});
|
| 97 |
+
expect(payload?.text).toContain("couldn't reach the configured model backend");
|
| 98 |
+
expect(payload?.text).toContain(transition.selectedModelRef);
|
| 99 |
+
expect(payload?.text).toContain(transition.activeModelRef);
|
| 100 |
+
expect(payload?.isError).toBe(true);
|
| 101 |
+
},
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
it.each(["format", "empty_response"] as const)(
|
| 105 |
+
"uses response wording for selected-backend %s evidence",
|
| 106 |
+
(reason) => {
|
| 107 |
+
const payload = buildSilentFallbackFailurePayload({
|
| 108 |
+
...base,
|
| 109 |
+
fallbackAttempts: [attempt(reason)],
|
| 110 |
+
});
|
| 111 |
+
expect(payload?.text).toContain("responded but produced no usable reply");
|
| 112 |
+
expect(payload?.text).not.toContain("couldn't reach");
|
| 113 |
+
},
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
it.each([
|
| 117 |
+
{ label: "empty evidence", attempts: [] },
|
| 118 |
+
{ label: "unknown reason", attempts: [attempt("unknown")] },
|
| 119 |
+
{ label: "auth skip", attempts: [attempt("auth")] },
|
| 120 |
+
{ label: "auth with synthesized status", attempts: [attempt("auth", { status: 401 })] },
|
| 121 |
+
{ label: "permanent auth", attempts: [attempt("auth_permanent")] },
|
| 122 |
+
{ label: "billing", attempts: [attempt("billing")] },
|
| 123 |
+
{ label: "rate limit", attempts: [attempt("rate_limit")] },
|
| 124 |
+
...(["timeout", "overloaded", "format", "empty_response"] as const).map((reason) => ({
|
| 125 |
+
label: `local skip retaining ${reason}`,
|
| 126 |
+
attempts: [attempt(reason, { code: "MODEL_FALLBACK_SKIPPED" })],
|
| 127 |
+
})),
|
| 128 |
+
{
|
| 129 |
+
label: "local skip beside a response",
|
| 130 |
+
attempts: [attempt("format"), attempt("format", { code: "MODEL_FALLBACK_SKIPPED" })],
|
| 131 |
+
},
|
| 132 |
+
{ label: "transport then response", attempts: [attempt("timeout"), attempt("format")] },
|
| 133 |
+
{ label: "response then transport", attempts: [attempt("format"), attempt("timeout")] },
|
| 134 |
+
{ label: "other backend only", attempts: [attempt("format", other)] },
|
| 135 |
+
{ label: "other model only", attempts: [attempt("format", { model: "different" })] },
|
| 136 |
+
{ label: "unattributed response", attempts: [attempt("format", { provider: "", model: "" })] },
|
| 137 |
+
{
|
| 138 |
+
label: "unattributed transport",
|
| 139 |
+
attempts: [attempt("timeout", { provider: "", model: "" })],
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
label: "incomplete evidence beside response",
|
| 143 |
+
attempts: [attempt("format"), attempt("timeout", { provider: "" })],
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
label: "incomplete evidence beside transport",
|
| 147 |
+
attempts: [attempt("timeout"), attempt("format", { model: " " })],
|
| 148 |
+
},
|
| 149 |
+
])("keeps $label cause-neutral", ({ attempts }) => {
|
| 150 |
+
const payload = buildSilentFallbackFailurePayload({ ...base, fallbackAttempts: attempts });
|
| 151 |
+
expect(payload?.text).toContain("produced no usable reply");
|
| 152 |
+
expect(payload?.text).not.toContain("responded");
|
| 153 |
+
expect(payload?.text).not.toContain("couldn't reach");
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
it.each([
|
| 157 |
+
{
|
| 158 |
+
selectedReason: "format",
|
| 159 |
+
otherReason: "timeout",
|
| 160 |
+
expected: "responded but produced no usable reply",
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
selectedReason: "timeout",
|
| 164 |
+
otherReason: "format",
|
| 165 |
+
expected: "couldn't reach the configured model backend",
|
| 166 |
+
},
|
| 167 |
+
] as const)(
|
| 168 |
+
"ignores another backend's $otherReason evidence",
|
| 169 |
+
({ selectedReason, otherReason, expected }) => {
|
| 170 |
+
const payload = buildSilentFallbackFailurePayload({
|
| 171 |
+
...base,
|
| 172 |
+
fallbackAttempts: [attempt(otherReason, other), attempt(selectedReason)],
|
| 173 |
+
});
|
| 174 |
+
expect(payload?.text).toContain(expected);
|
| 175 |
+
},
|
| 176 |
+
);
|
| 177 |
+
|
| 178 |
+
it("uses the configured runtime alias when attributing a response", () => {
|
| 179 |
+
// Exercise attribution with setup metadata; the plugin registration test owns
|
| 180 |
+
// the real Claude CLI descriptor and its canonical provider binding.
|
| 181 |
+
onTestFinished(() => cliBackendsTesting.resetDepsForTest());
|
| 182 |
+
cliBackendsTesting.setDepsForTest({
|
| 183 |
+
resolveRuntimeCliBackends: () => [],
|
| 184 |
+
resolvePluginSetupCliBackend: ({ backend }) =>
|
| 185 |
+
backend === "claude-cli"
|
| 186 |
+
? {
|
| 187 |
+
pluginId: "anthropic",
|
| 188 |
+
backend: {
|
| 189 |
+
id: "claude-cli",
|
| 190 |
+
modelProvider: "anthropic",
|
| 191 |
+
config: { command: "claude" },
|
| 192 |
+
},
|
| 193 |
+
}
|
| 194 |
+
: undefined,
|
| 195 |
+
});
|
| 196 |
+
const cfg = { plugins: { allow: ["anthropic"], entries: { anthropic: { enabled: true } } } };
|
| 197 |
+
const fallbackTransition = resolveFallbackTransition({
|
| 198 |
+
selectedProvider: "anthropic",
|
| 199 |
+
selectedModel: "claude-sonnet-4-6",
|
| 200 |
+
activeProvider: "openai",
|
| 201 |
+
activeModel: "gpt-5.5",
|
| 202 |
+
attempts: [],
|
| 203 |
+
cfg,
|
| 204 |
+
});
|
| 205 |
+
const payload = buildSilentFallbackFailurePayload({
|
| 206 |
+
...base,
|
| 207 |
+
cfg,
|
| 208 |
+
fallbackTransition,
|
| 209 |
+
fallbackAttempts: [attempt("format", { provider: "claude-cli", model: "claude-sonnet-4-6" })],
|
| 210 |
+
});
|
| 211 |
+
expect(payload?.text).toContain("responded but produced no usable reply");
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
it.each([
|
| 215 |
+
{ isHeartbeat: true },
|
| 216 |
+
{ hasSuccessfulTerminalDelivery: true },
|
| 217 |
+
{ allowEmptyAssistantReplyAsSilent: true },
|
| 218 |
+
{ silentExpected: true },
|
| 219 |
+
{ hasExplicitSilentReply: true },
|
| 220 |
+
{ fallbackFailureKnown: false },
|
| 221 |
+
{ fallbackTransition: { ...transition, fallbackActive: false } },
|
| 222 |
+
])("preserves warning suppression %j", (suppression) => {
|
| 223 |
+
expect(
|
| 224 |
+
buildSilentFallbackFailurePayload({
|
| 225 |
+
...base,
|
| 226 |
+
...suppression,
|
| 227 |
+
fallbackAttempts: [attempt("format")],
|
| 228 |
+
}),
|
| 229 |
+
).toBeUndefined();
|
| 230 |
+
});
|
| 231 |
+
});
|
src/auto-reply/reply/agent-runner-core.ts
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
| 2 |
+
import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js";
|
| 3 |
+
import { hasVisibleCommittedMessagingToolDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js";
|
| 4 |
+
import { MODEL_FALLBACK_SKIPPED_CODE } from "../../agents/model-fallback.types.js";
|
| 5 |
+
import type { ModelRef } from "../../agents/model-ref-shared.js";
|
| 6 |
+
import { areRuntimeModelRefsEquivalent } from "../../agents/model-runtime-aliases.js";
|
| 7 |
+
import type { OpenClawConfig } from "../../config/config.js";
|
| 8 |
+
import {
|
| 9 |
+
resolveSessionPluginStatusLines,
|
| 10 |
+
resolveSessionPluginTraceLines,
|
| 11 |
+
type SessionEntry,
|
| 12 |
+
} from "../../config/sessions.js";
|
| 13 |
+
import { loadSessionEntryReadOnly } from "../../config/sessions/session-accessor.js";
|
| 14 |
+
import type { TypingMode } from "../../config/types.js";
|
| 15 |
+
import { logVerbose } from "../../globals.js";
|
| 16 |
+
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
|
| 17 |
+
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
| 18 |
+
import {
|
| 19 |
+
sessionDeliveryChannel,
|
| 20 |
+
type DeliveryContext,
|
| 21 |
+
normalizeDeliveryContext,
|
| 22 |
+
} from "../../utils/delivery-context.shared.js";
|
| 23 |
+
import { resolveFallbackTransition } from "../fallback-state.js";
|
| 24 |
+
import {
|
| 25 |
+
isReplyPayloadTerminalContent,
|
| 26 |
+
markReplyPayloadForSourceSuppressionDelivery,
|
| 27 |
+
setReplyPayloadMetadata,
|
| 28 |
+
} from "../reply-payload.js";
|
| 29 |
+
import type { TemplateContext } from "../templating.js";
|
| 30 |
+
import type { VerboseLevel } from "../thinking.js";
|
| 31 |
+
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
| 32 |
+
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
| 33 |
+
import type { RuntimeFallbackAttempt } from "./agent-runner-execution.types.js";
|
| 34 |
+
import {
|
| 35 |
+
buildKnownAgentRunFailureReplyPayload,
|
| 36 |
+
buildTerminalAgentRunFailureReplyPayload,
|
| 37 |
+
} from "./agent-runner-failure-reply.js";
|
| 38 |
+
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
| 39 |
+
import { resolveEffectiveReplyRoute } from "./effective-reply-route.js";
|
| 40 |
+
import type { InternalGetReplyOptions } from "./get-reply.types.js";
|
| 41 |
+
import { normalizeReplyPayload } from "./normalize-reply.js";
|
| 42 |
+
import { sanitizePendingFinalDeliveryText } from "./pending-final-delivery-state.js";
|
| 43 |
+
import { type FollowupRun, type QueueSettings, scheduleFollowupDrain } from "./queue.js";
|
| 44 |
+
import { normalizeReplyPayloadDirectives, type DirectBlockDelivery } from "./reply-delivery.js";
|
| 45 |
+
import { isReplyOperationSuperseded } from "./reply-operation-abort.js";
|
| 46 |
+
import { type ReplyOperation, runAfterReplyOperationClear } from "./reply-run-registry.js";
|
| 47 |
+
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
|
| 48 |
+
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
|
| 49 |
+
import type { TypingController } from "./typing.js";
|
| 50 |
+
export const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
| 51 |
+
|
| 52 |
+
const RESTART_LIFECYCLE_REPLY_TEXT =
|
| 53 |
+
"⚠️ Gateway is restarting. Please wait a few seconds and try again.";
|
| 54 |
+
|
| 55 |
+
export function scheduleFollowupDrainAfterReplyOperationClear(params: {
|
| 56 |
+
operation: ReplyOperation;
|
| 57 |
+
queueKey: string;
|
| 58 |
+
runFollowup: (run: FollowupRun) => Promise<void>;
|
| 59 |
+
}): void {
|
| 60 |
+
runAfterReplyOperationClear(params.operation, (admissionSessionId) => {
|
| 61 |
+
const completedSessionId = params.operation.sessionId;
|
| 62 |
+
const runFollowupAfterClear =
|
| 63 |
+
admissionSessionId === completedSessionId
|
| 64 |
+
? params.runFollowup
|
| 65 |
+
: (queued: FollowupRun) =>
|
| 66 |
+
params.runFollowup(
|
| 67 |
+
queued.run.sessionId === completedSessionId
|
| 68 |
+
? { ...queued, admissionSessionId }
|
| 69 |
+
: queued,
|
| 70 |
+
);
|
| 71 |
+
scheduleFollowupDrain(params.queueKey, runFollowupAfterClear);
|
| 72 |
+
});
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export function markBeforeAgentRunBlockedPayloads(payloads: ReplyPayload[]): ReplyPayload[] {
|
| 76 |
+
return payloads.map((payload) =>
|
| 77 |
+
setReplyPayloadMetadata(payload, { beforeAgentRunBlocked: true }),
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export function buildSilentFallbackFailurePayload(params: {
|
| 82 |
+
fallbackTransition: ReturnType<typeof resolveFallbackTransition>;
|
| 83 |
+
fallbackFailureKnown: boolean;
|
| 84 |
+
fallbackAttempts: readonly RuntimeFallbackAttempt[];
|
| 85 |
+
cfg: OpenClawConfig;
|
| 86 |
+
isHeartbeat: boolean;
|
| 87 |
+
hasSuccessfulTerminalDelivery: boolean;
|
| 88 |
+
allowEmptyAssistantReplyAsSilent?: boolean;
|
| 89 |
+
silentExpected?: boolean;
|
| 90 |
+
hasExplicitSilentReply?: boolean;
|
| 91 |
+
}): ReplyPayload | undefined {
|
| 92 |
+
if (
|
| 93 |
+
params.isHeartbeat ||
|
| 94 |
+
params.allowEmptyAssistantReplyAsSilent === true ||
|
| 95 |
+
params.silentExpected === true ||
|
| 96 |
+
params.hasExplicitSilentReply === true ||
|
| 97 |
+
params.hasSuccessfulTerminalDelivery ||
|
| 98 |
+
!params.fallbackTransition.fallbackActive ||
|
| 99 |
+
!params.fallbackFailureKnown
|
| 100 |
+
) {
|
| 101 |
+
return undefined;
|
| 102 |
+
}
|
| 103 |
+
const selected = params.fallbackTransition.selectedModelRef;
|
| 104 |
+
const active = params.fallbackTransition.activeModelRef;
|
| 105 |
+
const attempts = params.fallbackAttempts;
|
| 106 |
+
const selectedAttempts = attempts.filter((attempt) =>
|
| 107 |
+
areRuntimeModelRefsEquivalent(`${attempt.provider}/${attempt.model}`, selected, {
|
| 108 |
+
config: params.cfg,
|
| 109 |
+
}),
|
| 110 |
+
);
|
| 111 |
+
let primary = `⚠️ The configured model backend ${selected} produced no usable reply. `;
|
| 112 |
+
// Local skips retain old failure reasons, not evidence of a new backend attempt.
|
| 113 |
+
if (
|
| 114 |
+
selectedAttempts.length > 0 &&
|
| 115 |
+
selectedAttempts.every((attempt) => attempt.code !== MODEL_FALLBACK_SKIPPED_CODE) &&
|
| 116 |
+
attempts.every((attempt) => attempt.provider.trim() && attempt.model.trim())
|
| 117 |
+
) {
|
| 118 |
+
if (
|
| 119 |
+
selectedAttempts.every(({ reason }) =>
|
| 120 |
+
["timeout", "server_error", "overloaded", "tls_certificate"].includes(reason),
|
| 121 |
+
)
|
| 122 |
+
) {
|
| 123 |
+
primary = `⚠️ I couldn't reach the configured model backend ${selected}. `;
|
| 124 |
+
} else if (
|
| 125 |
+
selectedAttempts.every(({ reason }) => reason === "format" || reason === "empty_response")
|
| 126 |
+
) {
|
| 127 |
+
primary = `⚠️ The configured model backend ${selected} responded but produced no usable reply. `;
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
return markReplyPayloadForSourceSuppressionDelivery({
|
| 131 |
+
text: `${primary}Fallback used ${active}, but it produced no visible reply.`,
|
| 132 |
+
isError: true,
|
| 133 |
+
});
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export function resolveSourceReplyPolicy(params: {
|
| 137 |
+
cfg: OpenClawConfig;
|
| 138 |
+
sessionCtx: TemplateContext;
|
| 139 |
+
sessionEntry?: SessionEntry;
|
| 140 |
+
sessionKey: string;
|
| 141 |
+
runtimePolicySessionKey?: string;
|
| 142 |
+
opts?: GetReplyOptions;
|
| 143 |
+
}): ReturnType<typeof resolveSourceReplyVisibilityPolicy> {
|
| 144 |
+
const sendPolicy = resolveSendPolicy({
|
| 145 |
+
cfg: params.cfg,
|
| 146 |
+
entry: params.sessionEntry,
|
| 147 |
+
sessionKey: params.runtimePolicySessionKey ?? params.sessionKey,
|
| 148 |
+
channel:
|
| 149 |
+
params.sessionCtx.OriginatingChannel ??
|
| 150 |
+
params.sessionCtx.Surface ??
|
| 151 |
+
params.sessionCtx.Provider ??
|
| 152 |
+
sessionDeliveryChannel(params.sessionEntry),
|
| 153 |
+
chatType: params.sessionEntry?.chatType,
|
| 154 |
+
});
|
| 155 |
+
return resolveSourceReplyVisibilityPolicy({
|
| 156 |
+
cfg: params.cfg,
|
| 157 |
+
ctx: params.sessionCtx,
|
| 158 |
+
requested: params.opts?.sourceReplyDeliveryMode,
|
| 159 |
+
sendPolicy,
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
export function resolveReplyRunDeliveryContext(params: {
|
| 164 |
+
cfg: OpenClawConfig;
|
| 165 |
+
sessionCtx: TemplateContext;
|
| 166 |
+
sessionEntry?: SessionEntry;
|
| 167 |
+
sessionKey: string;
|
| 168 |
+
runtimePolicySessionKey?: string;
|
| 169 |
+
opts?: GetReplyOptions;
|
| 170 |
+
}): DeliveryContext | undefined {
|
| 171 |
+
const sourceReplyPolicy = resolveSourceReplyPolicy(params);
|
| 172 |
+
if (
|
| 173 |
+
params.sessionCtx.InboundEventKind === "room_event" ||
|
| 174 |
+
sourceReplyPolicy.sendPolicyDenied ||
|
| 175 |
+
(sourceReplyPolicy.suppressDelivery &&
|
| 176 |
+
sourceReplyPolicy.sourceReplyDeliveryMode !== "message_tool_only")
|
| 177 |
+
) {
|
| 178 |
+
return undefined;
|
| 179 |
+
}
|
| 180 |
+
return normalizeDeliveryContext({
|
| 181 |
+
...resolveEffectiveReplyRoute({
|
| 182 |
+
ctx: params.sessionCtx,
|
| 183 |
+
entry: params.sessionEntry,
|
| 184 |
+
}),
|
| 185 |
+
threadId: resolveRoutedDeliveryThreadId({
|
| 186 |
+
ctx: params.sessionCtx,
|
| 187 |
+
sessionKey: params.sessionCtx.SessionKey ?? params.sessionKey,
|
| 188 |
+
}),
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
export function hasSuccessfulSourceReplyDelivery(params: {
|
| 193 |
+
blockReplyPipeline: { didStream: () => boolean; isAborted: () => boolean } | null;
|
| 194 |
+
directlySentBlockKeys?: Set<string>;
|
| 195 |
+
messagingToolSentTexts?: string[];
|
| 196 |
+
messagingToolSentMediaUrls?: string[];
|
| 197 |
+
messagingToolSentTargets?: unknown[];
|
| 198 |
+
}): boolean {
|
| 199 |
+
return (
|
| 200 |
+
params.blockReplyPipeline?.didStream() ||
|
| 201 |
+
(params.directlySentBlockKeys?.size ?? 0) > 0 ||
|
| 202 |
+
hasVisibleCommittedMessagingToolDeliveryEvidence(params)
|
| 203 |
+
);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
export function hasSuccessfulTerminalSourceReplyDelivery(params: {
|
| 207 |
+
blockReplyPipeline: {
|
| 208 |
+
didStreamTerminalReply?: () => boolean;
|
| 209 |
+
isAborted: () => boolean;
|
| 210 |
+
} | null;
|
| 211 |
+
directBlockDeliveries?: DirectBlockDelivery[];
|
| 212 |
+
}): boolean {
|
| 213 |
+
const sentTerminalBlock = params.directBlockDeliveries?.some(
|
| 214 |
+
({ payload, outcome, pending, source }) =>
|
| 215 |
+
outcome === "delivered" &&
|
| 216 |
+
!pending &&
|
| 217 |
+
source?.complete !== false &&
|
| 218 |
+
isReplyPayloadTerminalContent(payload) &&
|
| 219 |
+
normalizeReplyPayload(payload, { applyChannelTransforms: false }) !== null,
|
| 220 |
+
);
|
| 221 |
+
return (
|
| 222 |
+
params.blockReplyPipeline?.didStreamTerminalReply?.() === true || sentTerminalBlock === true
|
| 223 |
+
);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
export function resolveFallbackOriginModel(params: {
|
| 227 |
+
run: FollowupRun["run"];
|
| 228 |
+
fallbackStateEntry?: SessionEntry;
|
| 229 |
+
runtimeModelSelection?: ModelRef;
|
| 230 |
+
}): { provider: string; model: string; persistedAutoFallback: boolean } {
|
| 231 |
+
// Runtime-owned selection is not a fallback from the caller's nominal model.
|
| 232 |
+
if (params.runtimeModelSelection) {
|
| 233 |
+
return { ...params.runtimeModelSelection, persistedAutoFallback: false };
|
| 234 |
+
}
|
| 235 |
+
const entry = params.fallbackStateEntry;
|
| 236 |
+
const isAutoFallbackOverride =
|
| 237 |
+
entry?.modelOverrideSource === "auto" ||
|
| 238 |
+
(entry !== undefined &&
|
| 239 |
+
entry.modelOverrideSource === undefined &&
|
| 240 |
+
hasSessionAutoModelFallbackProvenance(entry));
|
| 241 |
+
if (isAutoFallbackOverride && entry !== undefined) {
|
| 242 |
+
const originProvider = normalizeOptionalString(entry.modelOverrideFallbackOriginProvider);
|
| 243 |
+
const originModel = normalizeOptionalString(entry.modelOverrideFallbackOriginModel);
|
| 244 |
+
if (originProvider && originModel) {
|
| 245 |
+
return { provider: originProvider, model: originModel, persistedAutoFallback: true };
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
return {
|
| 249 |
+
provider: params.run.provider,
|
| 250 |
+
model: params.run.model,
|
| 251 |
+
persistedAutoFallback: false,
|
| 252 |
+
};
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
export function buildInlinePluginStatusPayload(params: {
|
| 256 |
+
entry: SessionEntry | undefined;
|
| 257 |
+
includeStatusLines: boolean;
|
| 258 |
+
includeTraceLines: boolean;
|
| 259 |
+
}): ReplyPayload | undefined {
|
| 260 |
+
const statusLines = params.includeStatusLines
|
| 261 |
+
? resolveSessionPluginStatusLines(params.entry)
|
| 262 |
+
: [];
|
| 263 |
+
const traceLines = params.includeTraceLines ? resolveSessionPluginTraceLines(params.entry) : [];
|
| 264 |
+
const lines = [...statusLines, ...traceLines];
|
| 265 |
+
if (lines.length === 0) {
|
| 266 |
+
return undefined;
|
| 267 |
+
}
|
| 268 |
+
return { text: lines.join("\n") };
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
export function normalizeAssistantFinalDeliveryText(text: string): string {
|
| 272 |
+
const parsed = normalizeReplyPayloadDirectives({
|
| 273 |
+
payload: { text },
|
| 274 |
+
trimLeadingWhitespace: true,
|
| 275 |
+
parseMode: "auto",
|
| 276 |
+
});
|
| 277 |
+
return sanitizePendingFinalDeliveryText(parsed.payload.text ?? "");
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
export function refreshSessionEntryFromStore(params: {
|
| 281 |
+
storePath?: string;
|
| 282 |
+
sessionKey?: string;
|
| 283 |
+
fallbackEntry?: SessionEntry;
|
| 284 |
+
activeSessionStore?: Record<string, SessionEntry>;
|
| 285 |
+
expectedGeneration?: Pick<SessionEntry, "sessionId" | "lifecycleRevision">;
|
| 286 |
+
}): SessionEntry | undefined {
|
| 287 |
+
const { storePath, sessionKey, fallbackEntry, activeSessionStore } = params;
|
| 288 |
+
if (!storePath || !sessionKey) {
|
| 289 |
+
return fallbackEntry;
|
| 290 |
+
}
|
| 291 |
+
try {
|
| 292 |
+
const latestEntry = loadSessionEntryReadOnly({
|
| 293 |
+
storePath,
|
| 294 |
+
sessionKey,
|
| 295 |
+
});
|
| 296 |
+
if (!latestEntry) {
|
| 297 |
+
return fallbackEntry;
|
| 298 |
+
}
|
| 299 |
+
// Completion may refresh facts, but only admission can adopt a replacement generation.
|
| 300 |
+
if (
|
| 301 |
+
params.expectedGeneration &&
|
| 302 |
+
(latestEntry.sessionId !== params.expectedGeneration.sessionId ||
|
| 303 |
+
latestEntry.lifecycleRevision !== params.expectedGeneration.lifecycleRevision)
|
| 304 |
+
) {
|
| 305 |
+
return fallbackEntry;
|
| 306 |
+
}
|
| 307 |
+
if (activeSessionStore) {
|
| 308 |
+
activeSessionStore[sessionKey] = latestEntry;
|
| 309 |
+
}
|
| 310 |
+
return latestEntry;
|
| 311 |
+
} catch {
|
| 312 |
+
return fallbackEntry;
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
export function resolveAdmittedRunSessionFile(params: {
|
| 317 |
+
agentId: string;
|
| 318 |
+
sessionId: string;
|
| 319 |
+
sessionFile?: string;
|
| 320 |
+
sessionKey?: string;
|
| 321 |
+
storePath?: string;
|
| 322 |
+
}): string | undefined {
|
| 323 |
+
if (params.sessionKey?.trim()) {
|
| 324 |
+
return params.sessionKey.trim();
|
| 325 |
+
}
|
| 326 |
+
return params.sessionFile;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
export async function handleReplyAgentRunError(
|
| 330 |
+
error: unknown,
|
| 331 |
+
context: {
|
| 332 |
+
cfg: OpenClawConfig;
|
| 333 |
+
resolveVisibleReplyDelivery: () => Promise<boolean>;
|
| 334 |
+
isHeartbeat: boolean;
|
| 335 |
+
isRestartRecoveryArmed: () => boolean;
|
| 336 |
+
replyOperation: ReplyOperation;
|
| 337 |
+
resolvedVerboseLevel: VerboseLevel;
|
| 338 |
+
returnWithQueuedFollowupDrain: <T>(value: T) => T;
|
| 339 |
+
sessionCtx: TemplateContext;
|
| 340 |
+
},
|
| 341 |
+
): Promise<ReplyPayload | undefined> {
|
| 342 |
+
const {
|
| 343 |
+
cfg,
|
| 344 |
+
resolveVisibleReplyDelivery,
|
| 345 |
+
isHeartbeat,
|
| 346 |
+
isRestartRecoveryArmed,
|
| 347 |
+
replyOperation,
|
| 348 |
+
resolvedVerboseLevel,
|
| 349 |
+
returnWithQueuedFollowupDrain,
|
| 350 |
+
sessionCtx,
|
| 351 |
+
} = context;
|
| 352 |
+
|
| 353 |
+
if (isReplyOperationSuperseded(replyOperation)) {
|
| 354 |
+
return { text: SILENT_REPLY_TOKEN };
|
| 355 |
+
}
|
| 356 |
+
if (
|
| 357 |
+
replyOperation.result?.kind === "aborted" &&
|
| 358 |
+
replyOperation.result.code === "aborted_by_user"
|
| 359 |
+
) {
|
| 360 |
+
return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN });
|
| 361 |
+
}
|
| 362 |
+
if (
|
| 363 |
+
replyOperation.result?.kind === "aborted" &&
|
| 364 |
+
replyOperation.result.code === "aborted_for_restart"
|
| 365 |
+
) {
|
| 366 |
+
if (isRestartRecoveryArmed()) {
|
| 367 |
+
return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN });
|
| 368 |
+
}
|
| 369 |
+
return returnWithQueuedFollowupDrain(
|
| 370 |
+
markReplyPayloadForSourceSuppressionDelivery({
|
| 371 |
+
text: RESTART_LIFECYCLE_REPLY_TEXT,
|
| 372 |
+
}),
|
| 373 |
+
);
|
| 374 |
+
}
|
| 375 |
+
if (error instanceof GatewayDrainingError) {
|
| 376 |
+
replyOperation.fail("gateway_draining", error);
|
| 377 |
+
return returnWithQueuedFollowupDrain(
|
| 378 |
+
markReplyPayloadForSourceSuppressionDelivery({
|
| 379 |
+
text: RESTART_LIFECYCLE_REPLY_TEXT,
|
| 380 |
+
}),
|
| 381 |
+
);
|
| 382 |
+
}
|
| 383 |
+
if (error instanceof CommandLaneClearedError) {
|
| 384 |
+
replyOperation.fail("command_lane_cleared", error);
|
| 385 |
+
return returnWithQueuedFollowupDrain(
|
| 386 |
+
markReplyPayloadForSourceSuppressionDelivery({
|
| 387 |
+
text: RESTART_LIFECYCLE_REPLY_TEXT,
|
| 388 |
+
}),
|
| 389 |
+
);
|
| 390 |
+
}
|
| 391 |
+
const knownFailurePayload = buildKnownAgentRunFailureReplyPayload({
|
| 392 |
+
err: error,
|
| 393 |
+
sessionCtx,
|
| 394 |
+
resolvedVerboseLevel,
|
| 395 |
+
cfg,
|
| 396 |
+
});
|
| 397 |
+
if (knownFailurePayload) {
|
| 398 |
+
replyOperation.fail("run_failed", error);
|
| 399 |
+
return returnWithQueuedFollowupDrain(knownFailurePayload);
|
| 400 |
+
}
|
| 401 |
+
const visibleReplyDelivered = await resolveVisibleReplyDelivery();
|
| 402 |
+
if (!isHeartbeat && visibleReplyDelivered && !replyOperation.abortSignal.aborted) {
|
| 403 |
+
replyOperation.fail("run_failed", error);
|
| 404 |
+
return returnWithQueuedFollowupDrain(
|
| 405 |
+
buildTerminalAgentRunFailureReplyPayload({
|
| 406 |
+
visibleReplyDelivered: true,
|
| 407 |
+
sessionCtx,
|
| 408 |
+
cfg,
|
| 409 |
+
}),
|
| 410 |
+
);
|
| 411 |
+
}
|
| 412 |
+
replyOperation.fail("run_failed", error);
|
| 413 |
+
// Keep the followup queue moving even when an unexpected exception escapes
|
| 414 |
+
// the run path; the caller still receives the original error.
|
| 415 |
+
returnWithQueuedFollowupDrain(undefined);
|
| 416 |
+
throw error;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
export async function cleanupReplyAgentRun(context: {
|
| 420 |
+
blockReplyPipeline: BlockReplyPipeline | null;
|
| 421 |
+
clearRestartRecoveryDeliveryClaim: () => Promise<void>;
|
| 422 |
+
providedReplyOperation: ReplyOperation | undefined;
|
| 423 |
+
queueKey: string;
|
| 424 |
+
replyOperation: ReplyOperation;
|
| 425 |
+
runFollowupTurn: (queued: FollowupRun) => Promise<void>;
|
| 426 |
+
sessionKey: string | undefined;
|
| 427 |
+
shouldDrainQueuedFollowupsAfterClear: boolean;
|
| 428 |
+
typing: TypingController;
|
| 429 |
+
}): Promise<void> {
|
| 430 |
+
const {
|
| 431 |
+
blockReplyPipeline,
|
| 432 |
+
clearRestartRecoveryDeliveryClaim,
|
| 433 |
+
providedReplyOperation,
|
| 434 |
+
queueKey,
|
| 435 |
+
replyOperation,
|
| 436 |
+
runFollowupTurn,
|
| 437 |
+
sessionKey,
|
| 438 |
+
shouldDrainQueuedFollowupsAfterClear,
|
| 439 |
+
typing,
|
| 440 |
+
} = context;
|
| 441 |
+
|
| 442 |
+
try {
|
| 443 |
+
await clearRestartRecoveryDeliveryClaim();
|
| 444 |
+
} catch (error) {
|
| 445 |
+
logVerbose(
|
| 446 |
+
`failed to clear restart recovery delivery context for ${sessionKey ?? "unknown"}: ${String(
|
| 447 |
+
error,
|
| 448 |
+
)}`,
|
| 449 |
+
);
|
| 450 |
+
}
|
| 451 |
+
if (shouldDrainQueuedFollowupsAfterClear) {
|
| 452 |
+
scheduleFollowupDrainAfterReplyOperationClear({
|
| 453 |
+
operation: replyOperation,
|
| 454 |
+
queueKey,
|
| 455 |
+
runFollowup: runFollowupTurn,
|
| 456 |
+
});
|
| 457 |
+
if (!providedReplyOperation) {
|
| 458 |
+
replyOperation.complete();
|
| 459 |
+
}
|
| 460 |
+
} else if (!providedReplyOperation) {
|
| 461 |
+
replyOperation.complete();
|
| 462 |
+
}
|
| 463 |
+
blockReplyPipeline?.stop();
|
| 464 |
+
typing.markRunComplete();
|
| 465 |
+
// Safety net: the dispatcher's onIdle callback normally fires
|
| 466 |
+
// markDispatchIdle(), but if the dispatcher exits early, errors,
|
| 467 |
+
// or the reply path doesn't go through it cleanly, the second
|
| 468 |
+
// signal never fires and the typing keepalive loop runs forever.
|
| 469 |
+
// Repeated completion signals are harmless: cleanup() is guarded by
|
| 470 |
+
// the typing controller's sealed flag.
|
| 471 |
+
typing.markDispatchIdle();
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
export type RunReplyAgentParams = {
|
| 475 |
+
commandBody: string;
|
| 476 |
+
transcriptCommandBody?: string;
|
| 477 |
+
followupRun: FollowupRun;
|
| 478 |
+
queueKey: string;
|
| 479 |
+
resolvedQueue: QueueSettings;
|
| 480 |
+
shouldSteer: boolean;
|
| 481 |
+
shouldFollowup: boolean;
|
| 482 |
+
queueAdmissionState?: "empty" | "steering" | "ready";
|
| 483 |
+
isActive: boolean;
|
| 484 |
+
isRunActive?: () => boolean;
|
| 485 |
+
opts?: InternalGetReplyOptions;
|
| 486 |
+
typing: TypingController;
|
| 487 |
+
sessionEntry?: SessionEntry;
|
| 488 |
+
sessionStore?: Record<string, SessionEntry>;
|
| 489 |
+
sessionKey?: string;
|
| 490 |
+
runtimePolicySessionKey?: string;
|
| 491 |
+
storePath?: string;
|
| 492 |
+
defaultModel: string;
|
| 493 |
+
resolvedVerboseLevel: VerboseLevel;
|
| 494 |
+
toolProgressDetail?: "explain" | "raw";
|
| 495 |
+
isNewSession: boolean;
|
| 496 |
+
blockStreamingEnabled: boolean;
|
| 497 |
+
blockReplyChunking?: {
|
| 498 |
+
minChars: number;
|
| 499 |
+
maxChars: number;
|
| 500 |
+
breakPreference: "paragraph" | "newline" | "sentence";
|
| 501 |
+
flushOnParagraph?: boolean;
|
| 502 |
+
};
|
| 503 |
+
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
| 504 |
+
sessionCtx: TemplateContext;
|
| 505 |
+
shouldInjectGroupIntro: boolean;
|
| 506 |
+
typingMode: TypingMode;
|
| 507 |
+
resetTriggered?: boolean;
|
| 508 |
+
replyThreadingOverride?: TemplateContext["ReplyThreading"];
|
| 509 |
+
replyOperation?: ReplyOperation;
|
| 510 |
+
};
|
src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tests direct runtime config overrides passed into agent runner execution.
|
| 2 |
+
import { writeFile } from "node:fs/promises";
|
| 3 |
+
import { join } from "node:path";
|
| 4 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 5 |
+
import { OAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
|
| 6 |
+
import { FailoverError } from "../../agents/failover-error.js";
|
| 7 |
+
import { SessionManager } from "../../agents/sessions/session-manager.js";
|
| 8 |
+
import {
|
| 9 |
+
appendTranscriptMessage,
|
| 10 |
+
loadSessionEntry,
|
| 11 |
+
replaceSessionEntry,
|
| 12 |
+
} from "../../config/sessions/session-accessor.js";
|
| 13 |
+
import type { SessionEntry } from "../../config/sessions/types.js";
|
| 14 |
+
import {
|
| 15 |
+
clearMemoryPluginState,
|
| 16 |
+
registerMemoryCapability,
|
| 17 |
+
} from "../../plugins/memory-state.test-fixtures.js";
|
| 18 |
+
import { withTestDir } from "../../test-helpers/temp-dir.js";
|
| 19 |
+
import { withReplyDispatcher } from "../dispatch-dispatcher.js";
|
| 20 |
+
import { getReplyPayloadMetadata } from "../reply-payload.js";
|
| 21 |
+
import type { TemplateContext } from "../templating.js";
|
| 22 |
+
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
| 23 |
+
import type { ReplyPayload } from "../types.js";
|
| 24 |
+
import { createTestFollowupRun, withTestModelContextTokens } from "./agent-runner.test-fixtures.js";
|
| 25 |
+
import type { QueueSettings } from "./queue.js";
|
| 26 |
+
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
| 27 |
+
import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
|
| 28 |
+
import {
|
| 29 |
+
REPLY_OPERATION_RUN_STATE,
|
| 30 |
+
resolveReplyOperationAgentTurn,
|
| 31 |
+
type ReplyOperationRunState,
|
| 32 |
+
} from "./reply-operation-run-state.js";
|
| 33 |
+
import type { ReplyOperation } from "./reply-run-registry.js";
|
| 34 |
+
import { createMockReplyOperation, createMockTypingController } from "./test-helpers.js";
|
| 35 |
+
|
| 36 |
+
const freshCfg = { runtimeFresh: true };
|
| 37 |
+
const staleCfg = {
|
| 38 |
+
runtimeFresh: false,
|
| 39 |
+
skills: {
|
| 40 |
+
entries: {
|
| 41 |
+
whisper: {
|
| 42 |
+
apiKey: { source: "env" as const, provider: "default", id: "OPENAI_API_KEY" },
|
| 43 |
+
},
|
| 44 |
+
},
|
| 45 |
+
},
|
| 46 |
+
};
|
| 47 |
+
const sentinelError = new Error("stop-after-preflight");
|
| 48 |
+
|
| 49 |
+
const resolveQueuedReplyExecutionConfigMock = vi.fn();
|
| 50 |
+
const resolveReplyToModeMock = vi.fn();
|
| 51 |
+
const createReplyToModeFilterForChannelMock = vi.fn();
|
| 52 |
+
const createReplyMediaContextMock = vi.fn();
|
| 53 |
+
const createReplyMediaPathNormalizerMock = vi.fn();
|
| 54 |
+
const runSessionCompactionIfNeededMock = vi.fn();
|
| 55 |
+
const runMemoryFlushIfNeededMock = vi.fn();
|
| 56 |
+
const executeAgentTurnMock = vi.fn();
|
| 57 |
+
const resetReplyRunSessionMock = vi.fn();
|
| 58 |
+
const enqueueFollowupRunMock = vi.fn();
|
| 59 |
+
|
| 60 |
+
vi.mock("./agent-runner-utils.js", async () => {
|
| 61 |
+
const actual =
|
| 62 |
+
await vi.importActual<typeof import("./agent-runner-utils.js")>("./agent-runner-utils.js");
|
| 63 |
+
return {
|
| 64 |
+
...actual,
|
| 65 |
+
resolveQueuedReplyExecutionConfig: (...args: unknown[]) =>
|
| 66 |
+
resolveQueuedReplyExecutionConfigMock(...args),
|
| 67 |
+
};
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
vi.mock("./reply-threading.js", async () => {
|
| 71 |
+
const actual =
|
| 72 |
+
await vi.importActual<typeof import("./reply-threading.js")>("./reply-threading.js");
|
| 73 |
+
return {
|
| 74 |
+
...actual,
|
| 75 |
+
resolveReplyToMode: (...args: unknown[]) => resolveReplyToModeMock(...args),
|
| 76 |
+
createReplyToModeFilterForChannel: (...args: unknown[]) =>
|
| 77 |
+
createReplyToModeFilterForChannelMock(...args),
|
| 78 |
+
};
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
vi.mock("./reply-media-paths.js", () => ({
|
| 82 |
+
createReplyMediaContext: (...args: unknown[]) => {
|
| 83 |
+
createReplyMediaContextMock(...args);
|
| 84 |
+
return {
|
| 85 |
+
normalizePayload: createReplyMediaPathNormalizerMock(...args),
|
| 86 |
+
};
|
| 87 |
+
},
|
| 88 |
+
createReplyMediaPathNormalizer: (...args: unknown[]) =>
|
| 89 |
+
createReplyMediaPathNormalizerMock(...args),
|
| 90 |
+
}));
|
| 91 |
+
|
| 92 |
+
vi.mock("./agent-runner-memory.js", () => ({
|
| 93 |
+
runSessionCompactionIfNeeded: (...args: unknown[]) => runSessionCompactionIfNeededMock(...args),
|
| 94 |
+
runMemoryFlushIfNeeded: (...args: unknown[]) => runMemoryFlushIfNeededMock(...args),
|
| 95 |
+
}));
|
| 96 |
+
|
| 97 |
+
vi.mock("./agent-runner-execution.js", async () => {
|
| 98 |
+
const actual = await vi.importActual<typeof import("./agent-runner-execution.js")>(
|
| 99 |
+
"./agent-runner-execution.js",
|
| 100 |
+
);
|
| 101 |
+
return {
|
| 102 |
+
...actual,
|
| 103 |
+
executeAgentTurn: (...args: unknown[]) => executeAgentTurnMock(...args),
|
| 104 |
+
};
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
vi.mock("./agent-runner-session-reset.js", async () => {
|
| 108 |
+
const actual = await vi.importActual<typeof import("./agent-runner-session-reset.js")>(
|
| 109 |
+
"./agent-runner-session-reset.js",
|
| 110 |
+
);
|
| 111 |
+
return {
|
| 112 |
+
...actual,
|
| 113 |
+
resetReplyRunSession: (...args: unknown[]) => resetReplyRunSessionMock(...args),
|
| 114 |
+
};
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
vi.mock("./queue.js", async () => {
|
| 118 |
+
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
| 119 |
+
return {
|
| 120 |
+
...actual,
|
| 121 |
+
enqueueFollowupRun: (...args: unknown[]) => enqueueFollowupRunMock(...args),
|
| 122 |
+
};
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
const { runReplyAgent } = await import("./agent-runner.js");
|
| 126 |
+
|
| 127 |
+
function createTelegramSessionCtx(): TemplateContext {
|
| 128 |
+
return {
|
| 129 |
+
Provider: "telegram",
|
| 130 |
+
OriginatingChannel: "telegram",
|
| 131 |
+
OriginatingTo: "12345",
|
| 132 |
+
AccountId: "default",
|
| 133 |
+
ChatType: "dm",
|
| 134 |
+
MessageSid: "msg-1",
|
| 135 |
+
} as unknown as TemplateContext;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
type TestReplyOperation = ReplyOperation & {
|
| 139 |
+
setPhase: ReturnType<typeof vi.fn<ReplyOperation["setPhase"]>>;
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
function createReplyOperation(): TestReplyOperation {
|
| 143 |
+
const { replyOperation } = createMockReplyOperation({ key: "test", sessionId: "session-1" });
|
| 144 |
+
return Object.assign(replyOperation, {
|
| 145 |
+
phase: "queued" as const,
|
| 146 |
+
setPhase: vi.fn<ReplyOperation["setPhase"]>(),
|
| 147 |
+
hasOwnedSessionId: vi.fn(() => false),
|
| 148 |
+
captureOwnedSessionIds: vi.fn(() => new Set()),
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function createDirectRuntimeReplyParams({
|
| 153 |
+
shouldFollowup,
|
| 154 |
+
isActive,
|
| 155 |
+
}: {
|
| 156 |
+
shouldFollowup: boolean;
|
| 157 |
+
isActive: boolean;
|
| 158 |
+
}) {
|
| 159 |
+
const followupRun = createTestFollowupRun({
|
| 160 |
+
sessionId: "session-1",
|
| 161 |
+
sessionKey: "agent:main:telegram:default:direct:test",
|
| 162 |
+
messageProvider: "telegram",
|
| 163 |
+
config: staleCfg,
|
| 164 |
+
provider: "openai",
|
| 165 |
+
model: "gpt-5.4",
|
| 166 |
+
});
|
| 167 |
+
const resolvedQueue = { mode: "interrupt" } as QueueSettings;
|
| 168 |
+
const replyParams: Parameters<typeof runReplyAgent>[0] = {
|
| 169 |
+
commandBody: "hello",
|
| 170 |
+
followupRun,
|
| 171 |
+
queueKey: "main",
|
| 172 |
+
resolvedQueue,
|
| 173 |
+
shouldSteer: false,
|
| 174 |
+
shouldFollowup,
|
| 175 |
+
isActive,
|
| 176 |
+
typing: createMockTypingController(),
|
| 177 |
+
sessionCtx: createTelegramSessionCtx(),
|
| 178 |
+
defaultModel: "openai/gpt-5.4",
|
| 179 |
+
resolvedVerboseLevel: "off",
|
| 180 |
+
isNewSession: false,
|
| 181 |
+
blockStreamingEnabled: false,
|
| 182 |
+
resolvedBlockStreamingBreak: "message_end",
|
| 183 |
+
shouldInjectGroupIntro: false,
|
| 184 |
+
typingMode: "instant",
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
return { followupRun, resolvedQueue, replyParams };
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function requireResolveQueuedReplyExecutionConfigCall(index = 0) {
|
| 191 |
+
const call = resolveQueuedReplyExecutionConfigMock.mock.calls[index] as
|
| 192 |
+
| [
|
| 193 |
+
unknown,
|
| 194 |
+
{
|
| 195 |
+
originatingChannel?: string;
|
| 196 |
+
messageProvider?: string;
|
| 197 |
+
},
|
| 198 |
+
]
|
| 199 |
+
| undefined;
|
| 200 |
+
if (!call) {
|
| 201 |
+
throw new Error(`resolveQueuedReplyExecutionConfig call ${index} missing`);
|
| 202 |
+
}
|
| 203 |
+
return call;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
type MockCallSource = {
|
| 207 |
+
mock: {
|
| 208 |
+
calls: unknown[][];
|
| 209 |
+
};
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
function requireMaintenanceCall(mock: MockCallSource, name: string, index = 0) {
|
| 213 |
+
const call = mock.mock.calls[index]?.[0] as
|
| 214 |
+
| {
|
| 215 |
+
cfg?: unknown;
|
| 216 |
+
followupRun?: unknown;
|
| 217 |
+
sessionKey?: string;
|
| 218 |
+
runtimePolicySessionKey?: string;
|
| 219 |
+
}
|
| 220 |
+
| undefined;
|
| 221 |
+
if (!call) {
|
| 222 |
+
throw new Error(`${name} call ${index} missing`);
|
| 223 |
+
}
|
| 224 |
+
return call;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
type PreflightParams = Parameters<
|
| 228 |
+
typeof import("./agent-runner-memory.js").runSessionCompactionIfNeeded
|
| 229 |
+
>[0];
|
| 230 |
+
|
| 231 |
+
async function runRequiredCheckpoint(params: PreflightParams) {
|
| 232 |
+
if (!params.beforeCompaction) {
|
| 233 |
+
throw new Error("Expected the required preflight checkpoint");
|
| 234 |
+
}
|
| 235 |
+
return params.beforeCompaction(
|
| 236 |
+
params.sessionEntry ?? { sessionId: params.followupRun.run.sessionId, updatedAt: 1 },
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
describe("runReplyAgent runtime config", () => {
|
| 241 |
+
beforeEach(() => {
|
| 242 |
+
resolveQueuedReplyExecutionConfigMock.mockReset();
|
| 243 |
+
resolveReplyToModeMock.mockReset();
|
| 244 |
+
createReplyToModeFilterForChannelMock.mockReset();
|
| 245 |
+
createReplyMediaContextMock.mockReset();
|
| 246 |
+
createReplyMediaPathNormalizerMock.mockReset();
|
| 247 |
+
runSessionCompactionIfNeededMock.mockReset();
|
| 248 |
+
runMemoryFlushIfNeededMock.mockReset();
|
| 249 |
+
executeAgentTurnMock.mockReset();
|
| 250 |
+
resetReplyRunSessionMock.mockReset();
|
| 251 |
+
enqueueFollowupRunMock.mockReset();
|
| 252 |
+
|
| 253 |
+
resolveQueuedReplyExecutionConfigMock.mockResolvedValue(freshCfg);
|
| 254 |
+
resolveReplyToModeMock.mockReturnValue("all");
|
| 255 |
+
createReplyToModeFilterForChannelMock.mockReturnValue((payload: unknown) => payload);
|
| 256 |
+
createReplyMediaPathNormalizerMock.mockReturnValue((payload: unknown) => payload);
|
| 257 |
+
runSessionCompactionIfNeededMock.mockImplementation(async (params: PreflightParams) => {
|
| 258 |
+
await runRequiredCheckpoint(params);
|
| 259 |
+
throw sentinelError;
|
| 260 |
+
});
|
| 261 |
+
runMemoryFlushIfNeededMock.mockResolvedValue({ sessionEntry: undefined, outcome: "skipped" });
|
| 262 |
+
executeAgentTurnMock.mockResolvedValue({
|
| 263 |
+
runId: "runtime-config-test",
|
| 264 |
+
outcome: { kind: "rejected", payload: { text: "main reply" } },
|
| 265 |
+
});
|
| 266 |
+
resetReplyRunSessionMock.mockResolvedValue(false);
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
it("resolves direct reply runs before early helpers read config", async () => {
|
| 270 |
+
const { followupRun, replyParams } = createDirectRuntimeReplyParams({
|
| 271 |
+
shouldFollowup: false,
|
| 272 |
+
isActive: false,
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
await expect(runReplyAgent(replyParams)).rejects.toBe(sentinelError);
|
| 276 |
+
|
| 277 |
+
expect(followupRun.run.config).toBe(freshCfg);
|
| 278 |
+
expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledTimes(1);
|
| 279 |
+
const [configArg, configContextArg] = requireResolveQueuedReplyExecutionConfigCall();
|
| 280 |
+
expect(configArg).toBe(staleCfg);
|
| 281 |
+
expect(configContextArg.originatingChannel).toBe("telegram");
|
| 282 |
+
expect(configContextArg.messageProvider).toBe("telegram");
|
| 283 |
+
expect(resolveReplyToModeMock).toHaveBeenCalledWith(freshCfg, "telegram", "default", "dm");
|
| 284 |
+
expect(createReplyMediaContextMock).toHaveBeenCalledWith({
|
| 285 |
+
cfg: freshCfg,
|
| 286 |
+
agentId: "main",
|
| 287 |
+
sessionKey: undefined,
|
| 288 |
+
workspaceDir: followupRun.run.workspaceDir,
|
| 289 |
+
messageProvider: "telegram",
|
| 290 |
+
accountId: undefined,
|
| 291 |
+
groupId: undefined,
|
| 292 |
+
groupChannel: undefined,
|
| 293 |
+
groupSpace: undefined,
|
| 294 |
+
requesterSenderId: undefined,
|
| 295 |
+
requesterSenderName: undefined,
|
| 296 |
+
requesterSenderUsername: undefined,
|
| 297 |
+
requesterSenderE164: undefined,
|
| 298 |
+
});
|
| 299 |
+
expect(runSessionCompactionIfNeededMock).toHaveBeenCalledTimes(1);
|
| 300 |
+
expect(runMemoryFlushIfNeededMock).toHaveBeenCalledTimes(1);
|
| 301 |
+
expect(runSessionCompactionIfNeededMock.mock.invocationCallOrder[0]).toBeLessThan(
|
| 302 |
+
runMemoryFlushIfNeededMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
| 303 |
+
);
|
| 304 |
+
const memoryCall = requireMaintenanceCall(runMemoryFlushIfNeededMock, "runMemoryFlushIfNeeded");
|
| 305 |
+
expect(memoryCall.cfg).toBe(freshCfg);
|
| 306 |
+
expect(memoryCall.followupRun).toBe(followupRun);
|
| 307 |
+
const preflightCall = requireMaintenanceCall(
|
| 308 |
+
runSessionCompactionIfNeededMock,
|
| 309 |
+
"runSessionCompactionIfNeeded",
|
| 310 |
+
);
|
| 311 |
+
expect(preflightCall.cfg).toBe(freshCfg);
|
| 312 |
+
expect(preflightCall.followupRun).toBe(followupRun);
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
it("passes the derived runtime-policy key to pre-run maintenance", async () => {
|
| 316 |
+
const { followupRun, replyParams } = createDirectRuntimeReplyParams({
|
| 317 |
+
shouldFollowup: false,
|
| 318 |
+
isActive: false,
|
| 319 |
+
});
|
| 320 |
+
const runtimePolicySessionKey = "agent:main:telegram:default:direct:test";
|
| 321 |
+
followupRun.run.sessionKey = "agent:main:main";
|
| 322 |
+
followupRun.run.runtimePolicySessionKey = runtimePolicySessionKey;
|
| 323 |
+
replyParams.sessionKey = "agent:main:main";
|
| 324 |
+
replyParams.runtimePolicySessionKey = runtimePolicySessionKey;
|
| 325 |
+
|
| 326 |
+
await expect(runReplyAgent(replyParams)).rejects.toBe(sentinelError);
|
| 327 |
+
|
| 328 |
+
const preflightCall = requireMaintenanceCall(
|
| 329 |
+
runSessionCompactionIfNeededMock,
|
| 330 |
+
"runSessionCompactionIfNeeded",
|
| 331 |
+
);
|
| 332 |
+
expect(preflightCall.sessionKey).toBe("agent:main:main");
|
| 333 |
+
expect(preflightCall.runtimePolicySessionKey).toBe(runtimePolicySessionKey);
|
| 334 |
+
const memoryCall = requireMaintenanceCall(runMemoryFlushIfNeededMock, "runMemoryFlushIfNeeded");
|
| 335 |
+
expect(memoryCall.sessionKey).toBe("agent:main:main");
|
| 336 |
+
expect(memoryCall.runtimePolicySessionKey).toBe(runtimePolicySessionKey);
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
it("continues the main reply after a recorded memory-flush failure", async () => {
|
| 340 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 341 |
+
shouldFollowup: false,
|
| 342 |
+
isActive: false,
|
| 343 |
+
});
|
| 344 |
+
const onBlockReply = vi.fn();
|
| 345 |
+
const replyOperation = createReplyOperation();
|
| 346 |
+
replyParams.opts = { sourceReplyDeliveryMode: "message_tool_only", onBlockReply };
|
| 347 |
+
replyParams.replyOperation = replyOperation;
|
| 348 |
+
resolveQueuedReplyExecutionConfigMock.mockResolvedValue({
|
| 349 |
+
...freshCfg,
|
| 350 |
+
agents: { defaults: { compaction: { notifyUser: true } } },
|
| 351 |
+
});
|
| 352 |
+
runSessionCompactionIfNeededMock.mockImplementation(runRequiredCheckpoint);
|
| 353 |
+
runMemoryFlushIfNeededMock.mockImplementation(
|
| 354 |
+
async (params: {
|
| 355 |
+
replyOperation: ReplyOperation;
|
| 356 |
+
onVisibleErrorPayloads?: (payloads: Array<{ text?: string; isError?: boolean }>) => void;
|
| 357 |
+
}) => {
|
| 358 |
+
params.replyOperation.setPhase("memory_flushing");
|
| 359 |
+
params.onVisibleErrorPayloads?.([
|
| 360 |
+
{ text: "⚠️ memory flush preparation failed", isError: true },
|
| 361 |
+
]);
|
| 362 |
+
return { sessionEntry: undefined, outcome: "failed" };
|
| 363 |
+
},
|
| 364 |
+
);
|
| 365 |
+
|
| 366 |
+
const result = await runReplyAgent(replyParams);
|
| 367 |
+
|
| 368 |
+
expect(result).toEqual({ text: "main reply" });
|
| 369 |
+
expect(onBlockReply).not.toHaveBeenCalled();
|
| 370 |
+
expect(executeAgentTurnMock).toHaveBeenCalledOnce();
|
| 371 |
+
expect(replyOperation.setPhase).toHaveBeenLastCalledWith("running");
|
| 372 |
+
});
|
| 373 |
+
|
| 374 |
+
it("preserves conversation without running byte-forced optional memory work before reply", async () => {
|
| 375 |
+
const memory = await vi.importActual<typeof import("./agent-runner-memory.js")>(
|
| 376 |
+
"./agent-runner-memory.js",
|
| 377 |
+
);
|
| 378 |
+
const reset = await vi.importActual<typeof import("./agent-runner-session-reset.js")>(
|
| 379 |
+
"./agent-runner-session-reset.js",
|
| 380 |
+
);
|
| 381 |
+
runMemoryFlushIfNeededMock.mockImplementation(memory.runMemoryFlushIfNeeded);
|
| 382 |
+
runSessionCompactionIfNeededMock.mockImplementation(memory.runSessionCompactionIfNeeded);
|
| 383 |
+
resetReplyRunSessionMock.mockImplementation(reset.resetReplyRunSession);
|
| 384 |
+
registerMemoryCapability("memory-core", {
|
| 385 |
+
flushPlanResolver: () => ({
|
| 386 |
+
softThresholdTokens: 4_000,
|
| 387 |
+
forceFlushTranscriptBytes: 1,
|
| 388 |
+
reserveTokensFloor: 20_000,
|
| 389 |
+
prompt: "Save durable notes. NO_REPLY",
|
| 390 |
+
systemPrompt: "Write memory to memory/notes.md.",
|
| 391 |
+
relativePath: "memory/notes.md",
|
| 392 |
+
}),
|
| 393 |
+
});
|
| 394 |
+
try {
|
| 395 |
+
await withTestDir({ prefix: "openclaw-direct-runtime-" }, async (tempDir) => {
|
| 396 |
+
const { replyParams, followupRun } = createDirectRuntimeReplyParams({
|
| 397 |
+
shouldFollowup: false,
|
| 398 |
+
isActive: false,
|
| 399 |
+
});
|
| 400 |
+
const sessionKey = "agent:main:telegram:default:direct:test";
|
| 401 |
+
const sessionEntry: SessionEntry = {
|
| 402 |
+
sessionId: "session-1",
|
| 403 |
+
lifecycleRevision: "before-maintenance",
|
| 404 |
+
updatedAt: 1,
|
| 405 |
+
totalTokens: 100,
|
| 406 |
+
totalTokensFresh: true,
|
| 407 |
+
totalTokensVersion: 1,
|
| 408 |
+
compactionCount: 4,
|
| 409 |
+
memoryFlush: { kind: "failed", failureCount: 2 },
|
| 410 |
+
};
|
| 411 |
+
const sessionStore = { [sessionKey]: sessionEntry };
|
| 412 |
+
const storePath = join(tempDir, "sessions.json");
|
| 413 |
+
const scope = { agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath };
|
| 414 |
+
await replaceSessionEntry(scope, sessionEntry);
|
| 415 |
+
const olderMessage = { role: "user", content: "Remember the older marker: amber-orchid" };
|
| 416 |
+
const messages = [
|
| 417 |
+
olderMessage,
|
| 418 |
+
{ role: "assistant", content: "Noted." },
|
| 419 |
+
...Array.from({ length: 3 }, (_, index) => [
|
| 420 |
+
{ role: "user", content: `Recent question ${index}` },
|
| 421 |
+
{ role: "assistant", content: `Recent answer ${index}` },
|
| 422 |
+
]).flat(),
|
| 423 |
+
];
|
| 424 |
+
for (const message of messages) {
|
| 425 |
+
await appendTranscriptMessage(scope, { message });
|
| 426 |
+
}
|
| 427 |
+
// A file blocks the memory directory: optional maintenance fails without token pressure.
|
| 428 |
+
await writeFile(join(tempDir, "memory"), "not a directory");
|
| 429 |
+
followupRun.run.workspaceDir = tempDir;
|
| 430 |
+
followupRun.run.provider = "anthropic";
|
| 431 |
+
followupRun.run.model = "claude-sonnet-4-6";
|
| 432 |
+
replyParams.sessionKey = sessionKey;
|
| 433 |
+
replyParams.storePath = storePath;
|
| 434 |
+
replyParams.sessionEntry = sessionEntry;
|
| 435 |
+
replyParams.sessionStore = sessionStore;
|
| 436 |
+
resolveQueuedReplyExecutionConfigMock.mockResolvedValue(
|
| 437 |
+
withTestModelContextTokens({
|
| 438 |
+
cfg: { agents: { defaults: { compaction: { notifyUser: true } } } },
|
| 439 |
+
followupRun,
|
| 440 |
+
defaultModel: replyParams.defaultModel,
|
| 441 |
+
contextTokens: 100_000,
|
| 442 |
+
}),
|
| 443 |
+
);
|
| 444 |
+
const onBlockReply = vi.fn();
|
| 445 |
+
replyParams.opts = { onBlockReply };
|
| 446 |
+
const replyOperation = createReplyOperation();
|
| 447 |
+
replyParams.replyOperation = replyOperation;
|
| 448 |
+
executeAgentTurnMock.mockImplementation(async () => {
|
| 449 |
+
expect(SessionManager.open(scope).buildSessionContext().messages).toEqual(
|
| 450 |
+
messages.map((message) => expect.objectContaining(message)),
|
| 451 |
+
);
|
| 452 |
+
return {
|
| 453 |
+
runId: "runtime-config-test",
|
| 454 |
+
outcome: { kind: "rejected", payload: { text: "main reply" } },
|
| 455 |
+
};
|
| 456 |
+
});
|
| 457 |
+
|
| 458 |
+
const result = await runReplyAgent(replyParams);
|
| 459 |
+
|
| 460 |
+
expect(result).toEqual({ text: "main reply" });
|
| 461 |
+
expect(resetReplyRunSessionMock).not.toHaveBeenCalled();
|
| 462 |
+
expect(followupRun.run.sessionId).toBe(sessionEntry.sessionId);
|
| 463 |
+
expect(replyOperation.sessionId).toBe(sessionEntry.sessionId);
|
| 464 |
+
expect(loadSessionEntry(scope)).toMatchObject({
|
| 465 |
+
sessionId: sessionEntry.sessionId,
|
| 466 |
+
lifecycleRevision: sessionEntry.lifecycleRevision,
|
| 467 |
+
compactionCount: 4,
|
| 468 |
+
totalTokens: 100,
|
| 469 |
+
totalTokensFresh: true,
|
| 470 |
+
memoryFlush: { kind: "failed", failureCount: 2 },
|
| 471 |
+
});
|
| 472 |
+
expect(sessionStore[sessionKey]).toMatchObject({
|
| 473 |
+
lifecycleRevision: sessionEntry.lifecycleRevision,
|
| 474 |
+
compactionCount: 4,
|
| 475 |
+
totalTokens: 100,
|
| 476 |
+
});
|
| 477 |
+
expect(runMemoryFlushIfNeededMock).not.toHaveBeenCalled();
|
| 478 |
+
expect(runSessionCompactionIfNeededMock).toHaveBeenCalledOnce();
|
| 479 |
+
expect(onBlockReply).not.toHaveBeenCalled();
|
| 480 |
+
expect(executeAgentTurnMock).toHaveBeenCalledOnce();
|
| 481 |
+
});
|
| 482 |
+
} finally {
|
| 483 |
+
clearMemoryPluginState();
|
| 484 |
+
}
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
it("keeps the compacted session when preflight recovers an exhausted memory flush", async () => {
|
| 488 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 489 |
+
shouldFollowup: false,
|
| 490 |
+
isActive: false,
|
| 491 |
+
});
|
| 492 |
+
const sessionEntry = {
|
| 493 |
+
sessionId: "session-1",
|
| 494 |
+
updatedAt: 1,
|
| 495 |
+
compactionCount: 4,
|
| 496 |
+
};
|
| 497 |
+
replyParams.sessionEntry = sessionEntry;
|
| 498 |
+
runMemoryFlushIfNeededMock.mockResolvedValue({
|
| 499 |
+
sessionEntry,
|
| 500 |
+
outcome: "exhausted",
|
| 501 |
+
});
|
| 502 |
+
runSessionCompactionIfNeededMock.mockImplementation(async (params: PreflightParams) => {
|
| 503 |
+
expect(params.sessionEntry?.sessionId).toBe("session-1");
|
| 504 |
+
const checkpointed = await runRequiredCheckpoint(params);
|
| 505 |
+
return { ...checkpointed, compactionCount: 5 };
|
| 506 |
+
});
|
| 507 |
+
|
| 508 |
+
await expect(runReplyAgent(replyParams)).resolves.toEqual({ text: "main reply" });
|
| 509 |
+
|
| 510 |
+
expect(resetReplyRunSessionMock).not.toHaveBeenCalled();
|
| 511 |
+
expect(executeAgentTurnMock).toHaveBeenCalledOnce();
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
it.each(["context_overflow", "auth profile mismatch"])(
|
| 515 |
+
"surfaces required preflight failure (%s) after memory exhaustion without resetting",
|
| 516 |
+
async (reason) => {
|
| 517 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 518 |
+
shouldFollowup: false,
|
| 519 |
+
isActive: false,
|
| 520 |
+
});
|
| 521 |
+
runMemoryFlushIfNeededMock.mockResolvedValue({
|
| 522 |
+
sessionEntry: { sessionId: "session-1", updatedAt: 1, compactionCount: 4 },
|
| 523 |
+
outcome: "exhausted",
|
| 524 |
+
});
|
| 525 |
+
runSessionCompactionIfNeededMock.mockImplementation(async (params: PreflightParams) => {
|
| 526 |
+
await runRequiredCheckpoint(params);
|
| 527 |
+
throw new Error(`Preflight compaction required but failed: ${reason}`);
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
const result = await runReplyAgent(replyParams);
|
| 531 |
+
|
| 532 |
+
if (!result || Array.isArray(result)) {
|
| 533 |
+
throw new Error("expected a single preflight compaction failure reply payload");
|
| 534 |
+
}
|
| 535 |
+
expect(result.text).toContain("auto-compaction could not recover");
|
| 536 |
+
expect(getReplyPayloadMetadata(result)?.deliverDespiteSourceReplySuppression).toBe(true);
|
| 537 |
+
expect(resetReplyRunSessionMock).not.toHaveBeenCalled();
|
| 538 |
+
expect(executeAgentTurnMock).not.toHaveBeenCalled();
|
| 539 |
+
},
|
| 540 |
+
);
|
| 541 |
+
|
| 542 |
+
it.each(["abortByUser", "abortForRestart"] as const)(
|
| 543 |
+
"records %s during memory flush as cancellation without starting the main turn",
|
| 544 |
+
async (abortMethod) => {
|
| 545 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 546 |
+
shouldFollowup: false,
|
| 547 |
+
isActive: false,
|
| 548 |
+
});
|
| 549 |
+
const runState: ReplyOperationRunState = {};
|
| 550 |
+
replyParams.opts = { [REPLY_OPERATION_RUN_STATE]: runState };
|
| 551 |
+
runSessionCompactionIfNeededMock.mockImplementation(runRequiredCheckpoint);
|
| 552 |
+
runMemoryFlushIfNeededMock.mockImplementation(
|
| 553 |
+
async (params: { replyOperation: ReplyOperation }) => {
|
| 554 |
+
expect(params.replyOperation[abortMethod]()).toBe(true);
|
| 555 |
+
return { sessionEntry: undefined, outcome: "failed" };
|
| 556 |
+
},
|
| 557 |
+
);
|
| 558 |
+
|
| 559 |
+
const result = await runReplyAgent(replyParams);
|
| 560 |
+
|
| 561 |
+
expect(result).toMatchObject({
|
| 562 |
+
text:
|
| 563 |
+
abortMethod === "abortByUser"
|
| 564 |
+
? SILENT_REPLY_TOKEN
|
| 565 |
+
: "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
|
| 566 |
+
});
|
| 567 |
+
expect(resolveReplyOperationAgentTurn(runState)).toBe("cancelled");
|
| 568 |
+
expect(executeAgentTurnMock).not.toHaveBeenCalled();
|
| 569 |
+
},
|
| 570 |
+
);
|
| 571 |
+
|
| 572 |
+
it.each(["user", "restart"] as const)(
|
| 573 |
+
"records an aborted %s agent turn as cancellation",
|
| 574 |
+
async (reason) => {
|
| 575 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 576 |
+
shouldFollowup: false,
|
| 577 |
+
isActive: false,
|
| 578 |
+
});
|
| 579 |
+
const runState: ReplyOperationRunState = {};
|
| 580 |
+
replyParams.opts = { [REPLY_OPERATION_RUN_STATE]: runState };
|
| 581 |
+
runSessionCompactionIfNeededMock.mockResolvedValue(undefined);
|
| 582 |
+
executeAgentTurnMock.mockResolvedValue({
|
| 583 |
+
runId: "cancelled-runtime-config-test",
|
| 584 |
+
outcome: { kind: "aborted", reason },
|
| 585 |
+
});
|
| 586 |
+
|
| 587 |
+
await runReplyAgent(replyParams);
|
| 588 |
+
|
| 589 |
+
expect(resolveReplyOperationAgentTurn(runState)).toBe("cancelled");
|
| 590 |
+
},
|
| 591 |
+
);
|
| 592 |
+
|
| 593 |
+
it("surfaces known pre-run Codex usage-limit failures instead of dropping the reply", async () => {
|
| 594 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 595 |
+
shouldFollowup: false,
|
| 596 |
+
isActive: false,
|
| 597 |
+
});
|
| 598 |
+
const codexMessage =
|
| 599 |
+
"You've reached your Codex subscription usage limit. Codex did not return a reset time for this limit. Run /codex account for current usage details.";
|
| 600 |
+
runSessionCompactionIfNeededMock.mockRejectedValue(
|
| 601 |
+
new FailoverError(codexMessage, {
|
| 602 |
+
reason: "rate_limit",
|
| 603 |
+
provider: "openai",
|
| 604 |
+
model: "gpt-5.5",
|
| 605 |
+
}),
|
| 606 |
+
);
|
| 607 |
+
runMemoryFlushIfNeededMock.mockResolvedValue({ sessionEntry: undefined, outcome: "skipped" });
|
| 608 |
+
|
| 609 |
+
const result = await runReplyAgent(replyParams);
|
| 610 |
+
|
| 611 |
+
if (!result || Array.isArray(result)) {
|
| 612 |
+
throw new Error("expected a single usage-limit reply payload");
|
| 613 |
+
}
|
| 614 |
+
expect(result.text).toBe(`⚠️ ${codexMessage}`);
|
| 615 |
+
const metadata = getReplyPayloadMetadata(result);
|
| 616 |
+
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
|
| 617 |
+
});
|
| 618 |
+
|
| 619 |
+
it("delivers known pre-run OAuth refresh failures instead of dropping the reply", async () => {
|
| 620 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 621 |
+
shouldFollowup: false,
|
| 622 |
+
isActive: false,
|
| 623 |
+
});
|
| 624 |
+
runSessionCompactionIfNeededMock.mockRejectedValue(
|
| 625 |
+
new OAuthRefreshFailureError({
|
| 626 |
+
provider: "openai",
|
| 627 |
+
message: "refresh_token_invalidated",
|
| 628 |
+
}),
|
| 629 |
+
);
|
| 630 |
+
const delivered = vi.fn<(payload: ReplyPayload, kind: ReplyDispatchKind) => void>();
|
| 631 |
+
const dispatcher = createReplyDispatcher({
|
| 632 |
+
deliver: async (payload, { kind }) => {
|
| 633 |
+
delivered(payload, kind);
|
| 634 |
+
},
|
| 635 |
+
});
|
| 636 |
+
|
| 637 |
+
const result = await withReplyDispatcher({
|
| 638 |
+
dispatcher,
|
| 639 |
+
run: async () => {
|
| 640 |
+
const payload = await runReplyAgent(replyParams);
|
| 641 |
+
if (!payload || Array.isArray(payload)) {
|
| 642 |
+
throw new Error("expected a single pre-run failure reply payload");
|
| 643 |
+
}
|
| 644 |
+
dispatcher.sendFinalReply(payload);
|
| 645 |
+
return payload;
|
| 646 |
+
},
|
| 647 |
+
});
|
| 648 |
+
|
| 649 |
+
const metadata = getReplyPayloadMetadata(result);
|
| 650 |
+
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
|
| 651 |
+
expect(executeAgentTurnMock).not.toHaveBeenCalled();
|
| 652 |
+
expect(delivered).toHaveBeenCalledExactlyOnceWith(
|
| 653 |
+
expect.objectContaining({ text: expect.stringContaining("/login openai"), isError: true }),
|
| 654 |
+
"final",
|
| 655 |
+
);
|
| 656 |
+
expect(delivered.mock.calls[0]?.[0].presentation).toEqual({
|
| 657 |
+
blocks: [
|
| 658 |
+
{
|
| 659 |
+
type: "buttons",
|
| 660 |
+
buttons: [
|
| 661 |
+
{
|
| 662 |
+
label: "Sign in",
|
| 663 |
+
action: { type: "command", command: "/login openai" },
|
| 664 |
+
},
|
| 665 |
+
],
|
| 666 |
+
},
|
| 667 |
+
],
|
| 668 |
+
});
|
| 669 |
+
});
|
| 670 |
+
|
| 671 |
+
it("surfaces preflight compaction failures before the agent starts", async () => {
|
| 672 |
+
const { replyParams } = createDirectRuntimeReplyParams({
|
| 673 |
+
shouldFollowup: false,
|
| 674 |
+
isActive: false,
|
| 675 |
+
});
|
| 676 |
+
runSessionCompactionIfNeededMock.mockRejectedValue(
|
| 677 |
+
new Error("Preflight compaction required but failed: auth profile mismatch"),
|
| 678 |
+
);
|
| 679 |
+
runMemoryFlushIfNeededMock.mockResolvedValue({ sessionEntry: undefined, outcome: "skipped" });
|
| 680 |
+
|
| 681 |
+
const result = await runReplyAgent(replyParams);
|
| 682 |
+
|
| 683 |
+
if (!result || Array.isArray(result)) {
|
| 684 |
+
throw new Error("expected a single preflight compaction failure reply payload");
|
| 685 |
+
}
|
| 686 |
+
expect(result.text).toContain("Context is too large");
|
| 687 |
+
expect(result.text).toContain("auto-compaction could not recover");
|
| 688 |
+
expect(result.text).toContain("/compact");
|
| 689 |
+
expect(result.text).toContain("/new");
|
| 690 |
+
const metadata = getReplyPayloadMetadata(result);
|
| 691 |
+
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
|
| 692 |
+
});
|
| 693 |
+
|
| 694 |
+
it("does not resolve secrets before the enqueue-followup queue path", async () => {
|
| 695 |
+
const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({
|
| 696 |
+
shouldFollowup: true,
|
| 697 |
+
isActive: true,
|
| 698 |
+
});
|
| 699 |
+
const runState: ReplyOperationRunState = {};
|
| 700 |
+
replyParams.opts = { [REPLY_OPERATION_RUN_STATE]: runState };
|
| 701 |
+
enqueueFollowupRunMock.mockReturnValueOnce(true);
|
| 702 |
+
|
| 703 |
+
await expect(runReplyAgent(replyParams)).resolves.toBeUndefined();
|
| 704 |
+
|
| 705 |
+
expect(runState.admission).toEqual({ status: "accepted", mode: "followup" });
|
| 706 |
+
expect(resolveQueuedReplyExecutionConfigMock).not.toHaveBeenCalled();
|
| 707 |
+
expect(enqueueFollowupRunMock).toHaveBeenCalledTimes(1);
|
| 708 |
+
const enqueueCall = enqueueFollowupRunMock.mock.calls.at(0);
|
| 709 |
+
expect(enqueueCall?.[0]).toBe("main");
|
| 710 |
+
expect(enqueueCall?.[1]).toBe(followupRun);
|
| 711 |
+
expect(enqueueCall?.[2]).toBe(resolvedQueue);
|
| 712 |
+
expect(enqueueCall?.[3]).toBe("message-id");
|
| 713 |
+
expect(typeof enqueueCall?.[4]).toBe("function");
|
| 714 |
+
expect(enqueueCall?.[5]).toBe(false);
|
| 715 |
+
});
|
| 716 |
+
});
|
src/auto-reply/reply/agent-runner-embedded-candidate.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
| 2 |
+
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
|
| 3 |
+
import type {
|
| 4 |
+
CompactionAccountingFact,
|
| 5 |
+
RunEmbeddedAgentInternalParams,
|
| 6 |
+
} from "../../agents/embedded-agent-runner/run/internal-params.js";
|
| 7 |
+
import { runEmbeddedAgent } from "../../agents/embedded-agent.js";
|
| 8 |
+
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
|
| 9 |
+
import { resolveOpenAIRuntimeProvider } from "../../agents/openai-routing.js";
|
| 10 |
+
import type { CompactionRequestBudget } from "../../agents/sessions/compaction/request-budget.js";
|
| 11 |
+
import { resolveGroupSessionKey } from "../../config/sessions.js";
|
| 12 |
+
import { logVerbose } from "../../globals.js";
|
| 13 |
+
import { resolveSessionPinnedHarnessId } from "../../sessions/agent-harness-session-key.js";
|
| 14 |
+
import {
|
| 15 |
+
isMarkdownCapableMessageChannel,
|
| 16 |
+
resolveMessageChannel,
|
| 17 |
+
} from "../../utils/message-channel.js";
|
| 18 |
+
import type { PartialReplyPayload } from "../get-reply-options.types.js";
|
| 19 |
+
import type { ReplyPayload } from "../types.js";
|
| 20 |
+
import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
|
| 21 |
+
import {
|
| 22 |
+
createAgentRunEventHandler,
|
| 23 |
+
type MessageToolDeliveryState,
|
| 24 |
+
} from "./agent-runner-event-handler.js";
|
| 25 |
+
import type { CompletedAgentAuthSelection } from "./agent-runner-execution.types.js";
|
| 26 |
+
import type { AgentFallbackCandidateCommonParams } from "./agent-runner-fallback-cycle.types.js";
|
| 27 |
+
import { buildEmbeddedRunExecutionParams } from "./agent-runner-utils.js";
|
| 28 |
+
import { resolveReplyOperationTerminationFields } from "./reply-operation-abort.js";
|
| 29 |
+
import { markReplyOperationGlobalLaneWaitProgress } from "./reply-run-registry.js";
|
| 30 |
+
import {
|
| 31 |
+
bindSourceReplyDeliveryRuntime,
|
| 32 |
+
readSourceReplyDeliveryRuntime,
|
| 33 |
+
} from "./source-reply-delivery-runtime.js";
|
| 34 |
+
|
| 35 |
+
export async function runEmbeddedFallbackCandidate(
|
| 36 |
+
params: AgentFallbackCandidateCommonParams & {
|
| 37 |
+
effectiveRun: AgentFallbackCandidateCommonParams["candidateRun"];
|
| 38 |
+
sessionRuntimeOverride?: string;
|
| 39 |
+
getLifecycleGeneration: () => string;
|
| 40 |
+
onLifecycleGeneration: (generation: string) => void;
|
| 41 |
+
allowTransientCooldownProbe?: boolean;
|
| 42 |
+
notifyUserAboutCompaction: boolean;
|
| 43 |
+
messageToolDeliveryState: MessageToolDeliveryState;
|
| 44 |
+
githubPublicationAvailable: boolean;
|
| 45 |
+
onCompactionFacts: (facts: {
|
| 46 |
+
accounting?: CompactionAccountingFact;
|
| 47 |
+
postCompactionModelAttempted: boolean;
|
| 48 |
+
}) => void;
|
| 49 |
+
},
|
| 50 |
+
): Promise<{
|
| 51 |
+
result: Awaited<ReturnType<typeof runEmbeddedAgent>>;
|
| 52 |
+
maintenanceAuthProfile?: CompletedAgentAuthSelection;
|
| 53 |
+
compactionRequestBudget?: CompactionRequestBudget;
|
| 54 |
+
bootstrapPromptWarningSignaturesSeen: string[];
|
| 55 |
+
}> {
|
| 56 |
+
const turn = params.turn;
|
| 57 |
+
let maintenanceAuthProfile: CompletedAgentAuthSelection | undefined;
|
| 58 |
+
let compactionRequestBudget: CompactionRequestBudget | undefined;
|
| 59 |
+
const sourceReplyDeliveryRuntime = readSourceReplyDeliveryRuntime(params.candidateRun);
|
| 60 |
+
const candidateRun = {
|
| 61 |
+
...params.candidateRun,
|
| 62 |
+
...params.candidateFastMode,
|
| 63 |
+
thinkLevel: params.candidateThinkLevel,
|
| 64 |
+
};
|
| 65 |
+
const { embeddedContext, senderContext, runBaseParams } = await buildEmbeddedRunExecutionParams({
|
| 66 |
+
run: candidateRun,
|
| 67 |
+
replyRoute: turn.followupRun,
|
| 68 |
+
sessionCtx: turn.sessionCtx,
|
| 69 |
+
hasRepliedRef: turn.opts?.hasRepliedRef,
|
| 70 |
+
provider: params.provider,
|
| 71 |
+
runId: params.runId,
|
| 72 |
+
promptCacheKey: turn.opts?.promptCacheKey,
|
| 73 |
+
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
|
| 74 |
+
model: params.model,
|
| 75 |
+
});
|
| 76 |
+
if (sourceReplyDeliveryRuntime) {
|
| 77 |
+
bindSourceReplyDeliveryRuntime(runBaseParams, sourceReplyDeliveryRuntime);
|
| 78 |
+
}
|
| 79 |
+
const agentHarnessPolicy = params.sessionRuntimeOverride
|
| 80 |
+
? ({ runtime: params.sessionRuntimeOverride, runtimeSource: "model" } as const)
|
| 81 |
+
: resolveAgentHarnessPolicy({
|
| 82 |
+
provider: params.provider,
|
| 83 |
+
modelId: params.model,
|
| 84 |
+
config: params.runtimeConfig,
|
| 85 |
+
agentId: turn.followupRun.run.agentId,
|
| 86 |
+
sessionKey: turn.followupRun.run.runtimePolicySessionKey ?? turn.sessionKey,
|
| 87 |
+
});
|
| 88 |
+
const embeddedRunProvider = resolveOpenAIRuntimeProvider({
|
| 89 |
+
provider: params.provider,
|
| 90 |
+
harnessRuntime: agentHarnessPolicy.runtime,
|
| 91 |
+
authProfileProvider: runBaseParams.authProfileId?.split(":", 1)[0],
|
| 92 |
+
authProfileId: runBaseParams.authProfileId,
|
| 93 |
+
config: params.runtimeConfig,
|
| 94 |
+
workspaceDir: turn.followupRun.run.workspaceDir,
|
| 95 |
+
});
|
| 96 |
+
const embeddedRunHarnessOverride =
|
| 97 |
+
params.sessionRuntimeOverride ??
|
| 98 |
+
(agentHarnessPolicy.runtime === "openclaw" && embeddedRunProvider !== params.provider
|
| 99 |
+
? "openclaw"
|
| 100 |
+
: undefined);
|
| 101 |
+
let attemptCompactionCount = 0;
|
| 102 |
+
let postCompactionModelAttempted = false;
|
| 103 |
+
let compactionAccounting: CompactionAccountingFact | undefined;
|
| 104 |
+
const lifecycleBackstop = createAgentLifecycleTerminalBackstop({
|
| 105 |
+
runId: params.runId,
|
| 106 |
+
sessionKey: turn.sessionKey,
|
| 107 |
+
getLifecycleGeneration: params.getLifecycleGeneration,
|
| 108 |
+
resolveTerminationFields: (error) =>
|
| 109 |
+
resolveReplyOperationTerminationFields(error, params.runAbortSignal, turn.replyOperation),
|
| 110 |
+
});
|
| 111 |
+
params.onLifecycleBackstop(lifecycleBackstop);
|
| 112 |
+
try {
|
| 113 |
+
// Profiler milestone. Exposes pre-dispatch delay without normal-path logging.
|
| 114 |
+
params.timing.logMilestoneIfSlow({
|
| 115 |
+
runId: params.runId,
|
| 116 |
+
sessionId: turn.followupRun.run.sessionId,
|
| 117 |
+
sessionKey: turn.sessionKey,
|
| 118 |
+
milestone: "before_embedded_run",
|
| 119 |
+
});
|
| 120 |
+
let eventHandler: ReturnType<typeof createAgentRunEventHandler> | undefined;
|
| 121 |
+
const result = await params.timing.measure("embedded_run", () => {
|
| 122 |
+
const embeddedRunParams: RunEmbeddedAgentInternalParams = {
|
| 123 |
+
preparedRunAdmission: params.preparedRunAdmission,
|
| 124 |
+
githubPublicationAvailable: params.githubPublicationAvailable,
|
| 125 |
+
...embeddedContext,
|
| 126 |
+
messageActionTurnCapability: params.messageActionTurnCapability,
|
| 127 |
+
lifecycleGeneration: params.getLifecycleGeneration(),
|
| 128 |
+
allowGatewaySubagentBinding: true,
|
| 129 |
+
trigger: turn.isHeartbeat ? "heartbeat" : "user",
|
| 130 |
+
cronCreatorAuthorityCapability: turn.opts?.cronCreatorAuthorityCapability,
|
| 131 |
+
cronCreatorAuthorityUnavailableReason:
|
| 132 |
+
turn.opts?.turnAdoptionLifecycle?.cronCreatorAuthorityUnavailable,
|
| 133 |
+
groupId: resolveGroupSessionKey(turn.sessionCtx)?.id,
|
| 134 |
+
groupChannel:
|
| 135 |
+
normalizeOptionalString(turn.sessionCtx.GroupChannel) ??
|
| 136 |
+
normalizeOptionalString(turn.sessionCtx.GroupSubject),
|
| 137 |
+
groupSpace: normalizeOptionalString(turn.sessionCtx.GroupSpace),
|
| 138 |
+
...senderContext,
|
| 139 |
+
...runBaseParams,
|
| 140 |
+
contextWindow: turn.getActiveSessionEntry()?.contextWindow,
|
| 141 |
+
lane: params.runLane,
|
| 142 |
+
provider: embeddedRunProvider,
|
| 143 |
+
agentHarnessId: resolveSessionPinnedHarnessId(turn.getActiveSessionEntry()),
|
| 144 |
+
agentHarnessRuntimeOverride: embeddedRunHarnessOverride,
|
| 145 |
+
agentHarnessRuntimePreparationHint:
|
| 146 |
+
agentHarnessPolicy.runtimeSource !== "implicit" ? agentHarnessPolicy.runtime : undefined,
|
| 147 |
+
fastModeStartedAtMs: params.fastModeStartedAtMs,
|
| 148 |
+
fastModeAutoProgressState: params.fastModeAutoProgressState,
|
| 149 |
+
isFinalFallbackAttempt: params.isFinalFallbackAttempt,
|
| 150 |
+
sandboxSessionKey: turn.runtimePolicySessionKey,
|
| 151 |
+
prompt: turn.commandBody,
|
| 152 |
+
transcriptPrompt: turn.transcriptCommandBody,
|
| 153 |
+
media: turn.followupRun.media,
|
| 154 |
+
userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
|
| 155 |
+
contextEngineLogicalTurnLease: params.contextEngineLogicalTurnLease,
|
| 156 |
+
onContextEngineTurnCandidate: params.onContextEngineTurnCandidate,
|
| 157 |
+
currentInboundEventKind: turn.followupRun.currentInboundEventKind,
|
| 158 |
+
currentInboundContext: turn.followupRun.currentInboundContext,
|
| 159 |
+
explicitSkillSelections: turn.followupRun.explicitSkillSelections,
|
| 160 |
+
extraSystemPrompt: turn.followupRun.run.extraSystemPrompt,
|
| 161 |
+
sourceReplyDeliveryMode: turn.followupRun.run.sourceReplyDeliveryMode,
|
| 162 |
+
forceMessageTool: turn.followupRun.run.sourceReplyDeliveryMode === "message_tool_only",
|
| 163 |
+
// Heartbeat ambient routes are delivery context, never implicit message recipients.
|
| 164 |
+
// Omit false so subagent sessions keep their downstream default.
|
| 165 |
+
...(turn.isHeartbeat ? { requireExplicitMessageTarget: true } : {}),
|
| 166 |
+
cleanupBundleMcpOnRunEnd: turn.opts?.cleanupBundleMcpOnRunEnd,
|
| 167 |
+
silentReplyPromptMode: turn.followupRun.run.silentReplyPromptMode,
|
| 168 |
+
suppressNextUserMessagePersistence: params.suppressQueuedUserPersistenceForCandidate,
|
| 169 |
+
onUserMessagePersisted: params.notifyUserMessagePersisted,
|
| 170 |
+
suppressTranscriptOnlyAssistantPersistence:
|
| 171 |
+
turn.followupRun.run.suppressTranscriptOnlyAssistantPersistence,
|
| 172 |
+
assistantErrorTranscript: params.assistantErrorTranscript,
|
| 173 |
+
authProfileFailurePolicy: params.authProfileFailurePolicy,
|
| 174 |
+
prepareAssistantTranscriptMessage: turn.opts?.prepareAssistantTranscriptMessage,
|
| 175 |
+
onAutoCompactionSucceeded: (count) => {
|
| 176 |
+
attemptCompactionCount = Math.max(attemptCompactionCount, count);
|
| 177 |
+
},
|
| 178 |
+
toolResultFormat: (() => {
|
| 179 |
+
const channel = resolveMessageChannel(turn.sessionCtx.Surface, turn.sessionCtx.Provider);
|
| 180 |
+
return !channel || isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
|
| 181 |
+
})(),
|
| 182 |
+
toolProgressDetail: turn.toolProgressDetail,
|
| 183 |
+
toolsAllow: turn.opts?.toolsAllow,
|
| 184 |
+
disableTools: turn.opts?.disableTools,
|
| 185 |
+
// Marks reply-owned policy; final attempt preparation binds its concrete route.
|
| 186 |
+
toolAuthorityFingerprint: turn.replyOperation?.toolAuthorityFingerprint,
|
| 187 |
+
enableHeartbeatTool: turn.opts?.enableHeartbeatTool,
|
| 188 |
+
forceHeartbeatTool: turn.opts?.forceHeartbeatTool,
|
| 189 |
+
bootstrapContextMode: turn.opts?.bootstrapContextMode,
|
| 190 |
+
bootstrapContextRunKind: params.bootstrapContextRunKind,
|
| 191 |
+
images: params.currentTurnImages.images,
|
| 192 |
+
imageOrder: params.currentTurnImages.imageOrder,
|
| 193 |
+
abortSignal: params.runAbortSignal,
|
| 194 |
+
replyOperation: turn.replyOperation,
|
| 195 |
+
deferTerminalLifecycle: true,
|
| 196 |
+
onAttemptStart: lifecycleBackstop.beginAttempt,
|
| 197 |
+
onCompactionAccounting: (fact) => {
|
| 198 |
+
compactionAccounting = fact;
|
| 199 |
+
},
|
| 200 |
+
onCompactionRequestBudget: (budget) => {
|
| 201 |
+
compactionRequestBudget = budget;
|
| 202 |
+
},
|
| 203 |
+
onDeferredLifecycleOwner: params.deferredLifecycle.adopt,
|
| 204 |
+
onDeferredLifecycleAbort: params.deferredLifecycle.abort,
|
| 205 |
+
onRetryWait: params.deferredLifecycle.beginRetryWait,
|
| 206 |
+
onExecutionStarted: (info) => {
|
| 207 |
+
if (info?.lifecycleGeneration) {
|
| 208 |
+
params.onLifecycleGeneration(info.lifecycleGeneration);
|
| 209 |
+
}
|
| 210 |
+
},
|
| 211 |
+
onExecutionPhase: (info) => {
|
| 212 |
+
if (info.phase === "model_call_started" && attemptCompactionCount > 0) {
|
| 213 |
+
postCompactionModelAttempted = true;
|
| 214 |
+
}
|
| 215 |
+
params.signalExecutionPhaseForTyping(info);
|
| 216 |
+
},
|
| 217 |
+
onLaneWait: ({ waiting }) => {
|
| 218 |
+
const replyOperation = turn.replyOperation;
|
| 219 |
+
if (waiting && replyOperation) {
|
| 220 |
+
markReplyOperationGlobalLaneWaitProgress(replyOperation);
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
blockReplyBreak: turn.resolvedBlockStreamingBreak,
|
| 224 |
+
blockReplyChunking: turn.blockReplyChunking,
|
| 225 |
+
// Subscriber callbacks are detached. Stage channel presentation before typing I/O.
|
| 226 |
+
onPartialReply: async (payload) => {
|
| 227 |
+
const classified = params.presentation.classifyStreamingPartial(payload);
|
| 228 |
+
if (classified.skip || !classified.text) {
|
| 229 |
+
return false;
|
| 230 |
+
}
|
| 231 |
+
const textForTyping = classified.text;
|
| 232 |
+
let didMaterialize = false;
|
| 233 |
+
let materializedText: string | undefined;
|
| 234 |
+
const partialPayload: PartialReplyPayload = {
|
| 235 |
+
get text() {
|
| 236 |
+
if (!didMaterialize) {
|
| 237 |
+
const sanitized = params.presentation.sanitizeStreamingText(textForTyping, false);
|
| 238 |
+
materializedText = sanitized.skip ? undefined : sanitized.text;
|
| 239 |
+
didMaterialize = true;
|
| 240 |
+
}
|
| 241 |
+
return materializedText;
|
| 242 |
+
},
|
| 243 |
+
mediaUrls: payload.mediaUrls,
|
| 244 |
+
};
|
| 245 |
+
const onPartialReply = turn.opts?.onPartialReply;
|
| 246 |
+
return await params.presentation.presentWithTyping(
|
| 247 |
+
turn.typingSignals.signalTextDelta(textForTyping),
|
| 248 |
+
() => (onPartialReply ? onPartialReply(partialPayload) : false),
|
| 249 |
+
);
|
| 250 |
+
},
|
| 251 |
+
onAssistantMessageStart: async () => {
|
| 252 |
+
await params.presentation.presentWithTyping(turn.typingSignals.signalMessageStart(), () =>
|
| 253 |
+
turn.opts?.onAssistantMessageStart?.(),
|
| 254 |
+
);
|
| 255 |
+
},
|
| 256 |
+
onReasoningStream:
|
| 257 |
+
turn.typingSignals.shouldStartOnReasoning || turn.opts?.onReasoningStream
|
| 258 |
+
? async (payload) => {
|
| 259 |
+
if (turn.followupRun.run.silentExpected) {
|
| 260 |
+
return;
|
| 261 |
+
}
|
| 262 |
+
await params.presentation.presentWithTyping(
|
| 263 |
+
turn.typingSignals.signalReasoningDelta(),
|
| 264 |
+
() =>
|
| 265 |
+
turn.opts?.onReasoningStream?.({
|
| 266 |
+
text: payload.text,
|
| 267 |
+
mediaUrls: payload.mediaUrls,
|
| 268 |
+
isReasoningSnapshot: payload.isReasoningSnapshot,
|
| 269 |
+
requiresReasoningProgressOptIn: payload.requiresReasoningProgressOptIn,
|
| 270 |
+
}),
|
| 271 |
+
);
|
| 272 |
+
}
|
| 273 |
+
: undefined,
|
| 274 |
+
streamReasoningInNonStreamModes: turn.opts?.streamReasoningInNonStreamModes,
|
| 275 |
+
onReasoningEnd: turn.opts?.onReasoningEnd
|
| 276 |
+
? async () => {
|
| 277 |
+
await turn.opts?.onReasoningEnd?.();
|
| 278 |
+
}
|
| 279 |
+
: undefined,
|
| 280 |
+
onAgentEvent: (event) => {
|
| 281 |
+
eventHandler ??= createAgentRunEventHandler({
|
| 282 |
+
turn,
|
| 283 |
+
lifecycleBackstop,
|
| 284 |
+
notifyAgentRunStart: params.notifyAgentRunStart,
|
| 285 |
+
sourceRepliesAreToolOnly:
|
| 286 |
+
(sourceReplyDeliveryRuntime?.currentMode ??
|
| 287 |
+
turn.followupRun.run.sourceReplyDeliveryMode) === "message_tool_only",
|
| 288 |
+
messageToolDeliveryState: params.messageToolDeliveryState,
|
| 289 |
+
provider: params.provider,
|
| 290 |
+
model: params.model,
|
| 291 |
+
runId: params.runId,
|
| 292 |
+
effectiveSessionId: params.effectiveRun.sessionId,
|
| 293 |
+
notifyUserAboutCompaction: params.notifyUserAboutCompaction,
|
| 294 |
+
onCompactionCompleted: () => {
|
| 295 |
+
attemptCompactionCount += 1;
|
| 296 |
+
return attemptCompactionCount;
|
| 297 |
+
},
|
| 298 |
+
});
|
| 299 |
+
return eventHandler(event);
|
| 300 |
+
},
|
| 301 |
+
// Flush-before-tool requires a handler even when regular block streaming is off.
|
| 302 |
+
onBlockReply: params.presentation.blockReplyHandler,
|
| 303 |
+
onBlockReplyFlush:
|
| 304 |
+
turn.blockStreamingEnabled && turn.blockReplyPipeline
|
| 305 |
+
? async () => {
|
| 306 |
+
await turn.blockReplyPipeline?.flush({ force: true });
|
| 307 |
+
}
|
| 308 |
+
: undefined,
|
| 309 |
+
shouldEmitToolResult: turn.shouldEmitToolResult,
|
| 310 |
+
shouldEmitToolOutput: turn.shouldEmitToolOutput,
|
| 311 |
+
bootstrapPromptWarningSignaturesSeen: params.bootstrapPromptWarningSignaturesSeen,
|
| 312 |
+
bootstrapPromptWarningSignature:
|
| 313 |
+
params.bootstrapPromptWarningSignaturesSeen[
|
| 314 |
+
params.bootstrapPromptWarningSignaturesSeen.length - 1
|
| 315 |
+
],
|
| 316 |
+
onToolResult: turn.opts?.onToolResult
|
| 317 |
+
? (() => {
|
| 318 |
+
// Serialized delivery preserves tool result order across detached callbacks.
|
| 319 |
+
let toolResultChain: Promise<void> = Promise.resolve();
|
| 320 |
+
return (payload: ReplyPayload) => {
|
| 321 |
+
const delivery = toolResultChain.then(async () => {
|
| 322 |
+
turn.replyOperation?.recordActivity();
|
| 323 |
+
const { text, skip } = params.presentation.normalizeStreamingText(payload);
|
| 324 |
+
if (skip) {
|
| 325 |
+
return;
|
| 326 |
+
}
|
| 327 |
+
if (text !== undefined) {
|
| 328 |
+
await turn.typingSignals.signalTextDelta(text);
|
| 329 |
+
}
|
| 330 |
+
await turn.opts?.onToolResult?.({ ...payload, text });
|
| 331 |
+
});
|
| 332 |
+
// Keep later results best-effort while exposing this delivery to awaiting owners.
|
| 333 |
+
toolResultChain = delivery.catch((err: unknown) => {
|
| 334 |
+
logVerbose(`tool result delivery failed: ${String(err)}`);
|
| 335 |
+
});
|
| 336 |
+
const task = toolResultChain.finally(() => {
|
| 337 |
+
turn.pendingToolTasks.delete(task);
|
| 338 |
+
});
|
| 339 |
+
turn.pendingToolTasks.add(task);
|
| 340 |
+
return delivery;
|
| 341 |
+
};
|
| 342 |
+
})()
|
| 343 |
+
: undefined,
|
| 344 |
+
};
|
| 345 |
+
embeddedRunParams.onSuccessfulAuthProfile = (profileId) => {
|
| 346 |
+
maintenanceAuthProfile = {
|
| 347 |
+
authProfileId: profileId,
|
| 348 |
+
authProfileIdSource: profileId
|
| 349 |
+
? profileId === runBaseParams.authProfileId
|
| 350 |
+
? runBaseParams.authProfileIdSource
|
| 351 |
+
: "auto"
|
| 352 |
+
: undefined,
|
| 353 |
+
};
|
| 354 |
+
};
|
| 355 |
+
return runEmbeddedAgent(embeddedRunParams);
|
| 356 |
+
});
|
| 357 |
+
const resultCompactionCount = Math.max(0, result.meta?.agentMeta?.compactionCount ?? 0);
|
| 358 |
+
attemptCompactionCount = Math.max(attemptCompactionCount, resultCompactionCount);
|
| 359 |
+
return {
|
| 360 |
+
result,
|
| 361 |
+
maintenanceAuthProfile,
|
| 362 |
+
compactionRequestBudget,
|
| 363 |
+
bootstrapPromptWarningSignaturesSeen: resolveBootstrapWarningSignaturesSeen(
|
| 364 |
+
result.meta?.systemPromptReport,
|
| 365 |
+
),
|
| 366 |
+
};
|
| 367 |
+
} finally {
|
| 368 |
+
// Runtime event/result counts are observable, but cannot prove a durable write target.
|
| 369 |
+
const accounting: CompactionAccountingFact | undefined =
|
| 370 |
+
compactionAccounting ??
|
| 371 |
+
(attemptCompactionCount > 0
|
| 372 |
+
? {
|
| 373 |
+
kind: "presentation-only",
|
| 374 |
+
count: attemptCompactionCount,
|
| 375 |
+
currentContextSnapshot: { tokens: undefined },
|
| 376 |
+
}
|
| 377 |
+
: undefined);
|
| 378 |
+
params.onCompactionFacts({ accounting, postCompactionModelAttempted });
|
| 379 |
+
}
|
| 380 |
+
}
|
src/auto-reply/reply/agent-runner-error-handler.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
|
| 2 |
+
import {
|
| 3 |
+
classifyOAuthRefreshFailure,
|
| 4 |
+
classifyOAuthRefreshFailureError,
|
| 5 |
+
} from "../../agents/auth-profiles/oauth-refresh-failure.js";
|
| 6 |
+
import {
|
| 7 |
+
isCompactionFailureError,
|
| 8 |
+
isLikelyContextOverflowError,
|
| 9 |
+
} from "../../agents/embedded-agent-helpers.js";
|
| 10 |
+
import { findCliTimeoutError, isFailoverError } from "../../agents/failover-error.js";
|
| 11 |
+
import {
|
| 12 |
+
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
|
| 13 |
+
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
|
| 14 |
+
renderControlUiAgentFailureCopy,
|
| 15 |
+
renderFailoverCodeUserCopy,
|
| 16 |
+
} from "../../agents/failover/user-copy.js";
|
| 17 |
+
import { isAgentHarnessPreflightError } from "../../agents/harness/errors.js";
|
| 18 |
+
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
|
| 19 |
+
import { formatErrorMessage } from "../../infra/errors.js";
|
| 20 |
+
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
|
| 21 |
+
import { defaultRuntime } from "../../runtime.js";
|
| 22 |
+
import type { ReplyPayload } from "../types.js";
|
| 23 |
+
import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
|
| 24 |
+
import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
|
| 25 |
+
import type { AgentTurnInternalResult, AgentTurnParams } from "./agent-runner-execution.types.js";
|
| 26 |
+
import {
|
| 27 |
+
buildAuthProfileFailoverFailureText,
|
| 28 |
+
buildExternalRunFailureReply,
|
| 29 |
+
isNonDirectConversationContext,
|
| 30 |
+
isVerboseFailureDetailEnabled,
|
| 31 |
+
markAgentRunFailureReplyPayload,
|
| 32 |
+
resolveExternalRunFailureTextForConversation,
|
| 33 |
+
resolveReplyFailureSummary,
|
| 34 |
+
resolveReplyFailoverFacts,
|
| 35 |
+
} from "./agent-runner-failure-reply.js";
|
| 36 |
+
import type { AgentFallbackCycleState } from "./agent-runner-fallback-cycle.js";
|
| 37 |
+
import type { AgentTurnTimingTracker } from "./agent-runner-turn-timing.js";
|
| 38 |
+
import {
|
| 39 |
+
buildRestartLifecycleReplyText,
|
| 40 |
+
resolveReplyOperationAbortReason,
|
| 41 |
+
resolveReplyOperationTerminationFields,
|
| 42 |
+
resolveRestartLifecycleError,
|
| 43 |
+
} from "./reply-operation-abort.js";
|
| 44 |
+
|
| 45 |
+
const MAX_LIVE_SWITCH_RETRIES = 2;
|
| 46 |
+
|
| 47 |
+
type ErrorAction =
|
| 48 |
+
| { kind: "retry"; liveModelSwitchError?: LiveSessionModelSwitchError }
|
| 49 |
+
| Extract<AgentTurnInternalResult, { kind: "final" | "aborted" }>;
|
| 50 |
+
|
| 51 |
+
export async function handleAgentExecutionError(params: {
|
| 52 |
+
turn: AgentTurnParams;
|
| 53 |
+
error: unknown;
|
| 54 |
+
runtimeConfig: AgentTurnParams["followupRun"]["run"]["config"];
|
| 55 |
+
runId: string;
|
| 56 |
+
state: AgentFallbackCycleState;
|
| 57 |
+
liveModelSwitchRetries: number;
|
| 58 |
+
shouldSurfaceToControlUi: boolean;
|
| 59 |
+
timing: AgentTurnTimingTracker;
|
| 60 |
+
modelPatch: { fail: (error: unknown) => Promise<void> };
|
| 61 |
+
}): Promise<ErrorAction> {
|
| 62 |
+
const turn = params.turn;
|
| 63 |
+
const err = params.error;
|
| 64 |
+
// A failed candidate leaves its backstop pending; settlement takes it before later work.
|
| 65 |
+
// This keeps session-override failures from being mislabeled as model failures.
|
| 66 |
+
const postCompactionModelFailure =
|
| 67 |
+
params.state.postCompactionModelAttempted && params.state.pendingLifecycleTerminal
|
| 68 |
+
? true
|
| 69 |
+
: undefined;
|
| 70 |
+
const takePendingLifecycleTerminal = () => {
|
| 71 |
+
const terminal =
|
| 72 |
+
params.state.pendingLifecycleTerminal?.backstop ??
|
| 73 |
+
createAgentLifecycleTerminalBackstop({
|
| 74 |
+
runId: params.runId,
|
| 75 |
+
sessionKey: turn.sessionKey,
|
| 76 |
+
startedAt: params.state.turnStartedAtMs,
|
| 77 |
+
getLifecycleGeneration: () => params.state.lifecycleGeneration,
|
| 78 |
+
resolveTerminationFields: (error) =>
|
| 79 |
+
resolveReplyOperationTerminationFields(
|
| 80 |
+
error,
|
| 81 |
+
turn.replyOperation?.abortSignal ?? turn.opts?.abortSignal,
|
| 82 |
+
turn.replyOperation,
|
| 83 |
+
),
|
| 84 |
+
});
|
| 85 |
+
params.state.pendingLifecycleTerminal = undefined;
|
| 86 |
+
return terminal;
|
| 87 |
+
};
|
| 88 |
+
const settleFailure = async (
|
| 89 |
+
payload: ReplyPayload,
|
| 90 |
+
): Promise<Extract<AgentTurnInternalResult, { kind: "final" }>> => {
|
| 91 |
+
takePendingLifecycleTerminal().emit("error", err);
|
| 92 |
+
turn.replyOperation?.fail("run_failed", err);
|
| 93 |
+
await params.modelPatch.fail(err);
|
| 94 |
+
return {
|
| 95 |
+
kind: "final",
|
| 96 |
+
payload: markAgentRunFailureReplyPayload(payload),
|
| 97 |
+
postCompactionModelFailure,
|
| 98 |
+
};
|
| 99 |
+
};
|
| 100 |
+
const resolveReplyOperationAbortAction = (abortError: unknown): ErrorAction | undefined => {
|
| 101 |
+
const reason = resolveReplyOperationAbortReason(turn.replyOperation, abortError);
|
| 102 |
+
if (!reason) {
|
| 103 |
+
return undefined;
|
| 104 |
+
}
|
| 105 |
+
// Preserve signal-owned timeout attribution; only normalized restart/supersession need metadata.
|
| 106 |
+
const terminalMetadata = reason === "user" ? undefined : { aborted: true, stopReason: reason };
|
| 107 |
+
takePendingLifecycleTerminal().emit(
|
| 108 |
+
reason === "restart" ? "end" : "error",
|
| 109 |
+
abortError,
|
| 110 |
+
terminalMetadata,
|
| 111 |
+
);
|
| 112 |
+
return { kind: "aborted", reason };
|
| 113 |
+
};
|
| 114 |
+
const replyOperationAbortAction = resolveReplyOperationAbortAction(err);
|
| 115 |
+
if (replyOperationAbortAction) {
|
| 116 |
+
return replyOperationAbortAction;
|
| 117 |
+
}
|
| 118 |
+
if (err instanceof LiveSessionModelSwitchError) {
|
| 119 |
+
if (params.liveModelSwitchRetries <= MAX_LIVE_SWITCH_RETRIES) {
|
| 120 |
+
params.state.pendingLifecycleTerminal = undefined;
|
| 121 |
+
return { kind: "retry", liveModelSwitchError: err };
|
| 122 |
+
}
|
| 123 |
+
const visibleReplyDelivered = await turn.resolveVisibleReplyDelivery?.();
|
| 124 |
+
defaultRuntime.error(
|
| 125 |
+
`Live model switch failed after ${MAX_LIVE_SWITCH_RETRIES} retries ` +
|
| 126 |
+
`(${sanitizeForLog(err.provider)}/${sanitizeForLog(err.model)}). The requested model may be unavailable.`,
|
| 127 |
+
);
|
| 128 |
+
takePendingLifecycleTerminal().emit("error", err);
|
| 129 |
+
const switchErrorText = params.shouldSurfaceToControlUi
|
| 130 |
+
? renderControlUiAgentFailureCopy(
|
| 131 |
+
"model switch could not be completed. The requested model may be temporarily unavailable.",
|
| 132 |
+
)
|
| 133 |
+
: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel)
|
| 134 |
+
? "⚠️ Agent failed before reply: model switch could not be completed. " +
|
| 135 |
+
"The requested model may be temporarily unavailable. Please try again shortly."
|
| 136 |
+
: "⚠️ Model switch could not be completed. The requested model may be temporarily unavailable. Please try again shortly.";
|
| 137 |
+
turn.replyOperation?.fail("run_failed", err);
|
| 138 |
+
await params.modelPatch.fail(err);
|
| 139 |
+
return {
|
| 140 |
+
kind: "final",
|
| 141 |
+
payload: markAgentRunFailureReplyPayload({
|
| 142 |
+
text: resolveExternalRunFailureTextForConversation({
|
| 143 |
+
text: switchErrorText,
|
| 144 |
+
visibleReplyDelivered,
|
| 145 |
+
sessionCtx: turn.sessionCtx,
|
| 146 |
+
isGenericRunnerFailure: !params.shouldSurfaceToControlUi,
|
| 147 |
+
cfg: turn.followupRun.run.config,
|
| 148 |
+
}),
|
| 149 |
+
}),
|
| 150 |
+
};
|
| 151 |
+
}
|
| 152 |
+
const message = formatErrorMessage(err);
|
| 153 |
+
params.timing.logIfSlow({
|
| 154 |
+
runId: params.runId,
|
| 155 |
+
sessionId: turn.followupRun.run.sessionId,
|
| 156 |
+
sessionKey: turn.sessionKey,
|
| 157 |
+
outcome: "error",
|
| 158 |
+
error: message,
|
| 159 |
+
});
|
| 160 |
+
// The exhausted preflight is deliberate, even if its diagnostic cause looks
|
| 161 |
+
// like HTTP/overload. Settle delivery and normal diagnostic policy without replay.
|
| 162 |
+
if (isAgentHarnessPreflightError(err)) {
|
| 163 |
+
const externalReply = buildExternalRunFailureReply(
|
| 164 |
+
{ message, error: err },
|
| 165 |
+
{
|
| 166 |
+
includeDetails: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel),
|
| 167 |
+
isHeartbeat: turn.isHeartbeat,
|
| 168 |
+
},
|
| 169 |
+
);
|
| 170 |
+
const text = resolveExternalRunFailureTextForConversation({
|
| 171 |
+
text: params.shouldSurfaceToControlUi
|
| 172 |
+
? renderControlUiAgentFailureCopy(message)
|
| 173 |
+
: externalReply.text,
|
| 174 |
+
visibleReplyDelivered: await turn.resolveVisibleReplyDelivery?.(),
|
| 175 |
+
sessionCtx: turn.sessionCtx,
|
| 176 |
+
isGenericRunnerFailure: externalReply.isGenericRunnerFailure,
|
| 177 |
+
cfg: turn.followupRun.run.config,
|
| 178 |
+
});
|
| 179 |
+
return await settleFailure({ text });
|
| 180 |
+
}
|
| 181 |
+
const failoverFacts = resolveReplyFailoverFacts(err, message);
|
| 182 |
+
const failureSummary = resolveReplyFailureSummary({
|
| 183 |
+
error: err,
|
| 184 |
+
message,
|
| 185 |
+
reason: failoverFacts.reason,
|
| 186 |
+
attempts: isFailoverError(err) ? err.attempts : undefined,
|
| 187 |
+
});
|
| 188 |
+
const failoverReason = failoverFacts.reason;
|
| 189 |
+
const isBilling = failureSummary?.kind === "billing";
|
| 190 |
+
const isContextOverflow =
|
| 191 |
+
!isBilling && (failoverReason === "context_overflow" || isLikelyContextOverflowError(message));
|
| 192 |
+
const isCompactionFailure = !isBilling && isCompactionFailureError(message);
|
| 193 |
+
const oauthRefreshFailure =
|
| 194 |
+
classifyOAuthRefreshFailureError(err) ?? classifyOAuthRefreshFailure(message);
|
| 195 |
+
const hasAuthProfileFailoverFailure = buildAuthProfileFailoverFailureText(err) !== null;
|
| 196 |
+
const providerRequestError =
|
| 197 |
+
!isBilling &&
|
| 198 |
+
!oauthRefreshFailure &&
|
| 199 |
+
!hasAuthProfileFailoverFailure &&
|
| 200 |
+
!params.shouldSurfaceToControlUi
|
| 201 |
+
? failoverFacts.providerRequestError
|
| 202 |
+
: undefined;
|
| 203 |
+
const restartLifecycleError = resolveRestartLifecycleError(err);
|
| 204 |
+
if (
|
| 205 |
+
restartLifecycleError instanceof GatewayDrainingError ||
|
| 206 |
+
restartLifecycleError instanceof CommandLaneClearedError
|
| 207 |
+
) {
|
| 208 |
+
takePendingLifecycleTerminal().emit("error", restartLifecycleError);
|
| 209 |
+
turn.replyOperation?.fail(
|
| 210 |
+
restartLifecycleError instanceof GatewayDrainingError
|
| 211 |
+
? "gateway_draining"
|
| 212 |
+
: "command_lane_cleared",
|
| 213 |
+
restartLifecycleError,
|
| 214 |
+
);
|
| 215 |
+
return {
|
| 216 |
+
kind: "final",
|
| 217 |
+
payload: markAgentRunFailureReplyPayload({ text: buildRestartLifecycleReplyText() }),
|
| 218 |
+
};
|
| 219 |
+
}
|
| 220 |
+
if (isCompactionFailure) {
|
| 221 |
+
takePendingLifecycleTerminal().emit("error", err);
|
| 222 |
+
defaultRuntime.error(
|
| 223 |
+
`Auto-compaction failed (${message}). Preserving existing session mapping for ${turn.sessionKey ?? turn.followupRun.run.sessionId}.`,
|
| 224 |
+
);
|
| 225 |
+
turn.replyOperation?.fail("run_failed", err);
|
| 226 |
+
return {
|
| 227 |
+
kind: "final",
|
| 228 |
+
payload: markAgentRunFailureReplyPayload({
|
| 229 |
+
text: buildContextOverflowRecoveryText({
|
| 230 |
+
duringCompaction: true,
|
| 231 |
+
preserveSessionMapping: true,
|
| 232 |
+
cfg: params.runtimeConfig,
|
| 233 |
+
agentId: turn.followupRun.run.agentId,
|
| 234 |
+
primaryProvider: turn.followupRun.run.provider,
|
| 235 |
+
primaryModel: turn.followupRun.run.model,
|
| 236 |
+
runtimeProvider: params.state.attemptedRuntimeProvider,
|
| 237 |
+
runtimeModel: params.state.attemptedRuntimeModel,
|
| 238 |
+
activeSessionEntry: turn.getActiveSessionEntry(),
|
| 239 |
+
}),
|
| 240 |
+
}),
|
| 241 |
+
};
|
| 242 |
+
}
|
| 243 |
+
const replayPrevented = findCliTimeoutError(err)?.cliTimeout.observedActivity === true;
|
| 244 |
+
if (providerRequestError) {
|
| 245 |
+
return await settleFailure({
|
| 246 |
+
// Curated facet copy beats the generic classified summary; see
|
| 247 |
+
// buildExternalRunFailureReply for the same priority.
|
| 248 |
+
text: providerRequestError.userMessage,
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
|
| 252 |
+
const externalRunFailureCandidate =
|
| 253 |
+
!failureSummary && !isContextOverflow
|
| 254 |
+
? buildExternalRunFailureReply(
|
| 255 |
+
{ message, error: err },
|
| 256 |
+
{
|
| 257 |
+
includeAuthProfileId: !isNonDirectConversationContext(turn.sessionCtx),
|
| 258 |
+
includeDetails: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel),
|
| 259 |
+
isHeartbeat: turn.isHeartbeat,
|
| 260 |
+
replayPrevented,
|
| 261 |
+
failoverFacts,
|
| 262 |
+
},
|
| 263 |
+
)
|
| 264 |
+
: undefined;
|
| 265 |
+
const externalRunFailureReply =
|
| 266 |
+
!params.shouldSurfaceToControlUi ||
|
| 267 |
+
externalRunFailureCandidate?.presentation ||
|
| 268 |
+
renderFailoverCodeUserCopy(failoverFacts.code)
|
| 269 |
+
? externalRunFailureCandidate
|
| 270 |
+
: undefined;
|
| 271 |
+
const fallbackText =
|
| 272 |
+
failureSummary?.text ??
|
| 273 |
+
(isContextOverflow
|
| 274 |
+
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
|
| 275 |
+
: (externalRunFailureReply?.text ??
|
| 276 |
+
(params.shouldSurfaceToControlUi
|
| 277 |
+
? renderControlUiAgentFailureCopy(message)
|
| 278 |
+
: turn.isHeartbeat
|
| 279 |
+
? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
|
| 280 |
+
: GENERIC_EXTERNAL_RUN_FAILURE_TEXT)));
|
| 281 |
+
const userVisibleFallbackText = resolveExternalRunFailureTextForConversation({
|
| 282 |
+
text: fallbackText,
|
| 283 |
+
visibleReplyDelivered: await turn.resolveVisibleReplyDelivery?.(),
|
| 284 |
+
sessionCtx: turn.sessionCtx,
|
| 285 |
+
isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false,
|
| 286 |
+
cfg: turn.followupRun.run.config,
|
| 287 |
+
});
|
| 288 |
+
return await settleFailure({
|
| 289 |
+
text: userVisibleFallbackText,
|
| 290 |
+
...(externalRunFailureReply?.presentation
|
| 291 |
+
? { presentation: externalRunFailureReply.presentation }
|
| 292 |
+
: {}),
|
| 293 |
+
});
|
| 294 |
+
}
|
src/auto-reply/reply/agent-runner-event-handler.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
| 2 |
+
import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js";
|
| 3 |
+
import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js";
|
| 4 |
+
import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
|
| 5 |
+
import { logVerbose } from "../../globals.js";
|
| 6 |
+
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
| 7 |
+
import type { ReplyPayload } from "../types.js";
|
| 8 |
+
import type { AgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
|
| 9 |
+
import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js";
|
| 10 |
+
import type { AgentTurnParams } from "./agent-runner-execution.types.js";
|
| 11 |
+
import {
|
| 12 |
+
createCompactionHookNoticePayload,
|
| 13 |
+
createCompactionNoticePayload,
|
| 14 |
+
formatCompactionModelRef,
|
| 15 |
+
readCompactionHookMessages,
|
| 16 |
+
} from "./compaction-notice.js";
|
| 17 |
+
|
| 18 |
+
const agentCompactionLog = createSubsystemLogger("auto-reply/compaction");
|
| 19 |
+
const CODEX_APP_SERVER_COMPACTION_BACKEND = "codex-app-server";
|
| 20 |
+
|
| 21 |
+
export type MessageToolDeliveryState = {
|
| 22 |
+
toolCallIds: Set<string>;
|
| 23 |
+
completed: boolean;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
function readApprovalScopeValue(value: unknown): "turn" | "session" | undefined {
|
| 27 |
+
return value === "turn" || value === "session" ? value : undefined;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Bridges embedded-agent events into channel progress and compaction notices. */
|
| 31 |
+
export function createAgentRunEventHandler(params: {
|
| 32 |
+
turn: AgentTurnParams;
|
| 33 |
+
lifecycleBackstop: AgentLifecycleTerminalBackstop;
|
| 34 |
+
notifyAgentRunStart: () => void;
|
| 35 |
+
sourceRepliesAreToolOnly: boolean;
|
| 36 |
+
provider: string;
|
| 37 |
+
model: string;
|
| 38 |
+
runId: string;
|
| 39 |
+
effectiveSessionId?: string;
|
| 40 |
+
notifyUserAboutCompaction: boolean;
|
| 41 |
+
onCompactionCompleted: () => number;
|
| 42 |
+
messageToolDeliveryState: MessageToolDeliveryState;
|
| 43 |
+
}): NonNullable<RunEmbeddedAgentParams["onAgentEvent"]> {
|
| 44 |
+
const shouldSuppressProgressAfterMessageToolDelivery = () =>
|
| 45 |
+
params.sourceRepliesAreToolOnly &&
|
| 46 |
+
params.messageToolDeliveryState.completed &&
|
| 47 |
+
params.turn.opts?.allowProgressCallbacksWhenSourceDeliverySuppressed !== true;
|
| 48 |
+
|
| 49 |
+
const currentMessageId =
|
| 50 |
+
params.turn.sessionCtx.MessageSidFull ?? params.turn.sessionCtx.MessageSid;
|
| 51 |
+
const deliverCompactionNoticePayload = async (noticePayload: ReplyPayload, label: string) => {
|
| 52 |
+
const deliver = params.turn.opts?.onBlockReply ?? params.turn.onCompactionNoticePayload;
|
| 53 |
+
if (!deliver) {
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
try {
|
| 57 |
+
await deliver(noticePayload);
|
| 58 |
+
} catch (err) {
|
| 59 |
+
logVerbose(`compaction ${label} notice delivery failed (non-fatal): ${String(err)}`);
|
| 60 |
+
}
|
| 61 |
+
};
|
| 62 |
+
const sendCompactionNotice = async (phase: "start" | "end" | "incomplete") => {
|
| 63 |
+
await deliverCompactionNoticePayload(
|
| 64 |
+
createCompactionNoticePayload({
|
| 65 |
+
phase,
|
| 66 |
+
currentMessageId,
|
| 67 |
+
applyReplyToMode: params.turn.applyReplyToMode,
|
| 68 |
+
}),
|
| 69 |
+
phase,
|
| 70 |
+
);
|
| 71 |
+
};
|
| 72 |
+
const sendCompactionHookMessages = async (messages: string[]) => {
|
| 73 |
+
const noticePayload = createCompactionHookNoticePayload({
|
| 74 |
+
messages,
|
| 75 |
+
currentMessageId,
|
| 76 |
+
applyReplyToMode: params.turn.applyReplyToMode,
|
| 77 |
+
});
|
| 78 |
+
if (noticePayload) {
|
| 79 |
+
await deliverCompactionNoticePayload(noticePayload, "hook");
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
return async (evt) => {
|
| 84 |
+
params.turn.replyOperation?.recordActivity();
|
| 85 |
+
params.lifecycleBackstop.note(evt);
|
| 86 |
+
const hasLifecyclePhase = evt.stream === "lifecycle" && typeof evt.data.phase === "string";
|
| 87 |
+
if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
|
| 88 |
+
params.notifyAgentRunStart();
|
| 89 |
+
}
|
| 90 |
+
if (evt.stream === "tool" && evt.data.hideFromChannelProgress !== true) {
|
| 91 |
+
const phase = readStringValue(evt.data.phase) ?? "";
|
| 92 |
+
const name = readStringValue(evt.data.name);
|
| 93 |
+
const toolCallId = readStringValue(evt.data.toolCallId) ?? "";
|
| 94 |
+
const args =
|
| 95 |
+
evt.data.args && typeof evt.data.args === "object"
|
| 96 |
+
? (evt.data.args as Record<string, unknown>)
|
| 97 |
+
: undefined;
|
| 98 |
+
if (
|
| 99 |
+
params.sourceRepliesAreToolOnly &&
|
| 100 |
+
toolCallId &&
|
| 101 |
+
name &&
|
| 102 |
+
(phase === "start" || phase === "update") &&
|
| 103 |
+
args &&
|
| 104 |
+
isMessagingToolSendAction(name, args)
|
| 105 |
+
) {
|
| 106 |
+
params.messageToolDeliveryState.toolCallIds.add(toolCallId);
|
| 107 |
+
}
|
| 108 |
+
if (shouldSuppressProgressAfterMessageToolDelivery()) {
|
| 109 |
+
return;
|
| 110 |
+
}
|
| 111 |
+
if (phase === "start" || phase === "update") {
|
| 112 |
+
const toolStartProgressPromise = params.turn.opts?.onToolStart?.({
|
| 113 |
+
itemId: readStringValue(evt.data.itemId),
|
| 114 |
+
toolCallId: readStringValue(evt.data.toolCallId),
|
| 115 |
+
name,
|
| 116 |
+
phase,
|
| 117 |
+
args,
|
| 118 |
+
detailMode: params.turn.toolProgressDetail,
|
| 119 |
+
});
|
| 120 |
+
await Promise.all([params.turn.typingSignals.signalToolStart(), toolStartProgressPromise]);
|
| 121 |
+
}
|
| 122 |
+
const commandOutput = buildCommandOutputFromToolResultEvent(evt);
|
| 123 |
+
if (commandOutput) {
|
| 124 |
+
await params.turn.opts?.onCommandOutput?.(commandOutput);
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const suppressItemChannelProgress =
|
| 129 |
+
evt.stream === "item" &&
|
| 130 |
+
evt.data.suppressChannelProgress === true &&
|
| 131 |
+
Boolean(params.turn.opts?.onToolStart);
|
| 132 |
+
const hideItemFromChannelProgress =
|
| 133 |
+
evt.stream === "item" && evt.data.hideFromChannelProgress === true;
|
| 134 |
+
const itemPhase = evt.stream === "item" ? readStringValue(evt.data.phase) : "";
|
| 135 |
+
const itemName = evt.stream === "item" ? readStringValue(evt.data.name) : "";
|
| 136 |
+
const itemStatus = evt.stream === "item" ? readStringValue(evt.data.status) : "";
|
| 137 |
+
const itemToolCallId =
|
| 138 |
+
evt.stream === "item" ? (readStringValue(evt.data.toolCallId) ?? "") : "";
|
| 139 |
+
const completedMessageToolDelivery =
|
| 140 |
+
params.sourceRepliesAreToolOnly &&
|
| 141 |
+
itemPhase === "end" &&
|
| 142 |
+
itemStatus === "completed" &&
|
| 143 |
+
itemToolCallId.length > 0 &&
|
| 144 |
+
params.messageToolDeliveryState.toolCallIds.has(itemToolCallId);
|
| 145 |
+
const suppressProgressAfterMessageToolDelivery =
|
| 146 |
+
shouldSuppressProgressAfterMessageToolDelivery();
|
| 147 |
+
if (completedMessageToolDelivery) {
|
| 148 |
+
params.messageToolDeliveryState.toolCallIds.delete(itemToolCallId);
|
| 149 |
+
params.messageToolDeliveryState.completed = true;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if (
|
| 153 |
+
evt.stream === "item" &&
|
| 154 |
+
!hideItemFromChannelProgress &&
|
| 155 |
+
!suppressItemChannelProgress &&
|
| 156 |
+
(!suppressProgressAfterMessageToolDelivery || completedMessageToolDelivery)
|
| 157 |
+
) {
|
| 158 |
+
const itemSummary = readStringValue(evt.data.summary);
|
| 159 |
+
const itemProgressText = readStringValue(evt.data.progressText);
|
| 160 |
+
const itemMeta = readStringValue(evt.data.meta);
|
| 161 |
+
const itemCommandBearing =
|
| 162 |
+
typeof evt.data.commandBearing === "boolean" ? evt.data.commandBearing : undefined;
|
| 163 |
+
const itemApprovalId = readStringValue(evt.data.approvalId);
|
| 164 |
+
const itemApprovalSlug = readStringValue(evt.data.approvalSlug);
|
| 165 |
+
await params.turn.opts?.onItemEvent?.({
|
| 166 |
+
itemId: readStringValue(evt.data.itemId),
|
| 167 |
+
kind: readStringValue(evt.data.kind),
|
| 168 |
+
title: readStringValue(evt.data.title),
|
| 169 |
+
phase: itemPhase,
|
| 170 |
+
status: itemStatus,
|
| 171 |
+
...(itemToolCallId ? { toolCallId: itemToolCallId } : {}),
|
| 172 |
+
...(itemName ? { name: itemName } : {}),
|
| 173 |
+
...(itemSummary !== undefined ? { summary: itemSummary } : {}),
|
| 174 |
+
...(itemProgressText !== undefined ? { progressText: itemProgressText } : {}),
|
| 175 |
+
...(itemMeta !== undefined ? { meta: itemMeta } : {}),
|
| 176 |
+
...(itemCommandBearing !== undefined ? { commandBearing: itemCommandBearing } : {}),
|
| 177 |
+
...(itemApprovalId !== undefined ? { approvalId: itemApprovalId } : {}),
|
| 178 |
+
...(itemApprovalSlug !== undefined ? { approvalSlug: itemApprovalSlug } : {}),
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
if (evt.stream === "plan" && !shouldSuppressProgressAfterMessageToolDelivery()) {
|
| 182 |
+
await params.turn.opts?.onPlanUpdate?.({
|
| 183 |
+
phase: readStringValue(evt.data.phase),
|
| 184 |
+
title: readStringValue(evt.data.title),
|
| 185 |
+
explanation: readStringValue(evt.data.explanation),
|
| 186 |
+
...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
|
| 187 |
+
steps: normalizeAgentPlanSteps(evt.data.steps),
|
| 188 |
+
source: readStringValue(evt.data.source),
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
+
if (evt.stream === "approval" && !shouldSuppressProgressAfterMessageToolDelivery()) {
|
| 192 |
+
await params.turn.opts?.onApprovalEvent?.({
|
| 193 |
+
phase: readStringValue(evt.data.phase),
|
| 194 |
+
kind: readStringValue(evt.data.kind),
|
| 195 |
+
status: readStringValue(evt.data.status),
|
| 196 |
+
title: readStringValue(evt.data.title),
|
| 197 |
+
itemId: readStringValue(evt.data.itemId),
|
| 198 |
+
toolCallId: readStringValue(evt.data.toolCallId),
|
| 199 |
+
approvalId: readStringValue(evt.data.approvalId),
|
| 200 |
+
approvalSlug: readStringValue(evt.data.approvalSlug),
|
| 201 |
+
command: readStringValue(evt.data.command),
|
| 202 |
+
host: readStringValue(evt.data.host),
|
| 203 |
+
reason: readStringValue(evt.data.reason),
|
| 204 |
+
scope: readApprovalScopeValue(evt.data.scope),
|
| 205 |
+
message: readStringValue(evt.data.message),
|
| 206 |
+
});
|
| 207 |
+
}
|
| 208 |
+
if (evt.stream === "command_output" && !shouldSuppressProgressAfterMessageToolDelivery()) {
|
| 209 |
+
await params.turn.opts?.onCommandOutput?.({
|
| 210 |
+
itemId: readStringValue(evt.data.itemId),
|
| 211 |
+
phase: readStringValue(evt.data.phase),
|
| 212 |
+
title: readStringValue(evt.data.title),
|
| 213 |
+
toolCallId: readStringValue(evt.data.toolCallId),
|
| 214 |
+
name: readStringValue(evt.data.name),
|
| 215 |
+
output: readStringValue(evt.data.output),
|
| 216 |
+
status: readStringValue(evt.data.status),
|
| 217 |
+
exitCode:
|
| 218 |
+
typeof evt.data.exitCode === "number" || evt.data.exitCode === null
|
| 219 |
+
? evt.data.exitCode
|
| 220 |
+
: undefined,
|
| 221 |
+
durationMs: typeof evt.data.durationMs === "number" ? evt.data.durationMs : undefined,
|
| 222 |
+
cwd: readStringValue(evt.data.cwd),
|
| 223 |
+
});
|
| 224 |
+
}
|
| 225 |
+
if (evt.stream === "patch" && !shouldSuppressProgressAfterMessageToolDelivery()) {
|
| 226 |
+
await params.turn.opts?.onPatchSummary?.({
|
| 227 |
+
itemId: readStringValue(evt.data.itemId),
|
| 228 |
+
phase: readStringValue(evt.data.phase),
|
| 229 |
+
title: readStringValue(evt.data.title),
|
| 230 |
+
toolCallId: readStringValue(evt.data.toolCallId),
|
| 231 |
+
name: readStringValue(evt.data.name),
|
| 232 |
+
added: Array.isArray(evt.data.added)
|
| 233 |
+
? evt.data.added.filter((entry): entry is string => typeof entry === "string")
|
| 234 |
+
: undefined,
|
| 235 |
+
modified: Array.isArray(evt.data.modified)
|
| 236 |
+
? evt.data.modified.filter((entry): entry is string => typeof entry === "string")
|
| 237 |
+
: undefined,
|
| 238 |
+
deleted: Array.isArray(evt.data.deleted)
|
| 239 |
+
? evt.data.deleted.filter((entry): entry is string => typeof entry === "string")
|
| 240 |
+
: undefined,
|
| 241 |
+
summary: readStringValue(evt.data.summary),
|
| 242 |
+
});
|
| 243 |
+
}
|
| 244 |
+
if (evt.stream !== "compaction") {
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
const phase = readStringValue(evt.data.phase) ?? "";
|
| 249 |
+
const backend = readStringValue(evt.data.backend);
|
| 250 |
+
const hookMessages = readCompactionHookMessages(evt.data.messages);
|
| 251 |
+
const sendCompactionUserNotices = async (noticePhase: "start" | "end" | "incomplete") => {
|
| 252 |
+
if (hookMessages.length > 0) {
|
| 253 |
+
await sendCompactionHookMessages(hookMessages);
|
| 254 |
+
}
|
| 255 |
+
if (params.notifyUserAboutCompaction) {
|
| 256 |
+
await sendCompactionNotice(noticePhase);
|
| 257 |
+
}
|
| 258 |
+
};
|
| 259 |
+
if (phase === "start") {
|
| 260 |
+
await params.turn.opts?.onCompactionStart?.();
|
| 261 |
+
await sendCompactionUserNotices("start");
|
| 262 |
+
return;
|
| 263 |
+
}
|
| 264 |
+
if (phase !== "end") {
|
| 265 |
+
return;
|
| 266 |
+
}
|
| 267 |
+
if (evt.data.completed !== true) {
|
| 268 |
+
await params.turn.opts?.onCompactionEnd?.({ completed: false });
|
| 269 |
+
await sendCompactionUserNotices("incomplete");
|
| 270 |
+
return;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
const compactionCount = params.onCompactionCompleted();
|
| 274 |
+
if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) {
|
| 275 |
+
const modelRef = formatCompactionModelRef(params.provider, params.model);
|
| 276 |
+
const consoleMessage =
|
| 277 |
+
`codex app-server auto-compaction succeeded for ${modelRef}; ` +
|
| 278 |
+
"refreshed session context";
|
| 279 |
+
agentCompactionLog.info("codex app-server auto-compaction succeeded", {
|
| 280 |
+
event: "codex_app_server_compaction_succeeded",
|
| 281 |
+
backend,
|
| 282 |
+
provider: params.provider,
|
| 283 |
+
model: params.model,
|
| 284 |
+
sessionKey: params.turn.sessionKey,
|
| 285 |
+
sessionId: params.effectiveSessionId,
|
| 286 |
+
threadId: readStringValue(evt.data.threadId),
|
| 287 |
+
turnId: readStringValue(evt.data.turnId),
|
| 288 |
+
itemId: readStringValue(evt.data.itemId),
|
| 289 |
+
compactionCount,
|
| 290 |
+
consoleMessage,
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
await params.turn.opts?.onCompactionEnd?.({ completed: true });
|
| 294 |
+
await sendCompactionUserNotices("end");
|
| 295 |
+
};
|
| 296 |
+
}
|
src/auto-reply/reply/agent-runner-execute.ts
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "node:crypto";
|
| 2 |
+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
| 3 |
+
import type { OpenClawConfig } from "../../config/config.js";
|
| 4 |
+
import type { SessionEntry } from "../../config/sessions.js";
|
| 5 |
+
import { withBeforeAgentReplyObserver } from "../../plugins/before-agent-reply.js";
|
| 6 |
+
import { getGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js";
|
| 7 |
+
import { readPendingUserTurnTranscriptAdmission } from "../../sessions/user-turn-transcript-admission.js";
|
| 8 |
+
import { setReplyPayloadMetadata } from "../reply-payload.js";
|
| 9 |
+
import type { OriginatingChannelType } from "../templating.js";
|
| 10 |
+
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
| 11 |
+
import type { ReplyPayload } from "../types.js";
|
| 12 |
+
import {
|
| 13 |
+
resolveReplyRunDeliveryContext,
|
| 14 |
+
resolveSourceReplyPolicy,
|
| 15 |
+
type RunReplyAgentParams,
|
| 16 |
+
} from "./agent-runner-core.js";
|
| 17 |
+
import { executeAgentTurn } from "./agent-runner-execution.js";
|
| 18 |
+
import { markPostCompactionModelFailurePayload } from "./agent-runner-failure-reply.js";
|
| 19 |
+
import { runMemoryFlushIfNeeded, runSessionCompactionIfNeeded } from "./agent-runner-memory.js";
|
| 20 |
+
import { accountAgentTurnCompaction } from "./agent-runner-result-accounting.js";
|
| 21 |
+
import { finalizeReplyAgentRun } from "./agent-runner-result.js";
|
| 22 |
+
import { buildThreadingToolContext } from "./agent-runner-utils.js";
|
| 23 |
+
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
| 24 |
+
import type { CompactionNoticePhase } from "./compaction-notice.js";
|
| 25 |
+
import { createFollowupRunner } from "./followup-runner.js";
|
| 26 |
+
import {
|
| 27 |
+
buildRecoverablePendingFinalDeliveryText,
|
| 28 |
+
normalizePendingFinalDeliveryPayloads,
|
| 29 |
+
} from "./pending-final-delivery.js";
|
| 30 |
+
import type { FollowupRun } from "./queue.js";
|
| 31 |
+
import type { ReplyMediaContext } from "./reply-media-paths.js";
|
| 32 |
+
import { isReplyOperationSuperseded } from "./reply-operation-abort.js";
|
| 33 |
+
import { recordReplyOperationAgentTurn } from "./reply-operation-run-state.js";
|
| 34 |
+
import type { ReplyOperation } from "./reply-run-registry.js";
|
| 35 |
+
import { replyRunRegistry } from "./reply-run-registry.js";
|
| 36 |
+
import { resolveReplyToMode } from "./reply-threading.js";
|
| 37 |
+
import { createReplyRestartRecoveryClaimController } from "./restart-recovery-claim.js";
|
| 38 |
+
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
|
| 39 |
+
import type { TypingSignaler } from "./typing-mode.js";
|
| 40 |
+
type ExecutePreparedReplyAgentRunInput = Pick<
|
| 41 |
+
RunReplyAgentParams,
|
| 42 |
+
| "blockReplyChunking"
|
| 43 |
+
| "blockStreamingEnabled"
|
| 44 |
+
| "commandBody"
|
| 45 |
+
| "defaultModel"
|
| 46 |
+
| "followupRun"
|
| 47 |
+
| "opts"
|
| 48 |
+
| "queueKey"
|
| 49 |
+
| "replyThreadingOverride"
|
| 50 |
+
| "resolvedBlockStreamingBreak"
|
| 51 |
+
| "resolvedQueue"
|
| 52 |
+
| "resolvedVerboseLevel"
|
| 53 |
+
| "runtimePolicySessionKey"
|
| 54 |
+
| "sessionCtx"
|
| 55 |
+
| "sessionKey"
|
| 56 |
+
| "shouldInjectGroupIntro"
|
| 57 |
+
| "storePath"
|
| 58 |
+
| "toolProgressDetail"
|
| 59 |
+
| "transcriptCommandBody"
|
| 60 |
+
| "typing"
|
| 61 |
+
| "typingMode"
|
| 62 |
+
> & {
|
| 63 |
+
activeSessionStore: Record<string, SessionEntry> | undefined;
|
| 64 |
+
admitUserTurn: ReturnType<typeof createReplyRestartRecoveryClaimController>["admitUserTurn"];
|
| 65 |
+
applyReplyToMode: (payload: ReplyPayload) => ReplyPayload;
|
| 66 |
+
beginBeforeAgentReply: ReturnType<
|
| 67 |
+
typeof createReplyRestartRecoveryClaimController
|
| 68 |
+
>["beginBeforeAgentReply"];
|
| 69 |
+
blockReplyPipeline: BlockReplyPipeline | null;
|
| 70 |
+
cfg: OpenClawConfig;
|
| 71 |
+
checkpointBeforeAgentReply: ReturnType<
|
| 72 |
+
typeof createReplyRestartRecoveryClaimController
|
| 73 |
+
>["checkpointBeforeAgentReply"];
|
| 74 |
+
resolveVisibleReplyDelivery: () => Promise<boolean>;
|
| 75 |
+
getActiveIsNewSession: () => boolean;
|
| 76 |
+
getActiveSessionEntry: () => SessionEntry | undefined;
|
| 77 |
+
isHeartbeat: boolean;
|
| 78 |
+
isRestartRecoveryArmed: () => boolean;
|
| 79 |
+
pendingToolTasks: Set<Promise<void>>;
|
| 80 |
+
replyMediaContext: ReplyMediaContext;
|
| 81 |
+
replyOperation: ReplyOperation;
|
| 82 |
+
replyRouteThreadId: ReturnType<typeof resolveRoutedDeliveryThreadId>;
|
| 83 |
+
replyToChannel: OriginatingChannelType | undefined;
|
| 84 |
+
replyToMode: ReturnType<typeof resolveReplyToMode>;
|
| 85 |
+
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
|
| 86 |
+
returnWithQueuedFollowupDrain: <T>(value: T) => T;
|
| 87 |
+
runFollowupTurn: (queued: FollowupRun) => Promise<void>;
|
| 88 |
+
sendDirectCompactionNotice: ((phase: CompactionNoticePhase) => Promise<void>) | undefined;
|
| 89 |
+
setRunFollowupTurn: (runner: (queued: FollowupRun) => Promise<void>) => void;
|
| 90 |
+
setActiveSessionEntry: (entry: SessionEntry | undefined) => void;
|
| 91 |
+
shouldEmitToolOutput: () => boolean;
|
| 92 |
+
shouldEmitToolResult: () => boolean;
|
| 93 |
+
traceAgentPhase: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
|
| 94 |
+
turnAdoptionLifecycle: NonNullable<RunReplyAgentParams["opts"]>["turnAdoptionLifecycle"];
|
| 95 |
+
typingSignals: TypingSignaler;
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
function markPostCompactionFailureResult(
|
| 99 |
+
result: ReplyPayload | ReplyPayload[] | undefined,
|
| 100 |
+
postCompactionModelFailure: true | undefined,
|
| 101 |
+
): ReplyPayload | ReplyPayload[] | undefined {
|
| 102 |
+
if (Array.isArray(result)) {
|
| 103 |
+
return result.map((payload) =>
|
| 104 |
+
markPostCompactionModelFailurePayload(postCompactionModelFailure, payload),
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
return result
|
| 108 |
+
? markPostCompactionModelFailurePayload(postCompactionModelFailure, result)
|
| 109 |
+
: result;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export async function executePreparedReplyAgentRun(
|
| 113 |
+
context: ExecutePreparedReplyAgentRunInput,
|
| 114 |
+
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
| 115 |
+
const {
|
| 116 |
+
activeSessionStore,
|
| 117 |
+
admitUserTurn: admitUserTurnWithRecovery,
|
| 118 |
+
applyReplyToMode,
|
| 119 |
+
beginBeforeAgentReply: beginBeforeAgentReplyWithRecovery,
|
| 120 |
+
blockReplyChunking,
|
| 121 |
+
blockReplyPipeline,
|
| 122 |
+
blockStreamingEnabled,
|
| 123 |
+
cfg,
|
| 124 |
+
checkpointBeforeAgentReply: checkpointBeforeAgentReplyWithRecovery,
|
| 125 |
+
commandBody,
|
| 126 |
+
defaultModel,
|
| 127 |
+
followupRun,
|
| 128 |
+
getActiveIsNewSession,
|
| 129 |
+
getActiveSessionEntry,
|
| 130 |
+
isHeartbeat,
|
| 131 |
+
isRestartRecoveryArmed,
|
| 132 |
+
opts,
|
| 133 |
+
pendingToolTasks,
|
| 134 |
+
queueKey,
|
| 135 |
+
replyMediaContext,
|
| 136 |
+
replyOperation,
|
| 137 |
+
replyRouteThreadId,
|
| 138 |
+
replyThreadingOverride,
|
| 139 |
+
replyToChannel,
|
| 140 |
+
replyToMode,
|
| 141 |
+
resetSessionAfterRoleOrderingConflict,
|
| 142 |
+
resolvedBlockStreamingBreak,
|
| 143 |
+
resolvedQueue,
|
| 144 |
+
resolvedVerboseLevel,
|
| 145 |
+
returnWithQueuedFollowupDrain,
|
| 146 |
+
runtimePolicySessionKey,
|
| 147 |
+
sendDirectCompactionNotice,
|
| 148 |
+
sessionCtx,
|
| 149 |
+
sessionKey,
|
| 150 |
+
setActiveSessionEntry,
|
| 151 |
+
setRunFollowupTurn,
|
| 152 |
+
shouldEmitToolOutput,
|
| 153 |
+
shouldEmitToolResult,
|
| 154 |
+
shouldInjectGroupIntro,
|
| 155 |
+
storePath,
|
| 156 |
+
toolProgressDetail,
|
| 157 |
+
traceAgentPhase,
|
| 158 |
+
transcriptCommandBody,
|
| 159 |
+
turnAdoptionLifecycle,
|
| 160 |
+
typing,
|
| 161 |
+
typingMode,
|
| 162 |
+
typingSignals,
|
| 163 |
+
} = context;
|
| 164 |
+
let activeSessionEntry = getActiveSessionEntry();
|
| 165 |
+
const admitUserTurn = async (
|
| 166 |
+
...args: Parameters<typeof admitUserTurnWithRecovery>
|
| 167 |
+
): ReturnType<typeof admitUserTurnWithRecovery> => {
|
| 168 |
+
const result = await admitUserTurnWithRecovery(...args);
|
| 169 |
+
activeSessionEntry = getActiveSessionEntry();
|
| 170 |
+
return result;
|
| 171 |
+
};
|
| 172 |
+
const beginBeforeAgentReply = async (
|
| 173 |
+
...args: Parameters<typeof beginBeforeAgentReplyWithRecovery>
|
| 174 |
+
): ReturnType<typeof beginBeforeAgentReplyWithRecovery> => {
|
| 175 |
+
const result = await beginBeforeAgentReplyWithRecovery(...args);
|
| 176 |
+
activeSessionEntry = getActiveSessionEntry();
|
| 177 |
+
return result;
|
| 178 |
+
};
|
| 179 |
+
const checkpointBeforeAgentReply = async (
|
| 180 |
+
...args: Parameters<typeof checkpointBeforeAgentReplyWithRecovery>
|
| 181 |
+
): ReturnType<typeof checkpointBeforeAgentReplyWithRecovery> => {
|
| 182 |
+
const result = await checkpointBeforeAgentReplyWithRecovery(...args);
|
| 183 |
+
activeSessionEntry = getActiveSessionEntry();
|
| 184 |
+
return result;
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
await typingSignals.signalRunStart();
|
| 188 |
+
|
| 189 |
+
const preflightAdmission = readPendingUserTurnTranscriptAdmission(
|
| 190 |
+
followupRun.userTurnTranscriptRecorder,
|
| 191 |
+
);
|
| 192 |
+
const checkpointMemory = async (entry: SessionEntry) => {
|
| 193 |
+
const flushed = await traceAgentPhase("reply.memory_flush", () =>
|
| 194 |
+
runMemoryFlushIfNeeded({
|
| 195 |
+
preflightAdmission,
|
| 196 |
+
cfg,
|
| 197 |
+
followupRun,
|
| 198 |
+
promptForEstimate: followupRun.prompt,
|
| 199 |
+
opts,
|
| 200 |
+
defaultModel,
|
| 201 |
+
resolvedVerboseLevel,
|
| 202 |
+
sessionEntry: entry,
|
| 203 |
+
sessionStore: activeSessionStore,
|
| 204 |
+
sessionKey,
|
| 205 |
+
runtimePolicySessionKey,
|
| 206 |
+
storePath,
|
| 207 |
+
isHeartbeat,
|
| 208 |
+
replyOperation,
|
| 209 |
+
}),
|
| 210 |
+
);
|
| 211 |
+
setActiveSessionEntry(flushed.sessionEntry);
|
| 212 |
+
replyOperation.abortSignal.throwIfAborted();
|
| 213 |
+
if (flushed.outcome === "exhausted") {
|
| 214 |
+
await sendDirectCompactionNotice?.("memory_flush_degraded");
|
| 215 |
+
}
|
| 216 |
+
return flushed.sessionEntry;
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
+
const prePreflightCompactionCount = activeSessionEntry?.compactionCount ?? 0;
|
| 220 |
+
activeSessionEntry = await traceAgentPhase("reply.preflight_compaction", () =>
|
| 221 |
+
runSessionCompactionIfNeeded({
|
| 222 |
+
pendingUserEntryId: preflightAdmission?.entryId,
|
| 223 |
+
cfg,
|
| 224 |
+
followupRun,
|
| 225 |
+
promptForEstimate: followupRun.prompt,
|
| 226 |
+
defaultModel,
|
| 227 |
+
sessionEntry: activeSessionEntry,
|
| 228 |
+
sessionStore: activeSessionStore,
|
| 229 |
+
sessionKey,
|
| 230 |
+
runtimePolicySessionKey,
|
| 231 |
+
storePath,
|
| 232 |
+
isHeartbeat,
|
| 233 |
+
abortSignal: replyOperation.abortSignal,
|
| 234 |
+
beforeCompaction: checkpointMemory,
|
| 235 |
+
onCompactionStart: () => replyOperation.setPhase("preflight_compacting"),
|
| 236 |
+
onSessionIdChanged: (sessionId) => replyOperation.updateSessionId(sessionId),
|
| 237 |
+
onCompactionNotice: sendDirectCompactionNotice,
|
| 238 |
+
}),
|
| 239 |
+
);
|
| 240 |
+
setActiveSessionEntry(activeSessionEntry);
|
| 241 |
+
const preflightCompactionApplied =
|
| 242 |
+
(activeSessionEntry?.compactionCount ?? 0) > prePreflightCompactionCount;
|
| 243 |
+
|
| 244 |
+
const runFollowupTurn = createFollowupRunner({
|
| 245 |
+
resolveGatewayContext: getGatewayContextResolver(replyOperation),
|
| 246 |
+
opts,
|
| 247 |
+
typing,
|
| 248 |
+
typingMode,
|
| 249 |
+
sessionEntry: activeSessionEntry,
|
| 250 |
+
sessionStore: activeSessionStore,
|
| 251 |
+
sessionKey,
|
| 252 |
+
storePath,
|
| 253 |
+
defaultModel,
|
| 254 |
+
toolProgressDetail,
|
| 255 |
+
});
|
| 256 |
+
setRunFollowupTurn(runFollowupTurn);
|
| 257 |
+
|
| 258 |
+
replyOperation.setPhase("running");
|
| 259 |
+
const runStartedAt = Date.now();
|
| 260 |
+
const userTurnAdmission = await admitUserTurn(followupRun.userTurnTranscriptRecorder);
|
| 261 |
+
if (userTurnAdmission === "duplicate-source") {
|
| 262 |
+
return returnWithQueuedFollowupDrain(undefined);
|
| 263 |
+
}
|
| 264 |
+
// Adoption marks run start and must never be spool-replayed (would re-run tools).
|
| 265 |
+
// Suppressed delivery persists only the user transcript; crashed suppressed runs die
|
| 266 |
+
// silently. Deliverable turns atomically persist transcript plus recovery ownership.
|
| 267 |
+
await turnAdoptionLifecycle?.onAdopted();
|
| 268 |
+
const runOutcome = await withBeforeAgentReplyObserver(
|
| 269 |
+
{
|
| 270 |
+
beforeDispatch: async () => {
|
| 271 |
+
return await beginBeforeAgentReply();
|
| 272 |
+
},
|
| 273 |
+
afterDispatch: async (hookResult) => {
|
| 274 |
+
if (!hookResult?.handled) {
|
| 275 |
+
await checkpointBeforeAgentReply({ state: undefined });
|
| 276 |
+
return hookResult;
|
| 277 |
+
}
|
| 278 |
+
const hookReply = hookResult.reply ?? { text: SILENT_REPLY_TOKEN };
|
| 279 |
+
const hookFinalDeliveryText = buildRecoverablePendingFinalDeliveryText([hookReply]);
|
| 280 |
+
const normalizedHookReplies = normalizePendingFinalDeliveryPayloads([hookReply]);
|
| 281 |
+
let hookCheckpoint: Parameters<typeof checkpointBeforeAgentReply>[0] = {
|
| 282 |
+
state: normalizedHookReplies.length === 0 ? "handled-silent" : "pending",
|
| 283 |
+
};
|
| 284 |
+
if (sessionKey && storePath && normalizedHookReplies.length > 0) {
|
| 285 |
+
const sourceReplyPolicy = resolveSourceReplyPolicy({
|
| 286 |
+
cfg,
|
| 287 |
+
sessionCtx,
|
| 288 |
+
sessionEntry: activeSessionEntry,
|
| 289 |
+
sessionKey,
|
| 290 |
+
runtimePolicySessionKey,
|
| 291 |
+
opts,
|
| 292 |
+
});
|
| 293 |
+
if (!sourceReplyPolicy.suppressDelivery) {
|
| 294 |
+
const pendingFinalDeliveryIntentId = crypto.randomUUID();
|
| 295 |
+
const pendingFinalDeliveryDeliveryId = crypto.randomUUID();
|
| 296 |
+
setReplyPayloadMetadata(hookReply, {
|
| 297 |
+
pendingFinalDeliveryCompletion: {
|
| 298 |
+
deliveryId: pendingFinalDeliveryDeliveryId,
|
| 299 |
+
intentId: pendingFinalDeliveryIntentId,
|
| 300 |
+
...(activeSessionEntry?.restartRecoveryDeliveryRunId
|
| 301 |
+
? { recoveryRunId: activeSessionEntry.restartRecoveryDeliveryRunId }
|
| 302 |
+
: {}),
|
| 303 |
+
sessionId: replyOperation.sessionId,
|
| 304 |
+
sessionKey,
|
| 305 |
+
storePath,
|
| 306 |
+
},
|
| 307 |
+
});
|
| 308 |
+
hookCheckpoint = {
|
| 309 |
+
state: "handled-reply",
|
| 310 |
+
pendingFinalDelivery: {
|
| 311 |
+
text: hookFinalDeliveryText ?? "",
|
| 312 |
+
intentId: pendingFinalDeliveryIntentId,
|
| 313 |
+
deliveries: [{ id: pendingFinalDeliveryDeliveryId, state: "prepared" }],
|
| 314 |
+
context: resolveReplyRunDeliveryContext({
|
| 315 |
+
cfg,
|
| 316 |
+
sessionCtx,
|
| 317 |
+
sessionEntry: activeSessionEntry,
|
| 318 |
+
sessionKey,
|
| 319 |
+
runtimePolicySessionKey,
|
| 320 |
+
opts,
|
| 321 |
+
}),
|
| 322 |
+
},
|
| 323 |
+
};
|
| 324 |
+
} else {
|
| 325 |
+
// dispatch-from-config owns source visibility for every returned payload.
|
| 326 |
+
// This checkpoint records that recovery owes no delivery; the outer gate drops the reply.
|
| 327 |
+
hookCheckpoint = { state: "handled-silent" };
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
await checkpointBeforeAgentReply(hookCheckpoint);
|
| 331 |
+
return { ...hookResult, reply: hookReply };
|
| 332 |
+
},
|
| 333 |
+
},
|
| 334 |
+
() =>
|
| 335 |
+
traceAgentPhase("reply.run_agent_turn", () =>
|
| 336 |
+
executeAgentTurn({
|
| 337 |
+
commandBody,
|
| 338 |
+
transcriptCommandBody,
|
| 339 |
+
followupRun,
|
| 340 |
+
sessionCtx,
|
| 341 |
+
replyThreading: replyThreadingOverride ?? sessionCtx.ReplyThreading,
|
| 342 |
+
replyOperation,
|
| 343 |
+
opts,
|
| 344 |
+
resolveVisibleReplyDelivery: context.resolveVisibleReplyDelivery,
|
| 345 |
+
typingSignals,
|
| 346 |
+
blockReplyPipeline,
|
| 347 |
+
blockStreamingEnabled,
|
| 348 |
+
blockReplyChunking,
|
| 349 |
+
resolvedBlockStreamingBreak,
|
| 350 |
+
applyReplyToMode,
|
| 351 |
+
shouldEmitToolResult,
|
| 352 |
+
shouldEmitToolOutput,
|
| 353 |
+
pendingToolTasks,
|
| 354 |
+
resetSessionAfterRoleOrderingConflict,
|
| 355 |
+
isHeartbeat,
|
| 356 |
+
sessionKey,
|
| 357 |
+
runtimePolicySessionKey,
|
| 358 |
+
getActiveSessionEntry,
|
| 359 |
+
activeSessionStore,
|
| 360 |
+
storePath,
|
| 361 |
+
resolvedVerboseLevel,
|
| 362 |
+
toolProgressDetail,
|
| 363 |
+
replyMediaContext,
|
| 364 |
+
isRestartRecoveryArmed,
|
| 365 |
+
}),
|
| 366 |
+
),
|
| 367 |
+
);
|
| 368 |
+
const operationSuperseded = isReplyOperationSuperseded(replyOperation);
|
| 369 |
+
recordReplyOperationAgentTurn(
|
| 370 |
+
followupRun.replyOperationRunStates,
|
| 371 |
+
replyOperation,
|
| 372 |
+
runOutcome.outcome,
|
| 373 |
+
);
|
| 374 |
+
activeSessionEntry = getActiveSessionEntry();
|
| 375 |
+
const activeIsNewSession = getActiveIsNewSession();
|
| 376 |
+
|
| 377 |
+
if (runOutcome.outcome.kind !== "settled") {
|
| 378 |
+
// Only captured facts cross cancellation; no successor adoption, hooks, or reply work.
|
| 379 |
+
await accountAgentTurnCompaction({
|
| 380 |
+
compaction: runOutcome.outcome.compaction,
|
| 381 |
+
sessionStore: activeSessionStore,
|
| 382 |
+
replyOperation,
|
| 383 |
+
});
|
| 384 |
+
}
|
| 385 |
+
if (operationSuperseded) {
|
| 386 |
+
return { text: SILENT_REPLY_TOKEN };
|
| 387 |
+
}
|
| 388 |
+
if (runOutcome.outcome.kind !== "settled") {
|
| 389 |
+
if (runOutcome.outcome.kind === "rejected" && !replyOperation.result) {
|
| 390 |
+
replyOperation.fail("run_failed", new Error("reply operation exited with final payload"));
|
| 391 |
+
}
|
| 392 |
+
return returnWithQueuedFollowupDrain(
|
| 393 |
+
runOutcome.outcome.kind === "rejected"
|
| 394 |
+
? markPostCompactionModelFailurePayload(
|
| 395 |
+
runOutcome.outcome.postCompactionModelFailure,
|
| 396 |
+
runOutcome.outcome.payload,
|
| 397 |
+
)
|
| 398 |
+
: { text: SILENT_REPLY_TOKEN },
|
| 399 |
+
);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
const result = await finalizeReplyAgentRun({
|
| 403 |
+
activeIsNewSession,
|
| 404 |
+
activeSessionEntry,
|
| 405 |
+
activeSessionStore,
|
| 406 |
+
blockReplyPipeline,
|
| 407 |
+
blockStreamingEnabled,
|
| 408 |
+
cfg,
|
| 409 |
+
commandBody,
|
| 410 |
+
defaultModel,
|
| 411 |
+
followupRun,
|
| 412 |
+
isHeartbeat,
|
| 413 |
+
opts,
|
| 414 |
+
pendingToolTasks,
|
| 415 |
+
preflightCompactionApplied,
|
| 416 |
+
queueKey,
|
| 417 |
+
replyMediaContext,
|
| 418 |
+
replyOperation,
|
| 419 |
+
replyRouteThreadId,
|
| 420 |
+
replyThreadingOverride,
|
| 421 |
+
replyToChannel,
|
| 422 |
+
replyToMode,
|
| 423 |
+
resolvedBlockStreamingBreak,
|
| 424 |
+
resolvedQueue,
|
| 425 |
+
resolvedVerboseLevel,
|
| 426 |
+
returnWithQueuedFollowupDrain,
|
| 427 |
+
runFollowupTurn,
|
| 428 |
+
execution: runOutcome.outcome,
|
| 429 |
+
runId: runOutcome.runId,
|
| 430 |
+
runStartedAt,
|
| 431 |
+
runtimePolicySessionKey,
|
| 432 |
+
sessionCtx,
|
| 433 |
+
sessionKey,
|
| 434 |
+
shouldInjectGroupIntro,
|
| 435 |
+
storePath,
|
| 436 |
+
typingSignals,
|
| 437 |
+
});
|
| 438 |
+
return markPostCompactionFailureResult(result, runOutcome.outcome.postCompactionModelFailure);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
export function createReplyAgentRestartRecoveryController(
|
| 442 |
+
context: Pick<
|
| 443 |
+
RunReplyAgentParams,
|
| 444 |
+
"followupRun" | "opts" | "runtimePolicySessionKey" | "sessionCtx" | "sessionKey" | "storePath"
|
| 445 |
+
> & {
|
| 446 |
+
activeSessionStore: Record<string, SessionEntry> | undefined;
|
| 447 |
+
cfg: OpenClawConfig;
|
| 448 |
+
getActiveSessionEntry: () => SessionEntry | undefined;
|
| 449 |
+
replyOperation: ReplyOperation;
|
| 450 |
+
restartRecoverySourceTurnId: string | undefined;
|
| 451 |
+
setActiveSessionEntry: (entry: SessionEntry) => void;
|
| 452 |
+
},
|
| 453 |
+
) {
|
| 454 |
+
const {
|
| 455 |
+
activeSessionStore,
|
| 456 |
+
cfg,
|
| 457 |
+
followupRun,
|
| 458 |
+
getActiveSessionEntry,
|
| 459 |
+
opts,
|
| 460 |
+
replyOperation,
|
| 461 |
+
restartRecoverySourceTurnId,
|
| 462 |
+
runtimePolicySessionKey,
|
| 463 |
+
sessionCtx,
|
| 464 |
+
sessionKey,
|
| 465 |
+
setActiveSessionEntry,
|
| 466 |
+
storePath,
|
| 467 |
+
} = context;
|
| 468 |
+
|
| 469 |
+
const restartRecoverySameChannelThreadRequired = restartRecoverySourceTurnId
|
| 470 |
+
? buildThreadingToolContext({
|
| 471 |
+
sessionCtx,
|
| 472 |
+
config: cfg,
|
| 473 |
+
hasRepliedRef: undefined,
|
| 474 |
+
}).sameChannelThreadRequired
|
| 475 |
+
: undefined;
|
| 476 |
+
const {
|
| 477 |
+
admitUserTurn,
|
| 478 |
+
beginBeforeAgentReply,
|
| 479 |
+
checkpointBeforeAgentReply,
|
| 480 |
+
clear: clearRestartRecoveryDeliveryClaim,
|
| 481 |
+
isArmed: isRestartRecoveryArmed,
|
| 482 |
+
} = createReplyRestartRecoveryClaimController({
|
| 483 |
+
lifecycleGeneration: replyOperation.lifecycleGeneration,
|
| 484 |
+
admissionRunId:
|
| 485 |
+
normalizeOptionalString(sessionCtx.MessageSid) ??
|
| 486 |
+
normalizeOptionalString(sessionCtx.MessageSidFull),
|
| 487 |
+
getEntry: () =>
|
| 488 |
+
sessionKey
|
| 489 |
+
? (activeSessionStore?.[sessionKey] ?? getActiveSessionEntry())
|
| 490 |
+
: getActiveSessionEntry(),
|
| 491 |
+
getSessionId: () => replyOperation.sessionId,
|
| 492 |
+
isRestartAbort: () =>
|
| 493 |
+
replyOperation.result?.kind === "aborted" &&
|
| 494 |
+
replyOperation.result.code === "aborted_for_restart",
|
| 495 |
+
resolveDeliveryContext: (entry) =>
|
| 496 |
+
sessionKey
|
| 497 |
+
? resolveReplyRunDeliveryContext({
|
| 498 |
+
cfg,
|
| 499 |
+
sessionCtx,
|
| 500 |
+
sessionEntry: entry,
|
| 501 |
+
sessionKey,
|
| 502 |
+
runtimePolicySessionKey,
|
| 503 |
+
opts,
|
| 504 |
+
})
|
| 505 |
+
: undefined,
|
| 506 |
+
requesterAccountId:
|
| 507 |
+
followupRun.originatingAccountId ?? sessionCtx.AccountId ?? followupRun.run.agentAccountId,
|
| 508 |
+
requesterSenderId: sessionCtx.SenderId,
|
| 509 |
+
resolveUserTurnTarget: ({
|
| 510 |
+
entry,
|
| 511 |
+
sessionId,
|
| 512 |
+
sessionKey: targetSessionKey,
|
| 513 |
+
storePath: targetStorePath,
|
| 514 |
+
}) => ({
|
| 515 |
+
sessionId,
|
| 516 |
+
sessionKey: targetSessionKey,
|
| 517 |
+
sessionEntry: entry,
|
| 518 |
+
...(activeSessionStore ? { sessionStore: activeSessionStore } : {}),
|
| 519 |
+
storePath: targetStorePath,
|
| 520 |
+
agentId: followupRun.run.agentId,
|
| 521 |
+
cwd: followupRun.run.workspaceDir,
|
| 522 |
+
config: cfg,
|
| 523 |
+
}),
|
| 524 |
+
...(sessionKey ? { sessionKey } : {}),
|
| 525 |
+
setEntry: (entry) => {
|
| 526 |
+
setActiveSessionEntry(entry);
|
| 527 |
+
if (activeSessionStore && sessionKey) {
|
| 528 |
+
activeSessionStore[sessionKey] = entry;
|
| 529 |
+
}
|
| 530 |
+
},
|
| 531 |
+
sameChannelThreadRequired: restartRecoverySameChannelThreadRequired,
|
| 532 |
+
sourceTurnId: restartRecoverySourceTurnId,
|
| 533 |
+
sourceReplyDeliveryMode: sessionKey
|
| 534 |
+
? resolveSourceReplyPolicy({
|
| 535 |
+
cfg,
|
| 536 |
+
sessionCtx,
|
| 537 |
+
sessionEntry: getActiveSessionEntry(),
|
| 538 |
+
sessionKey,
|
| 539 |
+
runtimePolicySessionKey,
|
| 540 |
+
opts,
|
| 541 |
+
}).sourceReplyDeliveryMode
|
| 542 |
+
: opts?.sourceReplyDeliveryMode,
|
| 543 |
+
...(storePath ? { storePath } : {}),
|
| 544 |
+
});
|
| 545 |
+
const admitUserTurnWithSourceBinding: typeof admitUserTurn = async (...args) => {
|
| 546 |
+
const result = await admitUserTurn(...args);
|
| 547 |
+
if (result === "admitted" && restartRecoverySourceTurnId) {
|
| 548 |
+
replyRunRegistry.bindSourceTurnId(replyOperation, restartRecoverySourceTurnId);
|
| 549 |
+
}
|
| 550 |
+
return result;
|
| 551 |
+
};
|
| 552 |
+
return {
|
| 553 |
+
admitUserTurn: admitUserTurnWithSourceBinding,
|
| 554 |
+
beginBeforeAgentReply,
|
| 555 |
+
checkpointBeforeAgentReply,
|
| 556 |
+
clear: clearRestartRecoveryDeliveryClaim,
|
| 557 |
+
isArmed: isRestartRecoveryArmed,
|
| 558 |
+
};
|
| 559 |
+
}
|
src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { OAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
|
| 3 |
+
import { createCliOutputFailoverError } from "../../agents/cli-runner/output-error.js";
|
| 4 |
+
import { FailoverError } from "../../agents/failover-error.js";
|
| 5 |
+
import { MissingProviderAuthError, ProviderAuthError } from "../../agents/model-auth.js";
|
| 6 |
+
import type { TemplateContext } from "../templating.js";
|
| 7 |
+
import {
|
| 8 |
+
setupAgentRunnerExecutionTestState,
|
| 9 |
+
getExecuteAgentTurnForTest,
|
| 10 |
+
createRunAgentTurnParams,
|
| 11 |
+
createFollowupRun,
|
| 12 |
+
createMinimalRunAgentTurnParams,
|
| 13 |
+
createTestFallbackSummaryError,
|
| 14 |
+
} from "./agent-runner-execution.test-support.js";
|
| 15 |
+
|
| 16 |
+
const state = await setupAgentRunnerExecutionTestState();
|
| 17 |
+
|
| 18 |
+
const providerLoginPresentation = (command: string) => ({
|
| 19 |
+
blocks: [
|
| 20 |
+
{
|
| 21 |
+
type: "buttons",
|
| 22 |
+
buttons: [
|
| 23 |
+
{
|
| 24 |
+
label: "Sign in",
|
| 25 |
+
action: { type: "command", command },
|
| 26 |
+
},
|
| 27 |
+
],
|
| 28 |
+
},
|
| 29 |
+
],
|
| 30 |
+
});
|
| 31 |
+
const PROVIDER_LOGIN_PRESENTATION = providerLoginPresentation("/login openai");
|
| 32 |
+
|
| 33 |
+
describe("executeAgentTurn: authentication failures", () => {
|
| 34 |
+
it("surfaces gateway reauth guidance without a profile id", async () => {
|
| 35 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 36 |
+
new OAuthRefreshFailureError({ provider: "openai", message: "refresh_token_reused" }),
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 40 |
+
const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
|
| 41 |
+
|
| 42 |
+
expect(result.kind).toBe("final");
|
| 43 |
+
if (result.kind === "final") {
|
| 44 |
+
expect(result.payload.text).toBe(
|
| 45 |
+
"⚠️ Your model provider needs a new login. Send `/login openai` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider openai` on the gateway.",
|
| 46 |
+
);
|
| 47 |
+
expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
|
| 48 |
+
}
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it("adds provider login recovery to raw forwarded refresh failures", async () => {
|
| 52 |
+
const message = "OAuth token refresh failed for openai: refresh_token_invalidated";
|
| 53 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 54 |
+
new FailoverError(message, {
|
| 55 |
+
reason: "auth_permanent",
|
| 56 |
+
provider: "openai",
|
| 57 |
+
model: "gpt-5.6-sol",
|
| 58 |
+
status: 401,
|
| 59 |
+
rawError: message,
|
| 60 |
+
}),
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 64 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 65 |
+
|
| 66 |
+
expect(result.kind).toBe("final");
|
| 67 |
+
if (result.kind === "final") {
|
| 68 |
+
expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
|
| 69 |
+
}
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it("keeps provider login recovery actionable on Control UI turns", async () => {
|
| 73 |
+
state.isInternalMessageChannelMock.mockReturnValue(true);
|
| 74 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 75 |
+
new OAuthRefreshFailureError({ provider: "openai", message: "refresh_token_reused" }),
|
| 76 |
+
);
|
| 77 |
+
const followupRun = createFollowupRun();
|
| 78 |
+
followupRun.run.messageProvider = "webchat";
|
| 79 |
+
|
| 80 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 81 |
+
const result = await executeAgentTurn(
|
| 82 |
+
createMinimalRunAgentTurnParams({
|
| 83 |
+
followupRun,
|
| 84 |
+
sessionCtx: { Provider: "webchat", MessageSid: "msg" } as unknown as TemplateContext,
|
| 85 |
+
}),
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
expect(result.kind).toBe("final");
|
| 89 |
+
if (result.kind === "final") {
|
| 90 |
+
expect(result.payload.text).toBe(
|
| 91 |
+
"⚠️ Your model provider needs a new login. Send `/login openai` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider openai` on the gateway.",
|
| 92 |
+
);
|
| 93 |
+
expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
it("surfaces gateway reauth guidance from typed OAuth refresh failures", async () => {
|
| 98 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 99 |
+
new OAuthRefreshFailureError({
|
| 100 |
+
provider: "openai",
|
| 101 |
+
profileId: "openai:user@example.com",
|
| 102 |
+
message: "invalid_grant",
|
| 103 |
+
}),
|
| 104 |
+
);
|
| 105 |
+
|
| 106 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 107 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 108 |
+
|
| 109 |
+
expect(result.kind).toBe("final");
|
| 110 |
+
if (result.kind === "final") {
|
| 111 |
+
expect(result.payload.text).toBe(
|
| 112 |
+
"⚠️ Your model provider needs a new login. Send `/login openai` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider openai --profile-id 'openai:user@example.com'` on the gateway.",
|
| 113 |
+
);
|
| 114 |
+
expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
|
| 115 |
+
}
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("preserves OAuth profile guidance through failover wrappers", async () => {
|
| 119 |
+
const refreshError = new OAuthRefreshFailureError({
|
| 120 |
+
provider: "openai",
|
| 121 |
+
profileId: "openai:user@example.com",
|
| 122 |
+
message: "invalid_grant",
|
| 123 |
+
});
|
| 124 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 125 |
+
new FailoverError("OpenAI OAuth failed", {
|
| 126 |
+
reason: "auth",
|
| 127 |
+
provider: "openai",
|
| 128 |
+
model: "gpt-5.5",
|
| 129 |
+
profileId: "openai:user@example.com",
|
| 130 |
+
authProfileFailure: { allInCooldown: false },
|
| 131 |
+
status: 401,
|
| 132 |
+
cause: refreshError,
|
| 133 |
+
}),
|
| 134 |
+
);
|
| 135 |
+
|
| 136 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 137 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 138 |
+
|
| 139 |
+
expect(result.kind).toBe("final");
|
| 140 |
+
if (result.kind === "final") {
|
| 141 |
+
expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'");
|
| 142 |
+
expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
|
| 143 |
+
}
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
it("preserves OAuth profile guidance through fallback summaries", async () => {
|
| 147 |
+
const refreshError = new OAuthRefreshFailureError({
|
| 148 |
+
provider: "openai",
|
| 149 |
+
profileId: "openai:user@example.com",
|
| 150 |
+
message: "invalid_grant",
|
| 151 |
+
});
|
| 152 |
+
const failoverError = new FailoverError("OpenAI OAuth failed", {
|
| 153 |
+
reason: "auth",
|
| 154 |
+
provider: "openai",
|
| 155 |
+
model: "gpt-5.5",
|
| 156 |
+
profileId: "openai:user@example.com",
|
| 157 |
+
authProfileFailure: { allInCooldown: false },
|
| 158 |
+
status: 401,
|
| 159 |
+
cause: refreshError,
|
| 160 |
+
});
|
| 161 |
+
const summaryError = createTestFallbackSummaryError({
|
| 162 |
+
message: "All models failed",
|
| 163 |
+
attempts: [
|
| 164 |
+
{
|
| 165 |
+
provider: "openai",
|
| 166 |
+
model: "gpt-5.5",
|
| 167 |
+
error: "OpenAI OAuth failed",
|
| 168 |
+
reason: "auth",
|
| 169 |
+
},
|
| 170 |
+
],
|
| 171 |
+
soonestCooldownExpiry: null,
|
| 172 |
+
cause: failoverError,
|
| 173 |
+
});
|
| 174 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(summaryError);
|
| 175 |
+
|
| 176 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 177 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 178 |
+
|
| 179 |
+
expect(result.kind).toBe("final");
|
| 180 |
+
if (result.kind === "final") {
|
| 181 |
+
expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'");
|
| 182 |
+
expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
|
| 183 |
+
}
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
it("omits OAuth profile ids from group reauth guidance", async () => {
|
| 187 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 188 |
+
new OAuthRefreshFailureError({
|
| 189 |
+
provider: "openai",
|
| 190 |
+
profileId: "openai:user@example.com",
|
| 191 |
+
message: "invalid_grant",
|
| 192 |
+
}),
|
| 193 |
+
);
|
| 194 |
+
|
| 195 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 196 |
+
const result = await executeAgentTurn(
|
| 197 |
+
createMinimalRunAgentTurnParams({
|
| 198 |
+
sessionCtx: {
|
| 199 |
+
Provider: "whatsapp",
|
| 200 |
+
MessageSid: "msg",
|
| 201 |
+
ChatType: "group",
|
| 202 |
+
} as unknown as TemplateContext,
|
| 203 |
+
}),
|
| 204 |
+
);
|
| 205 |
+
|
| 206 |
+
expect(result.kind).toBe("final");
|
| 207 |
+
if (result.kind === "final") {
|
| 208 |
+
expect(result.payload.text).toContain("openclaw models auth login --provider openai");
|
| 209 |
+
expect(result.payload.text).not.toContain("user@example.com");
|
| 210 |
+
expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
|
| 211 |
+
}
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
it.each([
|
| 215 |
+
["openai", "/login openai"],
|
| 216 |
+
["xai", "/login xai"],
|
| 217 |
+
["minimax-portal", "/login minimax-portal"],
|
| 218 |
+
])("keeps disabled %s OAuth profiles actionable on later turns", async (provider, command) => {
|
| 219 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 220 |
+
new FailoverError("All OpenAI auth profiles are unavailable", {
|
| 221 |
+
reason: "auth_permanent",
|
| 222 |
+
provider,
|
| 223 |
+
model: "fixture-model",
|
| 224 |
+
authMode: "oauth",
|
| 225 |
+
authProfileFailure: { allInCooldown: true },
|
| 226 |
+
}),
|
| 227 |
+
);
|
| 228 |
+
|
| 229 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 230 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 231 |
+
|
| 232 |
+
expect(result.kind).toBe("final");
|
| 233 |
+
if (result.kind === "final") {
|
| 234 |
+
expect(result.payload.text).toContain(command);
|
| 235 |
+
expect(result.payload.presentation).toEqual(providerLoginPresentation(command));
|
| 236 |
+
}
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
it.each([
|
| 240 |
+
{
|
| 241 |
+
label: "provider names without OAuth evidence",
|
| 242 |
+
error: new FailoverError("Authentication unavailable", {
|
| 243 |
+
reason: "auth_permanent",
|
| 244 |
+
provider: "some-provider",
|
| 245 |
+
authProfileFailure: { allInCooldown: true },
|
| 246 |
+
}),
|
| 247 |
+
},
|
| 248 |
+
{
|
| 249 |
+
label: "OpenAI API-key failures",
|
| 250 |
+
error: new FailoverError("invalid API key", {
|
| 251 |
+
reason: "auth_permanent",
|
| 252 |
+
provider: "openai",
|
| 253 |
+
model: "gpt-5.5",
|
| 254 |
+
authMode: "api-key",
|
| 255 |
+
authProfileFailure: { allInCooldown: true },
|
| 256 |
+
}),
|
| 257 |
+
},
|
| 258 |
+
{
|
| 259 |
+
label: "transient OpenAI refresh failures",
|
| 260 |
+
error: new OAuthRefreshFailureError({
|
| 261 |
+
provider: "openai",
|
| 262 |
+
message: "temporary upstream issue",
|
| 263 |
+
}),
|
| 264 |
+
},
|
| 265 |
+
])("does not offer provider login for $label", async ({ error }) => {
|
| 266 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(error);
|
| 267 |
+
|
| 268 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 269 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 270 |
+
|
| 271 |
+
expect(result.kind).toBe("final");
|
| 272 |
+
if (result.kind === "final") {
|
| 273 |
+
expect(result.payload.presentation).toBeUndefined();
|
| 274 |
+
}
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
it("adds provider login while retaining non-OpenAI terminal guidance", async () => {
|
| 278 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 279 |
+
new OAuthRefreshFailureError({
|
| 280 |
+
provider: "anthropic",
|
| 281 |
+
message: "invalid_grant",
|
| 282 |
+
}),
|
| 283 |
+
);
|
| 284 |
+
|
| 285 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 286 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 287 |
+
|
| 288 |
+
expect(result.kind).toBe("final");
|
| 289 |
+
if (result.kind === "final") {
|
| 290 |
+
expect(result.payload.text).toBe(
|
| 291 |
+
"⚠️ Your model provider needs a new login. Send `/login anthropic` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider anthropic` on the gateway.",
|
| 292 |
+
);
|
| 293 |
+
expect(result.payload.presentation).toEqual(providerLoginPresentation("/login anthropic"));
|
| 294 |
+
}
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
it("surfaces Agent SDK OAuth session expiry in Discord channels", async () => {
|
| 298 |
+
const error = createCliOutputFailoverError({
|
| 299 |
+
output: {
|
| 300 |
+
text: "",
|
| 301 |
+
errorText: "Failed to authenticate: OAuth session expired and could not be refreshed",
|
| 302 |
+
},
|
| 303 |
+
provider: "claude-cli",
|
| 304 |
+
model: "claude-opus-5",
|
| 305 |
+
});
|
| 306 |
+
if (!error) {
|
| 307 |
+
throw new Error("expected CLI output failure");
|
| 308 |
+
}
|
| 309 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(error);
|
| 310 |
+
|
| 311 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 312 |
+
const result = await executeAgentTurn(
|
| 313 |
+
createMinimalRunAgentTurnParams({
|
| 314 |
+
sessionCtx: {
|
| 315 |
+
Provider: "discord",
|
| 316 |
+
Surface: "discord",
|
| 317 |
+
ChatType: "channel",
|
| 318 |
+
MessageSid: "msg",
|
| 319 |
+
} as unknown as TemplateContext,
|
| 320 |
+
}),
|
| 321 |
+
);
|
| 322 |
+
|
| 323 |
+
expect(result.kind).toBe("final");
|
| 324 |
+
if (result.kind === "final") {
|
| 325 |
+
expect(result.payload.text).toBe(
|
| 326 |
+
"⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` on the gateway.",
|
| 327 |
+
);
|
| 328 |
+
expect(result.payload.presentation).toEqual(providerLoginPresentation("/login"));
|
| 329 |
+
}
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
it("surfaces claude-cli re-auth hint from structured provider metadata when the message omits claude-cli", async () => {
|
| 333 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 334 |
+
new FailoverError(
|
| 335 |
+
"Failed to authenticate. API Error: 401 Invalid authentication credentials",
|
| 336 |
+
{
|
| 337 |
+
reason: "auth",
|
| 338 |
+
provider: "claude-cli",
|
| 339 |
+
model: "claude-sonnet-4-20250514",
|
| 340 |
+
status: 401,
|
| 341 |
+
},
|
| 342 |
+
),
|
| 343 |
+
);
|
| 344 |
+
|
| 345 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 346 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 347 |
+
|
| 348 |
+
expect(result.kind).toBe("final");
|
| 349 |
+
if (result.kind === "final") {
|
| 350 |
+
expect(result.payload.text).toBe(
|
| 351 |
+
"⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` on the gateway.",
|
| 352 |
+
);
|
| 353 |
+
}
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
it("surfaces the claude-cli re-auth hint when the CLI session is logged out", async () => {
|
| 357 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 358 |
+
new FailoverError("Not logged in · Please run /login", {
|
| 359 |
+
reason: "auth",
|
| 360 |
+
provider: "claude-cli",
|
| 361 |
+
model: "claude-sonnet-4-20250514",
|
| 362 |
+
status: 401,
|
| 363 |
+
}),
|
| 364 |
+
);
|
| 365 |
+
|
| 366 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 367 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 368 |
+
|
| 369 |
+
expect(result.kind).toBe("final");
|
| 370 |
+
if (result.kind === "final") {
|
| 371 |
+
expect(result.payload.text).toBe(
|
| 372 |
+
"⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` on the gateway.",
|
| 373 |
+
);
|
| 374 |
+
}
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
it("surfaces direct provider auth guidance for missing API keys", async () => {
|
| 378 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 379 |
+
new ProviderAuthError(
|
| 380 |
+
"missing-provider-auth",
|
| 381 |
+
"openai",
|
| 382 |
+
'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth.',
|
| 383 |
+
{ providerGuidance: true },
|
| 384 |
+
),
|
| 385 |
+
);
|
| 386 |
+
|
| 387 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 388 |
+
const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
|
| 389 |
+
|
| 390 |
+
expect(result.kind).toBe("final");
|
| 391 |
+
if (result.kind === "final") {
|
| 392 |
+
expect(result.payload.text).toBe(
|
| 393 |
+
"⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-6-astra` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs.",
|
| 394 |
+
);
|
| 395 |
+
}
|
| 396 |
+
});
|
| 397 |
+
|
| 398 |
+
it("surfaces typed missing API-key auth guidance without parsing the message", async () => {
|
| 399 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 400 |
+
new MissingProviderAuthError("openai", {
|
| 401 |
+
mode: "api-key",
|
| 402 |
+
source: "env: OPENAI_API_KEY",
|
| 403 |
+
}),
|
| 404 |
+
);
|
| 405 |
+
|
| 406 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 407 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 408 |
+
|
| 409 |
+
expect(result.kind).toBe("final");
|
| 410 |
+
if (result.kind === "final") {
|
| 411 |
+
expect(result.payload.text).toBe(
|
| 412 |
+
'⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.',
|
| 413 |
+
);
|
| 414 |
+
}
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
it("formats auth-profile failover copy from typed FailoverError metadata", async () => {
|
| 418 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 419 |
+
new FailoverError("Auth profile failover exhausted for provider openai", {
|
| 420 |
+
reason: "auth",
|
| 421 |
+
provider: "openai",
|
| 422 |
+
status: 401,
|
| 423 |
+
authProfileFailure: { allInCooldown: true },
|
| 424 |
+
cause: new Error("invalid_grant"),
|
| 425 |
+
}),
|
| 426 |
+
);
|
| 427 |
+
|
| 428 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 429 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 430 |
+
|
| 431 |
+
expect(result.kind).toBe("final");
|
| 432 |
+
if (result.kind === "final") {
|
| 433 |
+
expect(result.payload.text).toContain("Couldn't sign in to openai.");
|
| 434 |
+
expect(result.payload.text).toContain("openclaw configure");
|
| 435 |
+
expect(result.payload.text).toContain("(invalid_grant)");
|
| 436 |
+
expect(result.payload.text).not.toContain("Auth profile failover exhausted");
|
| 437 |
+
}
|
| 438 |
+
});
|
| 439 |
+
|
| 440 |
+
it("renders bounded recovery when the selected auth profile is unavailable", async () => {
|
| 441 |
+
state.isInternalMessageChannelMock.mockReturnValue(true);
|
| 442 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 443 |
+
new FailoverError('Codex app-server auth profile "openai:private" was not found', {
|
| 444 |
+
reason: "auth",
|
| 445 |
+
provider: "openai",
|
| 446 |
+
status: 401,
|
| 447 |
+
code: "selected_auth_profile_unavailable",
|
| 448 |
+
authProfileFailure: { allInCooldown: false },
|
| 449 |
+
cause: new Error("arbitrary plugin detail for openai:private"),
|
| 450 |
+
}),
|
| 451 |
+
);
|
| 452 |
+
|
| 453 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 454 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 455 |
+
|
| 456 |
+
expect(result.kind).toBe("final");
|
| 457 |
+
if (result.kind === "final") {
|
| 458 |
+
expect(result.payload.text).toBe(
|
| 459 |
+
"The selected auth profile is unavailable in this agent's OpenClaw credential store. Import or migrate that credential into the agent, select another configured profile, or run `openclaw configure`, then retry.",
|
| 460 |
+
);
|
| 461 |
+
expect(result.payload.text).not.toContain("openai:private");
|
| 462 |
+
expect(result.payload.text).not.toContain("arbitrary plugin detail");
|
| 463 |
+
expect(result.payload.text).not.toContain("/login");
|
| 464 |
+
expect(result.payload.presentation).toBeUndefined();
|
| 465 |
+
}
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
it("does not suggest re-authentication for typed format failures", async () => {
|
| 469 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 470 |
+
new FailoverError("Format failover exhausted for provider openai", {
|
| 471 |
+
reason: "format",
|
| 472 |
+
provider: "openai",
|
| 473 |
+
authProfileFailure: { allInCooldown: true },
|
| 474 |
+
cause: new Error("messages must alternate roles"),
|
| 475 |
+
}),
|
| 476 |
+
);
|
| 477 |
+
|
| 478 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 479 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 480 |
+
|
| 481 |
+
expect(result.kind).toBe("final");
|
| 482 |
+
if (result.kind === "final") {
|
| 483 |
+
expect(result.payload.text).toContain("Couldn't reach openai");
|
| 484 |
+
expect(result.payload.text).toContain("messages must alternate roles");
|
| 485 |
+
expect(result.payload.text).not.toContain("models auth login");
|
| 486 |
+
expect(result.payload.text).not.toContain("openclaw configure");
|
| 487 |
+
}
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
it("points stale openai missing-key failures at doctor repair with re-auth fallback", async () => {
|
| 491 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 492 |
+
new ProviderAuthError(
|
| 493 |
+
"missing-provider-auth",
|
| 494 |
+
"openai",
|
| 495 |
+
'No API key found for provider "openai".',
|
| 496 |
+
),
|
| 497 |
+
);
|
| 498 |
+
|
| 499 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 500 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
|
| 501 |
+
|
| 502 |
+
expect(result.kind).toBe("final");
|
| 503 |
+
if (result.kind === "final") {
|
| 504 |
+
expect(result.payload.text).toBe(
|
| 505 |
+
'⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.',
|
| 506 |
+
);
|
| 507 |
+
}
|
| 508 |
+
});
|
| 509 |
+
|
| 510 |
+
it("falls back to a generic provider message for unsafe missing-key provider ids", async () => {
|
| 511 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 512 |
+
new ProviderAuthError(
|
| 513 |
+
"missing-provider-auth",
|
| 514 |
+
"openai`\nrm -rf /",
|
| 515 |
+
'No API key found for provider "openai`\nrm -rf /".',
|
| 516 |
+
),
|
| 517 |
+
);
|
| 518 |
+
|
| 519 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 520 |
+
const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
|
| 521 |
+
|
| 522 |
+
expect(result.kind).toBe("final");
|
| 523 |
+
if (result.kind === "final") {
|
| 524 |
+
expect(result.payload.text).toBe(
|
| 525 |
+
"⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.",
|
| 526 |
+
);
|
| 527 |
+
}
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
it("falls back to a generic reauth command when the provider in the OAuth error is unsafe", async () => {
|
| 531 |
+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
| 532 |
+
new OAuthRefreshFailureError({ provider: "openai`\nrm -rf /", message: "invalid_grant" }),
|
| 533 |
+
);
|
| 534 |
+
|
| 535 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 536 |
+
const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
|
| 537 |
+
|
| 538 |
+
expect(result.kind).toBe("final");
|
| 539 |
+
if (result.kind === "final") {
|
| 540 |
+
expect(result.payload.text).toBe(
|
| 541 |
+
"⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login` on the gateway.",
|
| 542 |
+
);
|
| 543 |
+
expect(result.payload.presentation).toEqual(providerLoginPresentation("/login"));
|
| 544 |
+
}
|
| 545 |
+
});
|
| 546 |
+
});
|
src/auto-reply/reply/agent-runner-execution-cli-admission.test.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { withTestRunAdmission } from "../../agents/admitted-run-context.test-support.js";
|
| 3 |
+
import { buildPreparedCliRunContext } from "../../agents/cli-runner.test-helpers.js";
|
| 4 |
+
import { buildCliRunResult } from "../../agents/cli-runner/cli-run-settlement.js";
|
| 5 |
+
import { executeDeps } from "../../agents/cli-runner/execute-deps.js";
|
| 6 |
+
import { executePreparedCliRun } from "../../agents/cli-runner/execute.js";
|
| 7 |
+
import { buildCliMcpGrantContext } from "../../agents/cli-runner/mcp-grant-context.js";
|
| 8 |
+
import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
|
| 9 |
+
import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../../agents/failover/user-copy.js";
|
| 10 |
+
import { installSessionPlacementAdmissionProvider } from "../../agents/session-placement-admission.js";
|
| 11 |
+
import type { SessionEntry } from "../../config/sessions.js";
|
| 12 |
+
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
|
| 13 |
+
import {
|
| 14 |
+
setupAgentRunnerExecutionTestState,
|
| 15 |
+
getExecuteAgentTurnForTest,
|
| 16 |
+
createFollowupRun,
|
| 17 |
+
requireMockCall,
|
| 18 |
+
expectMockCallArgFields,
|
| 19 |
+
initialFallbackAttemptOptions,
|
| 20 |
+
fallbackAttemptOptions,
|
| 21 |
+
createMinimalRunAgentTurnParams,
|
| 22 |
+
makeTestSessionStorePath,
|
| 23 |
+
} from "./agent-runner-execution.test-support.js";
|
| 24 |
+
import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
|
| 25 |
+
|
| 26 |
+
const state = await setupAgentRunnerExecutionTestState();
|
| 27 |
+
|
| 28 |
+
function rejectUnexpectedCompactionSuccessor(): never {
|
| 29 |
+
throw new Error("Unexpected compaction successor during CLI admission test");
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
describe("executeAgentTurn: CLI admission", () => {
|
| 33 |
+
it.each([
|
| 34 |
+
"ordinary",
|
| 35 |
+
"heartbeat",
|
| 36 |
+
"preserved",
|
| 37 |
+
"revised",
|
| 38 |
+
"revision-established",
|
| 39 |
+
"rejected",
|
| 40 |
+
"rejected-clear",
|
| 41 |
+
])("settles the %s reply's native binding before releasing placement", async (kind) => {
|
| 42 |
+
const sessionKey = "agent:main:cli-binding-settlement";
|
| 43 |
+
const storePath = makeTestSessionStorePath();
|
| 44 |
+
const entry: SessionEntry = {
|
| 45 |
+
sessionId: "session",
|
| 46 |
+
updatedAt: 1,
|
| 47 |
+
...(kind === "revised" ? { lifecycleRevision: "original" } : {}),
|
| 48 |
+
};
|
| 49 |
+
const rejected = kind === "rejected" || kind === "rejected-clear";
|
| 50 |
+
const revisionChanged = kind === "revised" || kind === "revision-established";
|
| 51 |
+
const binding = { sessionId: "admitted-native-session", authProfileId: "anthropic:cli" };
|
| 52 |
+
const settledBinding = { ...binding, sessionId: "settled-native-session" };
|
| 53 |
+
await replaceSessionEntry(
|
| 54 |
+
{ sessionKey, storePath },
|
| 55 |
+
{
|
| 56 |
+
...entry,
|
| 57 |
+
cliSessionBindings: { "claude-cli": binding },
|
| 58 |
+
},
|
| 59 |
+
);
|
| 60 |
+
const followupRun = createFollowupRun();
|
| 61 |
+
followupRun.run.provider = "claude-cli";
|
| 62 |
+
followupRun.run.model = "claude-sonnet-4-6";
|
| 63 |
+
if (kind === "preserved") {
|
| 64 |
+
followupRun.run.inputProvenance = {
|
| 65 |
+
kind: "inter_session",
|
| 66 |
+
sourceTool: "exec_approval_followup",
|
| 67 |
+
};
|
| 68 |
+
}
|
| 69 |
+
state.isCliProviderMock.mockImplementation((provider) => provider === "claude-cli");
|
| 70 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
|
| 71 |
+
const first = await params.run(
|
| 72 |
+
"claude-cli",
|
| 73 |
+
"claude-sonnet-4-6",
|
| 74 |
+
initialFallbackAttemptOptions(params),
|
| 75 |
+
);
|
| 76 |
+
if (rejected) {
|
| 77 |
+
expect(first).toMatchObject({
|
| 78 |
+
classification: { code: "generic_external_run_failure" },
|
| 79 |
+
});
|
| 80 |
+
return {
|
| 81 |
+
result: await params.run("openai", "gpt-5.4", fallbackAttemptOptions(params, "format")),
|
| 82 |
+
provider: "openai",
|
| 83 |
+
model: "gpt-5.4",
|
| 84 |
+
attempts: [{ provider: "claude-cli", model: "claude-sonnet-4-6", reason: "format" }],
|
| 85 |
+
};
|
| 86 |
+
}
|
| 87 |
+
return {
|
| 88 |
+
result: first,
|
| 89 |
+
provider: "claude-cli",
|
| 90 |
+
model: "claude-sonnet-4-6",
|
| 91 |
+
attempts: [],
|
| 92 |
+
};
|
| 93 |
+
});
|
| 94 |
+
const candidateResult = {
|
| 95 |
+
payloads: [{ text: "done" }],
|
| 96 |
+
meta: {
|
| 97 |
+
agentMeta: { sessionId: settledBinding.sessionId, cliSessionBinding: settledBinding },
|
| 98 |
+
},
|
| 99 |
+
};
|
| 100 |
+
state.runCliAgentMock.mockResolvedValueOnce(
|
| 101 |
+
rejected
|
| 102 |
+
? buildCliRunResult({
|
| 103 |
+
context: buildPreparedCliRunContext(),
|
| 104 |
+
output: { text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT },
|
| 105 |
+
effectiveCliSessionId: settledBinding.sessionId,
|
| 106 |
+
bindingFlushOk: kind !== "rejected-clear",
|
| 107 |
+
usedHistoryPrompt: false,
|
| 108 |
+
userTurnHandled: true,
|
| 109 |
+
sessionBindingDisabled: false,
|
| 110 |
+
preparedContextAgentMeta: {},
|
| 111 |
+
})
|
| 112 |
+
: candidateResult,
|
| 113 |
+
);
|
| 114 |
+
state.runEmbeddedAgentMock.mockResolvedValueOnce({
|
| 115 |
+
payloads: [{ text: "fallback done" }],
|
| 116 |
+
meta: {},
|
| 117 |
+
});
|
| 118 |
+
let observedBinding: unknown;
|
| 119 |
+
const uninstall = installSessionPlacementAdmissionProvider({
|
| 120 |
+
assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
|
| 121 |
+
executeLocalTurn: async (_claim, runLocal) => {
|
| 122 |
+
if (revisionChanged) {
|
| 123 |
+
// Mutating the prepared object must not change the captured admission revision.
|
| 124 |
+
entry.lifecycleRevision = "replacement";
|
| 125 |
+
await replaceSessionEntry(
|
| 126 |
+
{ sessionKey, storePath },
|
| 127 |
+
{
|
| 128 |
+
...entry,
|
| 129 |
+
cliSessionBindings: { "claude-cli": binding },
|
| 130 |
+
},
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
const result = await runLocal();
|
| 134 |
+
observedBinding = loadSessionEntry({ sessionKey, storePath })?.cliSessionBindings?.[
|
| 135 |
+
"claude-cli"
|
| 136 |
+
];
|
| 137 |
+
return result;
|
| 138 |
+
},
|
| 139 |
+
executeTurn: async (_claim, _params, runLocal) => await runLocal(),
|
| 140 |
+
});
|
| 141 |
+
try {
|
| 142 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 143 |
+
const result = await executeAgentTurn({
|
| 144 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 145 |
+
sessionKey,
|
| 146 |
+
storePath,
|
| 147 |
+
isHeartbeat: kind === "heartbeat",
|
| 148 |
+
activeSessionStore: { [sessionKey]: entry },
|
| 149 |
+
getActiveSessionEntry: () => entry,
|
| 150 |
+
});
|
| 151 |
+
if (revisionChanged) {
|
| 152 |
+
expect(result.kind).toBe("final");
|
| 153 |
+
expect(state.runCliAgentMock).not.toHaveBeenCalled();
|
| 154 |
+
expect(
|
| 155 |
+
loadSessionEntry({ sessionKey, storePath })?.cliSessionBindings?.["claude-cli"],
|
| 156 |
+
).toEqual(binding);
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
expect(result.kind).toBe("success");
|
| 160 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 161 |
+
cliSessionId: binding.sessionId,
|
| 162 |
+
cliSessionBinding: binding,
|
| 163 |
+
});
|
| 164 |
+
expect(observedBinding).toEqual(
|
| 165 |
+
kind === "rejected-clear"
|
| 166 |
+
? undefined
|
| 167 |
+
: kind === "preserved" || rejected
|
| 168 |
+
? binding
|
| 169 |
+
: settledBinding,
|
| 170 |
+
);
|
| 171 |
+
} finally {
|
| 172 |
+
uninstall();
|
| 173 |
+
}
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
it.each(["ordinary", "rejected-clear", "room-event"] as const)(
|
| 177 |
+
"retains the %s reply after its continuity write loses ownership",
|
| 178 |
+
async (kind) => {
|
| 179 |
+
const sessionKey = "agent:main:cli-owner-loss";
|
| 180 |
+
const storePath = makeTestSessionStorePath();
|
| 181 |
+
const binding = { sessionId: "previous-native-session" };
|
| 182 |
+
const entry: SessionEntry = {
|
| 183 |
+
sessionId: "session",
|
| 184 |
+
updatedAt: 1,
|
| 185 |
+
cliSessionBindings: { "claude-cli": binding },
|
| 186 |
+
};
|
| 187 |
+
await replaceSessionEntry({ sessionKey, storePath }, entry);
|
| 188 |
+
const followupRun = createFollowupRun();
|
| 189 |
+
followupRun.run.provider = "claude-cli";
|
| 190 |
+
followupRun.run.model = "claude-sonnet-4-6";
|
| 191 |
+
if (kind === "room-event") {
|
| 192 |
+
followupRun.currentInboundEventKind = "room_event";
|
| 193 |
+
}
|
| 194 |
+
state.isCliProviderMock.mockImplementation((provider) => provider === "claude-cli");
|
| 195 |
+
const candidate = buildCliRunResult({
|
| 196 |
+
context: buildPreparedCliRunContext(),
|
| 197 |
+
output: {
|
| 198 |
+
text: kind === "rejected-clear" ? GENERIC_EXTERNAL_RUN_FAILURE_TEXT : "Captured reply",
|
| 199 |
+
},
|
| 200 |
+
effectiveCliSessionId: "replacement-native-session",
|
| 201 |
+
bindingFlushOk: kind !== "rejected-clear",
|
| 202 |
+
usedHistoryPrompt: false,
|
| 203 |
+
userTurnHandled: true,
|
| 204 |
+
sessionBindingDisabled: false,
|
| 205 |
+
preparedContextAgentMeta: {},
|
| 206 |
+
});
|
| 207 |
+
const provider: Parameters<typeof installSessionPlacementAdmissionProvider>[0] = {
|
| 208 |
+
assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
|
| 209 |
+
executeLocalTurn: async (_claim, runLocal) => await runLocal(),
|
| 210 |
+
executeTurn: async (_claim, _params, runLocal) => await runLocal(),
|
| 211 |
+
};
|
| 212 |
+
const uninstall = installSessionPlacementAdmissionProvider(provider);
|
| 213 |
+
let uninstallReplacement: (() => void) | undefined;
|
| 214 |
+
state.runCliAgentMock.mockImplementationOnce(async () => {
|
| 215 |
+
uninstallReplacement = installSessionPlacementAdmissionProvider({ ...provider });
|
| 216 |
+
return candidate;
|
| 217 |
+
});
|
| 218 |
+
state.runWithModelFallbackMock.mockImplementationOnce(
|
| 219 |
+
async (params: FallbackRunnerParams) => {
|
| 220 |
+
const result = await params.run(
|
| 221 |
+
"claude-cli",
|
| 222 |
+
"claude-sonnet-4-6",
|
| 223 |
+
initialFallbackAttemptOptions(params),
|
| 224 |
+
);
|
| 225 |
+
expect(result).toMatchObject({
|
| 226 |
+
classification: null,
|
| 227 |
+
result: {
|
| 228 |
+
payloads: expect.arrayContaining(candidate.payloads ?? []),
|
| 229 |
+
meta: {
|
| 230 |
+
replayInvalid: true,
|
| 231 |
+
error: {
|
| 232 |
+
message: expect.stringContaining("CLI session continuity could not be saved"),
|
| 233 |
+
fallbackSafe: false,
|
| 234 |
+
},
|
| 235 |
+
},
|
| 236 |
+
},
|
| 237 |
+
});
|
| 238 |
+
return { result, provider: "claude-cli", model: "claude-sonnet-4-6", attempts: [] };
|
| 239 |
+
},
|
| 240 |
+
);
|
| 241 |
+
try {
|
| 242 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 243 |
+
await executeAgentTurn({
|
| 244 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 245 |
+
sessionKey,
|
| 246 |
+
storePath,
|
| 247 |
+
activeSessionStore: { [sessionKey]: entry },
|
| 248 |
+
getActiveSessionEntry: () => entry,
|
| 249 |
+
});
|
| 250 |
+
expect(state.runCliAgentMock).toHaveBeenCalledOnce();
|
| 251 |
+
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
|
| 252 |
+
expect(
|
| 253 |
+
loadSessionEntry({ sessionKey, storePath })?.cliSessionBindings?.["claude-cli"],
|
| 254 |
+
).toEqual(binding);
|
| 255 |
+
} finally {
|
| 256 |
+
uninstallReplacement?.();
|
| 257 |
+
uninstall();
|
| 258 |
+
}
|
| 259 |
+
},
|
| 260 |
+
);
|
| 261 |
+
|
| 262 |
+
it("carries the admitted session permission and placement into the CLI grant", async () => {
|
| 263 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 264 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 265 |
+
result: await params.run(
|
| 266 |
+
"claude-cli",
|
| 267 |
+
"claude-sonnet-4-6",
|
| 268 |
+
initialFallbackAttemptOptions(params),
|
| 269 |
+
),
|
| 270 |
+
provider: "claude-cli",
|
| 271 |
+
model: "claude-sonnet-4-6",
|
| 272 |
+
attempts: [],
|
| 273 |
+
}));
|
| 274 |
+
let sessionEntry: SessionEntry = {
|
| 275 |
+
sessionId: "session",
|
| 276 |
+
updatedAt: 1,
|
| 277 |
+
permissionMode: "guarded",
|
| 278 |
+
sessionRoot: "/workspace/old",
|
| 279 |
+
execHost: "gateway",
|
| 280 |
+
cliSessionBindings: {
|
| 281 |
+
"claude-cli": { sessionId: "old-native-session", forceReuse: true },
|
| 282 |
+
},
|
| 283 |
+
};
|
| 284 |
+
const admittedSessionEntry: SessionEntry = {
|
| 285 |
+
sessionId: "session",
|
| 286 |
+
updatedAt: 2,
|
| 287 |
+
permissionMode: "read-only",
|
| 288 |
+
sessionRoot: "/workspace/project",
|
| 289 |
+
execHost: "node",
|
| 290 |
+
execNode: "node-a",
|
| 291 |
+
execCwd: "/workspace/project/task",
|
| 292 |
+
cliSessionBindings: {
|
| 293 |
+
"claude-cli": { sessionId: "new-native-session", forceReuse: true },
|
| 294 |
+
},
|
| 295 |
+
};
|
| 296 |
+
state.runCliAgentMock.mockResolvedValueOnce({ payloads: [{ text: "done" }], meta: {} });
|
| 297 |
+
const followupRun = createFollowupRun();
|
| 298 |
+
followupRun.run.provider = "claude-cli";
|
| 299 |
+
followupRun.run.model = "claude-sonnet-4-6";
|
| 300 |
+
const restoreAdmission = installSessionPlacementAdmissionProvider({
|
| 301 |
+
assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
|
| 302 |
+
executeLocalTurn: async (_claim, runLocal) => {
|
| 303 |
+
sessionEntry = admittedSessionEntry;
|
| 304 |
+
return await runLocal();
|
| 305 |
+
},
|
| 306 |
+
executeTurn: async (_claim, _params, runLocal) => await runLocal(),
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
try {
|
| 310 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 311 |
+
const result = await executeAgentTurn({
|
| 312 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 313 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 314 |
+
});
|
| 315 |
+
|
| 316 |
+
expect(result.kind).toBe("success");
|
| 317 |
+
const run = requireMockCall(
|
| 318 |
+
state.runCliAgentMock,
|
| 319 |
+
0,
|
| 320 |
+
"CLI run params",
|
| 321 |
+
)[0] as RunCliAgentParams;
|
| 322 |
+
expect(run.sessionEntry).toBe(admittedSessionEntry);
|
| 323 |
+
expect(run.sessionEntry?.sessionRoot).toBe("/workspace/project");
|
| 324 |
+
expect(run.cliSessionId).toBe("new-native-session");
|
| 325 |
+
expect(run.cliSessionBinding).toMatchObject({
|
| 326 |
+
sessionId: "new-native-session",
|
| 327 |
+
forceReuse: true,
|
| 328 |
+
});
|
| 329 |
+
const observedCliSessionId = run.cliSessionBinding?.sessionId ?? run.cliSessionId;
|
| 330 |
+
expect(observedCliSessionId).toBe("new-native-session");
|
| 331 |
+
if (!observedCliSessionId) {
|
| 332 |
+
throw new Error("expected admitted CLI session binding");
|
| 333 |
+
}
|
| 334 |
+
expect(
|
| 335 |
+
buildCliMcpGrantContext({
|
| 336 |
+
run,
|
| 337 |
+
config: run.config ?? {},
|
| 338 |
+
requireExplicitMessageTarget: false,
|
| 339 |
+
agentId: "main",
|
| 340 |
+
modelProvider: "anthropic",
|
| 341 |
+
modelId: "claude-sonnet-4-6",
|
| 342 |
+
}).execSession,
|
| 343 |
+
).toMatchObject({
|
| 344 |
+
permissionMode: "read-only",
|
| 345 |
+
execHost: "node",
|
| 346 |
+
execNode: "node-a",
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
const nodeInvoke = vi.fn<typeof executeDeps.invokeNodeClaudeCliRun>(async (request) => {
|
| 350 |
+
expect(request.nodeId).toBe("node-a");
|
| 351 |
+
expect(request.argv).toContain("new-native-session");
|
| 352 |
+
expect(request.argv).not.toContain("old-native-session");
|
| 353 |
+
return {
|
| 354 |
+
ok: true,
|
| 355 |
+
payloadJSON: JSON.stringify({ exitCode: 0, stderrTail: "", truncated: false }),
|
| 356 |
+
};
|
| 357 |
+
});
|
| 358 |
+
const restoreNodeInvoke = executeDeps.invokeNodeClaudeCliRun;
|
| 359 |
+
const backend = {
|
| 360 |
+
command: "claude",
|
| 361 |
+
args: ["-p"],
|
| 362 |
+
resumeArgs: ["--resume", "{sessionId}"],
|
| 363 |
+
output: "text" as const,
|
| 364 |
+
input: "stdin" as const,
|
| 365 |
+
serialize: true,
|
| 366 |
+
};
|
| 367 |
+
const prepared = buildPreparedCliRunContext({
|
| 368 |
+
provider: "claude-cli",
|
| 369 |
+
model: run.model,
|
| 370 |
+
runId: run.runId,
|
| 371 |
+
workspaceDir: run.workspaceDir,
|
| 372 |
+
config: run.config,
|
| 373 |
+
sessionEntry: run.sessionEntry,
|
| 374 |
+
backend,
|
| 375 |
+
});
|
| 376 |
+
prepared.params = {
|
| 377 |
+
...run,
|
| 378 |
+
admittedRunContext: prepared.params.admittedRunContext,
|
| 379 |
+
skillsSnapshot: undefined,
|
| 380 |
+
};
|
| 381 |
+
prepared.cwd = run.cwd;
|
| 382 |
+
prepared.reusableCliSession = { mode: "reuse", sessionId: observedCliSessionId };
|
| 383 |
+
executeDeps.invokeNodeClaudeCliRun = nodeInvoke;
|
| 384 |
+
try {
|
| 385 |
+
await withTestRunAdmission(prepared.params, async (admittedRunContext) => {
|
| 386 |
+
prepared.params.admittedRunContext = admittedRunContext;
|
| 387 |
+
await executePreparedCliRun(prepared, observedCliSessionId);
|
| 388 |
+
});
|
| 389 |
+
} finally {
|
| 390 |
+
executeDeps.invokeNodeClaudeCliRun = restoreNodeInvoke;
|
| 391 |
+
}
|
| 392 |
+
expect(nodeInvoke).toHaveBeenCalledOnce();
|
| 393 |
+
} finally {
|
| 394 |
+
restoreAdmission();
|
| 395 |
+
}
|
| 396 |
+
});
|
| 397 |
+
});
|
src/auto-reply/reply/agent-runner-execution-cli-auth.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from "node:path";
|
| 2 |
+
import { describe, expect, it, onTestFinished } from "vitest";
|
| 3 |
+
import { saveAuthProfileStore } from "../../agents/auth-profiles/store-runtime.js";
|
| 4 |
+
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
|
| 5 |
+
import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
|
| 6 |
+
import { closeOpenClawAgentDatabaseByPath } from "../../state/openclaw-agent-db.js";
|
| 7 |
+
import {
|
| 8 |
+
createFollowupRun,
|
| 9 |
+
createMinimalRunAgentTurnParams,
|
| 10 |
+
expectMockCallArgFields,
|
| 11 |
+
fallbackAttemptOptions,
|
| 12 |
+
getExecuteAgentTurnForTest,
|
| 13 |
+
setupAgentRunnerExecutionTestState,
|
| 14 |
+
type FallbackRunnerParams,
|
| 15 |
+
} from "./agent-runner-execution.test-support.js";
|
| 16 |
+
|
| 17 |
+
const state = await setupAgentRunnerExecutionTestState();
|
| 18 |
+
const managedProfile = "claude-cli:managed";
|
| 19 |
+
const canonicalProfile = "anthropic:managed";
|
| 20 |
+
const primaryProfile = "openai:primary";
|
| 21 |
+
const googleProfile = "google:managed";
|
| 22 |
+
const nativeProfile = "anthropic:claude-cli";
|
| 23 |
+
const customProfile = "custom-cli:managed";
|
| 24 |
+
const credentials = {
|
| 25 |
+
[managedProfile]: { type: "token", provider: "claude-cli", token: "synthetic-managed-token" },
|
| 26 |
+
[canonicalProfile]: { type: "token", provider: "anthropic", token: "synthetic-canonical-token" },
|
| 27 |
+
[primaryProfile]: { type: "api_key", provider: "openai", key: "synthetic-primary-key" },
|
| 28 |
+
[googleProfile]: { type: "api_key", provider: "google", key: "synthetic-google-key" },
|
| 29 |
+
[nativeProfile]: { type: "token", provider: "claude-cli", token: "synthetic-native-token" },
|
| 30 |
+
[customProfile]: { type: "token", provider: "custom-cli", token: "synthetic-custom-token" },
|
| 31 |
+
} satisfies Record<string, AuthProfileCredential>;
|
| 32 |
+
|
| 33 |
+
describe("executeAgentTurn: CLI credential selection", () => {
|
| 34 |
+
it.each([
|
| 35 |
+
{
|
| 36 |
+
name: "selects ordered CLI credentials after an automatic cross-provider fallback",
|
| 37 |
+
selected: primaryProfile,
|
| 38 |
+
source: "auto",
|
| 39 |
+
primary: "openai",
|
| 40 |
+
provider: "claude-cli",
|
| 41 |
+
backend: "claude-cli",
|
| 42 |
+
profiles: [primaryProfile, managedProfile],
|
| 43 |
+
expected: managedProfile,
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
name: "rejects an incompatible explicit account before CLI execution",
|
| 47 |
+
selected: primaryProfile,
|
| 48 |
+
source: "user",
|
| 49 |
+
primary: "openai",
|
| 50 |
+
provider: "claude-cli",
|
| 51 |
+
backend: "claude-cli",
|
| 52 |
+
profiles: [primaryProfile, managedProfile],
|
| 53 |
+
error: 'cannot use auth profile "openai:primary"',
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
name: "forwards explicitly selected canonical credentials",
|
| 57 |
+
selected: canonicalProfile,
|
| 58 |
+
source: "user",
|
| 59 |
+
primary: "anthropic",
|
| 60 |
+
provider: "claude-cli",
|
| 61 |
+
backend: "claude-cli",
|
| 62 |
+
profiles: [canonicalProfile, managedProfile],
|
| 63 |
+
expected: canonicalProfile,
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
name: "preserves a selected native login even when a managed account exists",
|
| 67 |
+
selected: nativeProfile,
|
| 68 |
+
source: "user",
|
| 69 |
+
primary: "claude-cli",
|
| 70 |
+
provider: "claude-cli",
|
| 71 |
+
backend: "claude-cli",
|
| 72 |
+
profiles: [nativeProfile, managedProfile],
|
| 73 |
+
expected: undefined,
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
name: "uses the executing Google candidate for canonical API-key fallback",
|
| 77 |
+
selected: primaryProfile,
|
| 78 |
+
source: "auto",
|
| 79 |
+
primary: "openai",
|
| 80 |
+
provider: "google",
|
| 81 |
+
backend: "google-gemini-cli",
|
| 82 |
+
profiles: [primaryProfile, googleProfile],
|
| 83 |
+
expected: googleProfile,
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
name: "retains existing scoped forwarding for a custom CLI backend",
|
| 87 |
+
selected: customProfile,
|
| 88 |
+
source: "user",
|
| 89 |
+
primary: "custom-cli",
|
| 90 |
+
provider: "custom-cli",
|
| 91 |
+
backend: "custom-cli",
|
| 92 |
+
profiles: [customProfile],
|
| 93 |
+
expected: customProfile,
|
| 94 |
+
},
|
| 95 |
+
] as const)("$name", async (testCase) => {
|
| 96 |
+
const followupRun = createFollowupRun();
|
| 97 |
+
onTestFinished(() => {
|
| 98 |
+
closeOpenClawAgentDatabaseByPath(
|
| 99 |
+
path.join(followupRun.run.agentDir, "openclaw-agent.sqlite"),
|
| 100 |
+
);
|
| 101 |
+
});
|
| 102 |
+
const model = "test-model";
|
| 103 |
+
followupRun.run.provider = testCase.primary;
|
| 104 |
+
followupRun.run.model = model;
|
| 105 |
+
followupRun.run.authProfileId = testCase.selected;
|
| 106 |
+
followupRun.run.authProfileIdSource = testCase.source;
|
| 107 |
+
followupRun.run.thinkingCatalog = [{ provider: testCase.provider, id: model, input: ["text"] }];
|
| 108 |
+
const profiles = Object.fromEntries(testCase.profiles.map((id) => [id, credentials[id]]));
|
| 109 |
+
followupRun.run.config = {
|
| 110 |
+
auth: {
|
| 111 |
+
order: Object.fromEntries(testCase.profiles.map((id) => [credentials[id].provider, [id]])),
|
| 112 |
+
},
|
| 113 |
+
agents: {
|
| 114 |
+
defaults: {
|
| 115 |
+
models: { [`${testCase.provider}/${model}`]: { agentRuntime: { id: testCase.backend } } },
|
| 116 |
+
},
|
| 117 |
+
},
|
| 118 |
+
};
|
| 119 |
+
saveAuthProfileStore({ version: 1, profiles }, followupRun.run.agentDir, {
|
| 120 |
+
filterExternalAuthProfiles: false,
|
| 121 |
+
syncExternalCli: false,
|
| 122 |
+
});
|
| 123 |
+
cliBackendsTesting.setDepsForTest({
|
| 124 |
+
resolveRuntimeCliBackends: () => [
|
| 125 |
+
{
|
| 126 |
+
id: "claude-cli",
|
| 127 |
+
modelProvider: "anthropic",
|
| 128 |
+
pluginId: "anthropic",
|
| 129 |
+
config: { command: "claude" },
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
id: "google-gemini-cli",
|
| 133 |
+
modelProvider: "google",
|
| 134 |
+
pluginId: "google",
|
| 135 |
+
config: { command: "gemini" },
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
id: "custom-cli",
|
| 139 |
+
modelProvider: "custom-cli",
|
| 140 |
+
pluginId: "custom",
|
| 141 |
+
config: { command: "custom" },
|
| 142 |
+
},
|
| 143 |
+
],
|
| 144 |
+
resolvePluginSetupCliBackend: () => undefined,
|
| 145 |
+
});
|
| 146 |
+
state.isCliProviderMock.mockImplementation((provider) => provider === testCase.backend);
|
| 147 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 148 |
+
result: await params.run(
|
| 149 |
+
testCase.provider,
|
| 150 |
+
model,
|
| 151 |
+
fallbackAttemptOptions(params, "rate_limit"),
|
| 152 |
+
),
|
| 153 |
+
provider: testCase.provider,
|
| 154 |
+
model,
|
| 155 |
+
attempts: [],
|
| 156 |
+
}));
|
| 157 |
+
state.runCliAgentMock.mockResolvedValueOnce({ payloads: [{ text: "done" }], meta: {} });
|
| 158 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 159 |
+
const result = executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun }));
|
| 160 |
+
if ("error" in testCase) {
|
| 161 |
+
expect(await result).toMatchObject({ kind: "final", payload: { isError: true } });
|
| 162 |
+
expect(state.runCliAgentMock).not.toHaveBeenCalled();
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
expect((await result).kind).toBe("success");
|
| 166 |
+
expect(state.runCliAgentMock).toHaveBeenCalledOnce();
|
| 167 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI credential handoff", {
|
| 168 |
+
provider: testCase.backend,
|
| 169 |
+
authProfileId: testCase.expected,
|
| 170 |
+
});
|
| 171 |
+
});
|
| 172 |
+
});
|
src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
|
| 3 |
+
import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
|
| 4 |
+
import type { TemplateContext } from "../templating.js";
|
| 5 |
+
import type { GetReplyOptions } from "../types.js";
|
| 6 |
+
import {
|
| 7 |
+
createFollowupRun,
|
| 8 |
+
initialFallbackAttemptOptions,
|
| 9 |
+
createMockTypingSignaler,
|
| 10 |
+
getExecuteAgentTurnForTest,
|
| 11 |
+
loadActualRunCliAgentForTest,
|
| 12 |
+
setupAgentRunnerExecutionTestState,
|
| 13 |
+
} from "./agent-runner-execution.test-support.js";
|
| 14 |
+
import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
|
| 15 |
+
|
| 16 |
+
const state = await setupAgentRunnerExecutionTestState();
|
| 17 |
+
|
| 18 |
+
const scriptedCliProgram = String.raw`
|
| 19 |
+
let input = "";
|
| 20 |
+
process.stdin.setEncoding("utf8");
|
| 21 |
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
| 22 |
+
process.stdin.on("end", () => {
|
| 23 |
+
if (!input.trim()) process.exit(2);
|
| 24 |
+
const events = [
|
| 25 |
+
{ type: "init", session_id: "scripted-commentary" },
|
| 26 |
+
{
|
| 27 |
+
type: "stream_event",
|
| 28 |
+
event: {
|
| 29 |
+
type: "content_block_delta",
|
| 30 |
+
delta: { type: "text_delta", text: "The subprocess findings are durable." },
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
type: "stream_event",
|
| 35 |
+
event: {
|
| 36 |
+
type: "content_block_start",
|
| 37 |
+
index: 1,
|
| 38 |
+
content_block: { type: "tool_use", id: "tool-scripted", name: "Read", input: {} },
|
| 39 |
+
},
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
type: "stream_event",
|
| 43 |
+
event: {
|
| 44 |
+
type: "content_block_delta",
|
| 45 |
+
delta: { type: "text_delta", text: "Subprocess final answer." },
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
{ type: "stream_event", event: { type: "message_stop" } },
|
| 49 |
+
{ type: "result", session_id: "scripted-commentary", result: "Subprocess final answer." },
|
| 50 |
+
];
|
| 51 |
+
process.stdout.write(events.map((event) => JSON.stringify(event)).join("\n") + "\n");
|
| 52 |
+
});
|
| 53 |
+
`;
|
| 54 |
+
|
| 55 |
+
function useClaudeCliFallback() {
|
| 56 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 57 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 58 |
+
result: await params.run(
|
| 59 |
+
"claude-cli",
|
| 60 |
+
"claude-opus-4-6",
|
| 61 |
+
initialFallbackAttemptOptions(params),
|
| 62 |
+
),
|
| 63 |
+
provider: "claude-cli",
|
| 64 |
+
model: "claude-opus-4-6",
|
| 65 |
+
attempts: [],
|
| 66 |
+
}));
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function useScriptedClaudeCliBackend() {
|
| 70 |
+
const backend = {
|
| 71 |
+
id: "claude-cli",
|
| 72 |
+
modelProvider: "anthropic",
|
| 73 |
+
pluginId: "anthropic",
|
| 74 |
+
bundleMcp: false,
|
| 75 |
+
contextEngineHostCapabilities: ["thread-bootstrap-projection"] as const,
|
| 76 |
+
config: {
|
| 77 |
+
command: process.execPath,
|
| 78 |
+
args: ["-e", scriptedCliProgram],
|
| 79 |
+
input: "stdin" as const,
|
| 80 |
+
output: "jsonl" as const,
|
| 81 |
+
jsonlDialect: "claude-stream-json" as const,
|
| 82 |
+
sessionMode: "none" as const,
|
| 83 |
+
systemPromptWhen: "never" as const,
|
| 84 |
+
},
|
| 85 |
+
};
|
| 86 |
+
cliBackendsTesting.setDepsForTest({
|
| 87 |
+
resolvePluginSetupCliBackend: ({ backend: id }) =>
|
| 88 |
+
id === backend.id ? { pluginId: backend.pluginId, backend } : undefined,
|
| 89 |
+
resolveRuntimeCliBackends: () => [backend],
|
| 90 |
+
});
|
| 91 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: RunCliAgentParams) => {
|
| 92 |
+
return await (
|
| 93 |
+
await loadActualRunCliAgentForTest()
|
| 94 |
+
)(params);
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function createClaudeCliFollowupRun() {
|
| 99 |
+
const followupRun = createFollowupRun();
|
| 100 |
+
followupRun.run.agentId = "agent";
|
| 101 |
+
followupRun.run.provider = "claude-cli";
|
| 102 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 103 |
+
followupRun.run.skillsSnapshot = { prompt: "", skills: [], version: 0 };
|
| 104 |
+
followupRun.run.timeoutMs = 10_000;
|
| 105 |
+
return followupRun;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function createTurnParams(opts: GetReplyOptions, blockStreamingEnabled: boolean) {
|
| 109 |
+
return {
|
| 110 |
+
commandBody: "hi",
|
| 111 |
+
followupRun: createClaudeCliFollowupRun(),
|
| 112 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 113 |
+
opts,
|
| 114 |
+
typingSignals: createMockTypingSignaler(),
|
| 115 |
+
blockReplyPipeline: null,
|
| 116 |
+
blockStreamingEnabled,
|
| 117 |
+
resolvedBlockStreamingBreak: "message_end" as const,
|
| 118 |
+
applyReplyToMode: <T>(payload: T) => payload,
|
| 119 |
+
shouldEmitToolResult: () => true,
|
| 120 |
+
shouldEmitToolOutput: () => false,
|
| 121 |
+
pendingToolTasks: new Set<Promise<void>>(),
|
| 122 |
+
resetSessionAfterRoleOrderingConflict: async () => false,
|
| 123 |
+
isHeartbeat: false,
|
| 124 |
+
sessionKey: "main",
|
| 125 |
+
getActiveSessionEntry: () => undefined,
|
| 126 |
+
resolvedVerboseLevel: "off" as const,
|
| 127 |
+
};
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
describe("executeAgentTurn: CLI durable commentary", () => {
|
| 131 |
+
it("delivers commentary from a real JSONL CLI subprocess", async () => {
|
| 132 |
+
useClaudeCliFallback();
|
| 133 |
+
useScriptedClaudeCliBackend();
|
| 134 |
+
state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
|
| 135 |
+
(params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
|
| 136 |
+
params.onBlockReply,
|
| 137 |
+
);
|
| 138 |
+
const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
|
| 139 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 140 |
+
const result = await executeAgentTurn(createTurnParams({ onBlockReply }, true));
|
| 141 |
+
|
| 142 |
+
expect(state.runCliAgentMock.mock.calls[0]?.[0]).toMatchObject({ emitCommentaryText: true });
|
| 143 |
+
const resolveContextEngineHost = state.runEmbeddedAgentEntryMock.mock.calls[0]?.[0]?.harness
|
| 144 |
+
?.resolveContextEngineHost as
|
| 145 |
+
| ((
|
| 146 |
+
provider: string,
|
| 147 |
+
model: string,
|
| 148 |
+
) => {
|
| 149 |
+
id: string;
|
| 150 |
+
label: string;
|
| 151 |
+
capabilities: readonly string[];
|
| 152 |
+
})
|
| 153 |
+
| undefined;
|
| 154 |
+
expect(resolveContextEngineHost?.("claude-cli", "claude-opus-4-6")).toEqual({
|
| 155 |
+
id: "cli:claude-cli",
|
| 156 |
+
label: 'CLI backend "claude-cli"',
|
| 157 |
+
capabilities: ["thread-bootstrap-projection"],
|
| 158 |
+
});
|
| 159 |
+
expect(result.kind).toBe("success");
|
| 160 |
+
if (result.kind === "success") {
|
| 161 |
+
expect(result.runResult.payloads).toEqual([{ text: "Subprocess final answer." }]);
|
| 162 |
+
}
|
| 163 |
+
expect(onBlockReply).toHaveBeenCalledExactlyOnceWith({
|
| 164 |
+
text: "The subprocess findings are durable.",
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
it("delivers completed CLI commentary through block streaming", async () => {
|
| 169 |
+
useClaudeCliFallback();
|
| 170 |
+
state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
|
| 171 |
+
(params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
|
| 172 |
+
params.onBlockReply,
|
| 173 |
+
);
|
| 174 |
+
state.runCliAgentMock.mockImplementationOnce(
|
| 175 |
+
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
| 176 |
+
expect(params.emitCommentaryText).toBe(true);
|
| 177 |
+
const agentEvents = await import("../../infra/agent-events.js");
|
| 178 |
+
agentEvents.emitAgentEvent({
|
| 179 |
+
runId: params.runId,
|
| 180 |
+
stream: "item",
|
| 181 |
+
data: {
|
| 182 |
+
kind: "preamble",
|
| 183 |
+
itemId: "commentary-durable-1",
|
| 184 |
+
progressText: "The durable findings live here.",
|
| 185 |
+
},
|
| 186 |
+
});
|
| 187 |
+
return { payloads: [{ text: "Short final wrap-up." }], meta: {} };
|
| 188 |
+
},
|
| 189 |
+
);
|
| 190 |
+
|
| 191 |
+
const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
|
| 192 |
+
const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>(async () => undefined);
|
| 193 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 194 |
+
const result = await executeAgentTurn(
|
| 195 |
+
createTurnParams(
|
| 196 |
+
{
|
| 197 |
+
onBlockReply,
|
| 198 |
+
onItemEvent,
|
| 199 |
+
commentaryProgressEnabled: false,
|
| 200 |
+
progressPreambleEnabled: true,
|
| 201 |
+
},
|
| 202 |
+
true,
|
| 203 |
+
),
|
| 204 |
+
);
|
| 205 |
+
|
| 206 |
+
await vi.waitFor(() => {
|
| 207 |
+
expect(onBlockReply).toHaveBeenCalledExactlyOnceWith({
|
| 208 |
+
text: "The durable findings live here.",
|
| 209 |
+
});
|
| 210 |
+
expect(onItemEvent).toHaveBeenCalledExactlyOnceWith({
|
| 211 |
+
itemId: "commentary-durable-1",
|
| 212 |
+
kind: "preamble",
|
| 213 |
+
progressText: "The durable findings live here.",
|
| 214 |
+
suppressDurableProgress: true,
|
| 215 |
+
});
|
| 216 |
+
});
|
| 217 |
+
expect(result.kind).toBe("success");
|
| 218 |
+
if (result.kind === "success") {
|
| 219 |
+
expect(result.runResult.payloads).toEqual([{ text: "Short final wrap-up." }]);
|
| 220 |
+
}
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
it("forwards channel presentation callbacks to native CLI input", async () => {
|
| 224 |
+
useClaudeCliFallback();
|
| 225 |
+
state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
|
| 226 |
+
(params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
|
| 227 |
+
params.onBlockReply,
|
| 228 |
+
);
|
| 229 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: RunCliAgentParams) => {
|
| 230 |
+
await params.onBlockReply?.({
|
| 231 |
+
text: "Which color?",
|
| 232 |
+
presentation: {
|
| 233 |
+
blocks: [
|
| 234 |
+
{
|
| 235 |
+
type: "buttons",
|
| 236 |
+
buttons: [
|
| 237 |
+
{
|
| 238 |
+
label: "Blue",
|
| 239 |
+
action: { type: "question", questionId: "question-1", optionValue: "Blue" },
|
| 240 |
+
},
|
| 241 |
+
],
|
| 242 |
+
},
|
| 243 |
+
],
|
| 244 |
+
},
|
| 245 |
+
});
|
| 246 |
+
await params.onPartialReply?.({ text: "Fallback question" });
|
| 247 |
+
return { payloads: [{ text: "Answered." }], meta: {} };
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
|
| 251 |
+
const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
|
| 252 |
+
async () => undefined,
|
| 253 |
+
);
|
| 254 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 255 |
+
|
| 256 |
+
await executeAgentTurn(createTurnParams({ onBlockReply, onPartialReply }, false));
|
| 257 |
+
|
| 258 |
+
expect(onBlockReply).toHaveBeenCalledWith(
|
| 259 |
+
expect.objectContaining({ text: "Which color?", presentation: expect.any(Object) }),
|
| 260 |
+
);
|
| 261 |
+
expect(onPartialReply).toHaveBeenCalledWith({ text: "Fallback question" });
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
it("delivers commentary payloads without block streaming", async () => {
|
| 265 |
+
useClaudeCliFallback();
|
| 266 |
+
state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
|
| 267 |
+
(params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
|
| 268 |
+
params.onBlockReply,
|
| 269 |
+
);
|
| 270 |
+
state.runCliAgentMock.mockImplementationOnce(
|
| 271 |
+
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
| 272 |
+
expect(params.emitCommentaryText).toBe(true);
|
| 273 |
+
const agentEvents = await import("../../infra/agent-events.js");
|
| 274 |
+
agentEvents.emitAgentEvent({
|
| 275 |
+
runId: params.runId,
|
| 276 |
+
stream: "item",
|
| 277 |
+
data: {
|
| 278 |
+
kind: "preamble",
|
| 279 |
+
itemId: "commentary-payload-1",
|
| 280 |
+
progressText: "A durable commentary update.",
|
| 281 |
+
},
|
| 282 |
+
});
|
| 283 |
+
return { payloads: [{ text: "Final answer." }], meta: {} };
|
| 284 |
+
},
|
| 285 |
+
);
|
| 286 |
+
|
| 287 |
+
const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
|
| 288 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 289 |
+
const result = await executeAgentTurn(
|
| 290 |
+
createTurnParams({ onBlockReply, commentaryPayloadsEnabled: true }, false),
|
| 291 |
+
);
|
| 292 |
+
|
| 293 |
+
await vi.waitFor(() => {
|
| 294 |
+
expect(onBlockReply).toHaveBeenCalledExactlyOnceWith({
|
| 295 |
+
text: "A durable commentary update.",
|
| 296 |
+
isCommentary: true,
|
| 297 |
+
});
|
| 298 |
+
});
|
| 299 |
+
expect(result.kind).toBe("success");
|
| 300 |
+
if (result.kind === "success") {
|
| 301 |
+
expect(result.runResult.payloads).toEqual([{ text: "Final answer." }]);
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
});
|
src/auto-reply/reply/agent-runner-execution-cli-delegation.test.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { buildCliMcpGrantContext } from "../../agents/cli-runner/mcp-grant-context.js";
|
| 3 |
+
import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
|
| 4 |
+
import {
|
| 5 |
+
createFollowupRun,
|
| 6 |
+
createMinimalRunAgentTurnParams,
|
| 7 |
+
fallbackAttemptOptions,
|
| 8 |
+
getExecuteAgentTurnForTest,
|
| 9 |
+
initialFallbackAttemptOptions,
|
| 10 |
+
requireMockCall,
|
| 11 |
+
setupAgentRunnerExecutionTestState,
|
| 12 |
+
} from "./agent-runner-execution.test-support.js";
|
| 13 |
+
import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
|
| 14 |
+
|
| 15 |
+
const state = await setupAgentRunnerExecutionTestState();
|
| 16 |
+
|
| 17 |
+
function resolveMockedCliGrantCapability() {
|
| 18 |
+
const run = requireMockCall(state.runCliAgentMock, 0, "CLI run params")[0] as RunCliAgentParams;
|
| 19 |
+
return buildCliMcpGrantContext({
|
| 20 |
+
run,
|
| 21 |
+
config: run.config ?? {},
|
| 22 |
+
requireExplicitMessageTarget: false,
|
| 23 |
+
agentId: "main",
|
| 24 |
+
modelProvider: "openai",
|
| 25 |
+
modelId: "gpt-5.4",
|
| 26 |
+
}).delegationCapability;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
describe("executeAgentTurn: CLI delegation grants", () => {
|
| 30 |
+
it("keeps primary CLI completion grants unrestricted", async () => {
|
| 31 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 32 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 33 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 34 |
+
provider: "codex-cli",
|
| 35 |
+
model: "gpt-5.4",
|
| 36 |
+
attempts: [],
|
| 37 |
+
}));
|
| 38 |
+
state.runCliAgentMock.mockResolvedValue({ payloads: [{ text: "done" }], meta: {} });
|
| 39 |
+
const followupRun = createFollowupRun();
|
| 40 |
+
followupRun.run.provider = "codex-cli";
|
| 41 |
+
followupRun.run.model = "gpt-5.4";
|
| 42 |
+
followupRun.run.inputProvenance = {
|
| 43 |
+
kind: "inter_session",
|
| 44 |
+
sourceTool: "subagent_announce",
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 48 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun }));
|
| 49 |
+
|
| 50 |
+
expect(result.kind).toBe("success");
|
| 51 |
+
expect(resolveMockedCliGrantCapability()).toBeUndefined();
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it("restricts fallback CLI completion grants", async () => {
|
| 55 |
+
state.isCliProviderMock.mockImplementation((provider) => provider === "codex-cli");
|
| 56 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
|
| 57 |
+
await params.run("anthropic", "claude", initialFallbackAttemptOptions(params));
|
| 58 |
+
return {
|
| 59 |
+
result: await params.run("codex-cli", "gpt-5.4", fallbackAttemptOptions(params, "unknown")),
|
| 60 |
+
provider: "codex-cli",
|
| 61 |
+
model: "gpt-5.4",
|
| 62 |
+
attempts: [{ provider: "anthropic", model: "claude", error: "rate limit" }],
|
| 63 |
+
};
|
| 64 |
+
});
|
| 65 |
+
state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [], meta: {} });
|
| 66 |
+
state.runCliAgentMock.mockResolvedValue({ payloads: [{ text: "done" }], meta: {} });
|
| 67 |
+
const followupRun = createFollowupRun();
|
| 68 |
+
followupRun.run.inputProvenance = {
|
| 69 |
+
kind: "inter_session",
|
| 70 |
+
sourceTool: "subagent_announce",
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 74 |
+
const result = await executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun }));
|
| 75 |
+
|
| 76 |
+
expect(result.kind).toBe("success");
|
| 77 |
+
expect(resolveMockedCliGrantCapability()).toBe("report_only");
|
| 78 |
+
});
|
| 79 |
+
});
|
src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
| 2 |
+
import { createDeferred } from "../../../test/helpers/promise.js";
|
| 3 |
+
import { createCliJsonlStreamingParser } from "../../agents/cli-output-stream.js";
|
| 4 |
+
import type { TemplateContext } from "../templating.js";
|
| 5 |
+
import type { GetReplyOptions } from "../types.js";
|
| 6 |
+
import {
|
| 7 |
+
createAgentTurnExecutionDefaults,
|
| 8 |
+
setupAgentRunnerExecutionTestState,
|
| 9 |
+
getExecuteAgentTurnForTest,
|
| 10 |
+
createMockTypingSignaler,
|
| 11 |
+
createFollowupRun,
|
| 12 |
+
initialFallbackAttemptOptions,
|
| 13 |
+
runInitialFallbackAttempt,
|
| 14 |
+
createMinimalRunAgentTurnParams,
|
| 15 |
+
} from "./agent-runner-execution.test-support.js";
|
| 16 |
+
import type {
|
| 17 |
+
FallbackRunnerParams,
|
| 18 |
+
EmbeddedAgentParams,
|
| 19 |
+
} from "./agent-runner-execution.test-support.js";
|
| 20 |
+
|
| 21 |
+
const state = await setupAgentRunnerExecutionTestState();
|
| 22 |
+
|
| 23 |
+
describe("executeAgentTurn: CLI progress bridging", () => {
|
| 24 |
+
it("bridges CLI assistant agent events into onPartialReply for live preview (#76869)", async () => {
|
| 25 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 26 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 27 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 28 |
+
provider: "claude-cli",
|
| 29 |
+
model: "claude-opus-4-6",
|
| 30 |
+
attempts: [],
|
| 31 |
+
}));
|
| 32 |
+
state.runCliAgentMock.mockImplementationOnce(
|
| 33 |
+
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
| 34 |
+
expect(params.emitCommentaryText).toBe(false);
|
| 35 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 36 |
+
"../../infra/agent-events.js",
|
| 37 |
+
);
|
| 38 |
+
realAgentEvents.emitAgentEvent({
|
| 39 |
+
runId: params.runId,
|
| 40 |
+
stream: "assistant",
|
| 41 |
+
data: { text: "Hello", delta: "Hello" },
|
| 42 |
+
});
|
| 43 |
+
realAgentEvents.emitAgentEvent({
|
| 44 |
+
runId: params.runId,
|
| 45 |
+
stream: "assistant",
|
| 46 |
+
data: { text: "Hello world", delta: " world" },
|
| 47 |
+
});
|
| 48 |
+
return { payloads: [{ text: "Hello world" }], meta: {} };
|
| 49 |
+
},
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
|
| 53 |
+
async (_payload) => undefined,
|
| 54 |
+
);
|
| 55 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 56 |
+
const followupRun = createFollowupRun();
|
| 57 |
+
followupRun.run.provider = "claude-cli";
|
| 58 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 59 |
+
|
| 60 |
+
await executeAgentTurn({
|
| 61 |
+
commandBody: "hi",
|
| 62 |
+
followupRun,
|
| 63 |
+
sessionCtx: {
|
| 64 |
+
Provider: "telegram",
|
| 65 |
+
MessageSid: "msg",
|
| 66 |
+
} as unknown as TemplateContext,
|
| 67 |
+
opts: { onPartialReply },
|
| 68 |
+
typingSignals: createMockTypingSignaler(),
|
| 69 |
+
...createAgentTurnExecutionDefaults(),
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
const partialTexts = onPartialReply.mock.calls.map((call) => call[0].text);
|
| 73 |
+
expect(partialTexts).toEqual(["Hello", "Hello world"]);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
it("serializes and drains bridged CLI assistant previews before completing (#76869)", async () => {
|
| 77 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 78 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 79 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 80 |
+
provider: "claude-cli",
|
| 81 |
+
model: "claude-opus-4-6",
|
| 82 |
+
attempts: [],
|
| 83 |
+
}));
|
| 84 |
+
state.runCliAgentMock.mockImplementationOnce(
|
| 85 |
+
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
| 86 |
+
expect(params.emitCommentaryText).toBe(false);
|
| 87 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 88 |
+
"../../infra/agent-events.js",
|
| 89 |
+
);
|
| 90 |
+
realAgentEvents.emitAgentEvent({
|
| 91 |
+
runId: params.runId,
|
| 92 |
+
stream: "assistant",
|
| 93 |
+
data: { text: "Hello", delta: "Hello" },
|
| 94 |
+
});
|
| 95 |
+
realAgentEvents.emitAgentEvent({
|
| 96 |
+
runId: params.runId,
|
| 97 |
+
stream: "assistant",
|
| 98 |
+
data: { text: "Hello world", delta: " world" },
|
| 99 |
+
});
|
| 100 |
+
return { payloads: [{ text: "Hello world" }], meta: {} };
|
| 101 |
+
},
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
let firstPreviewStarted: (() => void) | undefined;
|
| 105 |
+
let releaseFirstPreview: (() => void) | undefined;
|
| 106 |
+
const firstPreviewPromise = new Promise<void>((resolve) => {
|
| 107 |
+
firstPreviewStarted = resolve;
|
| 108 |
+
});
|
| 109 |
+
const previewOrder: string[] = [];
|
| 110 |
+
const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
|
| 111 |
+
async (payload) => {
|
| 112 |
+
previewOrder.push(payload.text ?? "");
|
| 113 |
+
if (payload.text === "Hello") {
|
| 114 |
+
firstPreviewStarted?.();
|
| 115 |
+
await new Promise<void>((resolve) => {
|
| 116 |
+
releaseFirstPreview = resolve;
|
| 117 |
+
});
|
| 118 |
+
previewOrder.push("Hello released");
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
);
|
| 122 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 123 |
+
const followupRun = createFollowupRun();
|
| 124 |
+
followupRun.run.provider = "claude-cli";
|
| 125 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 126 |
+
|
| 127 |
+
const runPromise = executeAgentTurn({
|
| 128 |
+
commandBody: "hi",
|
| 129 |
+
followupRun,
|
| 130 |
+
sessionCtx: {
|
| 131 |
+
Provider: "telegram",
|
| 132 |
+
MessageSid: "msg",
|
| 133 |
+
} as unknown as TemplateContext,
|
| 134 |
+
opts: { onPartialReply },
|
| 135 |
+
typingSignals: createMockTypingSignaler(),
|
| 136 |
+
...createAgentTurnExecutionDefaults(),
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
await firstPreviewPromise;
|
| 140 |
+
await new Promise((resolve) => {
|
| 141 |
+
setImmediate(resolve);
|
| 142 |
+
});
|
| 143 |
+
expect(previewOrder).toEqual(["Hello"]);
|
| 144 |
+
|
| 145 |
+
releaseFirstPreview?.();
|
| 146 |
+
await runPromise;
|
| 147 |
+
|
| 148 |
+
expect(previewOrder).toEqual(["Hello", "Hello released", "Hello world"]);
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
it("bridges CLI tool agent events into onToolStart for live preview", async () => {
|
| 152 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 153 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 154 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 155 |
+
provider: "claude-cli",
|
| 156 |
+
model: "claude-opus-4-6",
|
| 157 |
+
attempts: [],
|
| 158 |
+
}));
|
| 159 |
+
state.runCliAgentMock.mockImplementationOnce(
|
| 160 |
+
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
| 161 |
+
expect(params.emitCommentaryText).toBe(false);
|
| 162 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 163 |
+
"../../infra/agent-events.js",
|
| 164 |
+
);
|
| 165 |
+
realAgentEvents.emitAgentEvent({
|
| 166 |
+
runId: params.runId,
|
| 167 |
+
stream: "tool",
|
| 168 |
+
data: {
|
| 169 |
+
phase: "start",
|
| 170 |
+
name: "Bash",
|
| 171 |
+
toolCallId: "toolu_01ABCD",
|
| 172 |
+
args: { command: "ls -la" },
|
| 173 |
+
},
|
| 174 |
+
});
|
| 175 |
+
realAgentEvents.emitAgentEvent({
|
| 176 |
+
runId: params.runId,
|
| 177 |
+
stream: "tool",
|
| 178 |
+
data: {
|
| 179 |
+
phase: "result",
|
| 180 |
+
name: "Bash",
|
| 181 |
+
toolCallId: "toolu_01ABCD",
|
| 182 |
+
isError: false,
|
| 183 |
+
},
|
| 184 |
+
});
|
| 185 |
+
return { payloads: [{ text: "done" }], meta: {} };
|
| 186 |
+
},
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
const onToolStart = vi.fn<NonNullable<GetReplyOptions["onToolStart"]>>(async () => undefined);
|
| 190 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 191 |
+
const followupRun = createFollowupRun();
|
| 192 |
+
followupRun.run.provider = "claude-cli";
|
| 193 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 194 |
+
|
| 195 |
+
await executeAgentTurn({
|
| 196 |
+
commandBody: "hi",
|
| 197 |
+
followupRun,
|
| 198 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 199 |
+
opts: { onToolStart },
|
| 200 |
+
typingSignals: createMockTypingSignaler(),
|
| 201 |
+
...createAgentTurnExecutionDefaults(),
|
| 202 |
+
});
|
| 203 |
+
await new Promise((resolve) => {
|
| 204 |
+
setImmediate(resolve);
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
expect(onToolStart).toHaveBeenCalledTimes(1);
|
| 208 |
+
const call = onToolStart.mock.calls[0]?.[0];
|
| 209 |
+
expect(call?.name).toBe("Bash");
|
| 210 |
+
expect(call?.phase).toBe("start");
|
| 211 |
+
expect(call?.args).toEqual({ command: "ls -la" });
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
it("starts CLI assistant progress before a later tool while typing is slow", async () => {
|
| 215 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 216 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 217 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 218 |
+
provider: "claude-cli",
|
| 219 |
+
model: "claude-opus-4-6",
|
| 220 |
+
attempts: [],
|
| 221 |
+
}));
|
| 222 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 223 |
+
const agentEvents = await import("../../infra/agent-events.js");
|
| 224 |
+
agentEvents.emitAgentEvent({
|
| 225 |
+
runId: params.runId,
|
| 226 |
+
stream: "assistant",
|
| 227 |
+
data: { text: "answer before tool", delta: "answer before tool" },
|
| 228 |
+
});
|
| 229 |
+
agentEvents.emitAgentEvent({
|
| 230 |
+
runId: params.runId,
|
| 231 |
+
stream: "assistant",
|
| 232 |
+
data: { text: "answer before tool 2", delta: " 2" },
|
| 233 |
+
});
|
| 234 |
+
agentEvents.emitAgentEvent({
|
| 235 |
+
runId: params.runId,
|
| 236 |
+
stream: "tool",
|
| 237 |
+
data: {
|
| 238 |
+
phase: "start",
|
| 239 |
+
name: "Bash",
|
| 240 |
+
toolCallId: "toolu_order",
|
| 241 |
+
args: { command: "echo hi" },
|
| 242 |
+
},
|
| 243 |
+
});
|
| 244 |
+
return { payloads: [{ text: "final" }], meta: {} };
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
let releaseTyping: (() => void) | undefined;
|
| 248 |
+
const typingPending = new Promise<void>((resolve) => {
|
| 249 |
+
releaseTyping = resolve;
|
| 250 |
+
});
|
| 251 |
+
const typingSignals = createMockTypingSignaler();
|
| 252 |
+
vi.mocked(typingSignals.signalTextDelta).mockReturnValue(typingPending);
|
| 253 |
+
const callbackOrder: string[] = [];
|
| 254 |
+
const toolStarted = createDeferred();
|
| 255 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 256 |
+
const followupRun = createFollowupRun();
|
| 257 |
+
followupRun.run.provider = "claude-cli";
|
| 258 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 259 |
+
const runPromise = executeAgentTurn({
|
| 260 |
+
commandBody: "hi",
|
| 261 |
+
followupRun,
|
| 262 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 263 |
+
opts: {
|
| 264 |
+
preserveProgressCallbackStartOrder: true,
|
| 265 |
+
onPartialReply: (payload) => {
|
| 266 |
+
callbackOrder.push(`partial:${payload.text}`);
|
| 267 |
+
},
|
| 268 |
+
onToolStart: () => {
|
| 269 |
+
callbackOrder.push("tool");
|
| 270 |
+
toolStarted.resolve();
|
| 271 |
+
},
|
| 272 |
+
},
|
| 273 |
+
typingSignals,
|
| 274 |
+
...createAgentTurnExecutionDefaults(),
|
| 275 |
+
});
|
| 276 |
+
onTestFinished(async () => {
|
| 277 |
+
releaseTyping?.();
|
| 278 |
+
await runPromise;
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
try {
|
| 282 |
+
await Promise.race([toolStarted.promise, runPromise]);
|
| 283 |
+
expect(callbackOrder).toEqual([
|
| 284 |
+
"partial:answer before tool",
|
| 285 |
+
"partial:answer before tool 2",
|
| 286 |
+
"tool",
|
| 287 |
+
]);
|
| 288 |
+
} finally {
|
| 289 |
+
releaseTyping?.();
|
| 290 |
+
await runPromise;
|
| 291 |
+
}
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
it("starts CLI tool progress before later assistant text while typing is slow", async () => {
|
| 295 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 296 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 297 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 298 |
+
provider: "claude-cli",
|
| 299 |
+
model: "claude-opus-4-6",
|
| 300 |
+
attempts: [],
|
| 301 |
+
}));
|
| 302 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 303 |
+
const agentEvents = await import("../../infra/agent-events.js");
|
| 304 |
+
agentEvents.emitAgentEvent({
|
| 305 |
+
runId: params.runId,
|
| 306 |
+
stream: "tool",
|
| 307 |
+
data: {
|
| 308 |
+
phase: "start",
|
| 309 |
+
name: "Bash",
|
| 310 |
+
toolCallId: "toolu_inverse_order",
|
| 311 |
+
args: { command: "echo hi" },
|
| 312 |
+
},
|
| 313 |
+
});
|
| 314 |
+
agentEvents.emitAgentEvent({
|
| 315 |
+
runId: params.runId,
|
| 316 |
+
stream: "tool",
|
| 317 |
+
data: {
|
| 318 |
+
phase: "update",
|
| 319 |
+
name: "Bash",
|
| 320 |
+
toolCallId: "toolu_inverse_order",
|
| 321 |
+
args: { command: "echo hi" },
|
| 322 |
+
},
|
| 323 |
+
});
|
| 324 |
+
agentEvents.emitAgentEvent({
|
| 325 |
+
runId: params.runId,
|
| 326 |
+
stream: "assistant",
|
| 327 |
+
data: { text: "answer after tool", delta: "answer after tool" },
|
| 328 |
+
});
|
| 329 |
+
return { payloads: [{ text: "final" }], meta: {} };
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
let releaseTyping: (() => void) | undefined;
|
| 333 |
+
const typingPending = new Promise<void>((resolve) => {
|
| 334 |
+
releaseTyping = resolve;
|
| 335 |
+
});
|
| 336 |
+
const typingSignals = createMockTypingSignaler();
|
| 337 |
+
vi.mocked(typingSignals.signalToolStart).mockReturnValue(typingPending);
|
| 338 |
+
const callbackOrder: string[] = [];
|
| 339 |
+
const partialReplyStarted = createDeferred();
|
| 340 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 341 |
+
const followupRun = createFollowupRun();
|
| 342 |
+
followupRun.run.provider = "claude-cli";
|
| 343 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 344 |
+
const runPromise = executeAgentTurn({
|
| 345 |
+
commandBody: "hi",
|
| 346 |
+
followupRun,
|
| 347 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 348 |
+
opts: {
|
| 349 |
+
preserveProgressCallbackStartOrder: true,
|
| 350 |
+
onPartialReply: (payload) => {
|
| 351 |
+
callbackOrder.push(`partial:${payload.text}`);
|
| 352 |
+
partialReplyStarted.resolve();
|
| 353 |
+
},
|
| 354 |
+
onToolStart: (payload) => {
|
| 355 |
+
callbackOrder.push(`tool:${payload.phase}`);
|
| 356 |
+
},
|
| 357 |
+
},
|
| 358 |
+
typingSignals,
|
| 359 |
+
...createAgentTurnExecutionDefaults(),
|
| 360 |
+
});
|
| 361 |
+
onTestFinished(async () => {
|
| 362 |
+
releaseTyping?.();
|
| 363 |
+
await runPromise;
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
try {
|
| 367 |
+
await Promise.race([partialReplyStarted.promise, runPromise]);
|
| 368 |
+
expect(callbackOrder).toEqual(["tool:start", "tool:update", "partial:answer after tool"]);
|
| 369 |
+
} finally {
|
| 370 |
+
releaseTyping?.();
|
| 371 |
+
await runPromise;
|
| 372 |
+
}
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
it("bridges CLI preambles for progress headlines when commentary is disabled", async () => {
|
| 376 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 377 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 378 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 379 |
+
provider: "claude-cli",
|
| 380 |
+
model: "claude-opus-4-6",
|
| 381 |
+
attempts: [],
|
| 382 |
+
}));
|
| 383 |
+
state.runCliAgentMock.mockImplementationOnce(
|
| 384 |
+
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
| 385 |
+
expect(params.emitCommentaryText).toBe(true);
|
| 386 |
+
const agentEvents = await import("../../infra/agent-events.js");
|
| 387 |
+
// Inter-tool commentary surfaces as a stream:"item", kind:"preamble" agent event.
|
| 388 |
+
agentEvents.emitAgentEvent({
|
| 389 |
+
runId: params.runId,
|
| 390 |
+
stream: "item",
|
| 391 |
+
data: {
|
| 392 |
+
kind: "preamble",
|
| 393 |
+
itemId: "commentary-1",
|
| 394 |
+
progressText: "Let me check the files.",
|
| 395 |
+
},
|
| 396 |
+
});
|
| 397 |
+
return { payloads: [{ text: "done" }], meta: {} };
|
| 398 |
+
},
|
| 399 |
+
);
|
| 400 |
+
|
| 401 |
+
const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>(async () => undefined);
|
| 402 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 403 |
+
const followupRun = createFollowupRun();
|
| 404 |
+
followupRun.run.provider = "claude-cli";
|
| 405 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 406 |
+
|
| 407 |
+
await executeAgentTurn({
|
| 408 |
+
commandBody: "hi",
|
| 409 |
+
followupRun,
|
| 410 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 411 |
+
opts: {
|
| 412 |
+
onItemEvent,
|
| 413 |
+
commentaryProgressEnabled: false,
|
| 414 |
+
progressPreambleEnabled: true,
|
| 415 |
+
},
|
| 416 |
+
typingSignals: createMockTypingSignaler(),
|
| 417 |
+
...createAgentTurnExecutionDefaults(),
|
| 418 |
+
});
|
| 419 |
+
await new Promise((resolve) => {
|
| 420 |
+
setImmediate(resolve);
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
expect(onItemEvent).toHaveBeenCalledTimes(1);
|
| 424 |
+
const call = onItemEvent.mock.calls[0]?.[0];
|
| 425 |
+
expect(call?.kind).toBe("preamble");
|
| 426 |
+
expect(call?.progressText).toBe("Let me check the files.");
|
| 427 |
+
expect(call?.itemId).toBe("commentary-1");
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
it("does not emit CLI preambles when both progress lanes are disabled", async () => {
|
| 431 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 432 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 433 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 434 |
+
provider: "claude-cli",
|
| 435 |
+
model: "claude-opus-4-6",
|
| 436 |
+
attempts: [],
|
| 437 |
+
}));
|
| 438 |
+
state.runCliAgentMock.mockImplementationOnce(
|
| 439 |
+
async (params: { runId: string; emitCommentaryText?: boolean }) => {
|
| 440 |
+
// With no commentary lane or headline consumer, pre-tool text stays in
|
| 441 |
+
// the assistant stream instead of being split into progress events.
|
| 442 |
+
expect(params.emitCommentaryText).toBe(false);
|
| 443 |
+
return { payloads: [{ text: "done" }], meta: {} };
|
| 444 |
+
},
|
| 445 |
+
);
|
| 446 |
+
|
| 447 |
+
const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>();
|
| 448 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 449 |
+
const followupRun = createFollowupRun();
|
| 450 |
+
followupRun.run.provider = "claude-cli";
|
| 451 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 452 |
+
|
| 453 |
+
await executeAgentTurn({
|
| 454 |
+
commandBody: "hi",
|
| 455 |
+
followupRun,
|
| 456 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 457 |
+
opts: {
|
| 458 |
+
onItemEvent,
|
| 459 |
+
commentaryProgressEnabled: false,
|
| 460 |
+
progressPreambleEnabled: false,
|
| 461 |
+
},
|
| 462 |
+
typingSignals: createMockTypingSignaler(),
|
| 463 |
+
...createAgentTurnExecutionDefaults(),
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
expect(state.runCliAgentMock).toHaveBeenCalledTimes(1);
|
| 467 |
+
expect(onItemEvent).not.toHaveBeenCalled();
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
it("does not bridge CLI tool deltas when silentExpected is set", async () => {
|
| 471 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 472 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 473 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 474 |
+
provider: "claude-cli",
|
| 475 |
+
model: "claude-opus-4-6",
|
| 476 |
+
attempts: [],
|
| 477 |
+
}));
|
| 478 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 479 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 480 |
+
"../../infra/agent-events.js",
|
| 481 |
+
);
|
| 482 |
+
realAgentEvents.emitAgentEvent({
|
| 483 |
+
runId: params.runId,
|
| 484 |
+
stream: "tool",
|
| 485 |
+
data: {
|
| 486 |
+
phase: "start",
|
| 487 |
+
name: "Bash",
|
| 488 |
+
toolCallId: "toolu_silent",
|
| 489 |
+
args: { command: "echo silent" },
|
| 490 |
+
},
|
| 491 |
+
});
|
| 492 |
+
return { payloads: [{ text: "final" }], meta: {} };
|
| 493 |
+
});
|
| 494 |
+
|
| 495 |
+
const onToolStart = vi.fn<NonNullable<GetReplyOptions["onToolStart"]>>(async () => undefined);
|
| 496 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 497 |
+
const followupRun = createFollowupRun();
|
| 498 |
+
followupRun.run.provider = "claude-cli";
|
| 499 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 500 |
+
followupRun.run.silentExpected = true;
|
| 501 |
+
|
| 502 |
+
await executeAgentTurn({
|
| 503 |
+
commandBody: "hi",
|
| 504 |
+
followupRun,
|
| 505 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 506 |
+
opts: { onToolStart },
|
| 507 |
+
typingSignals: createMockTypingSignaler(),
|
| 508 |
+
...createAgentTurnExecutionDefaults(),
|
| 509 |
+
});
|
| 510 |
+
await new Promise((resolve) => {
|
| 511 |
+
setImmediate(resolve);
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
expect(onToolStart).not.toHaveBeenCalled();
|
| 515 |
+
});
|
| 516 |
+
|
| 517 |
+
it("does not bridge CLI assistant deltas when silentExpected is set (#76869)", async () => {
|
| 518 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 519 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 520 |
+
result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
|
| 521 |
+
provider: "claude-cli",
|
| 522 |
+
model: "claude-opus-4-6",
|
| 523 |
+
attempts: [],
|
| 524 |
+
}));
|
| 525 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 526 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 527 |
+
"../../infra/agent-events.js",
|
| 528 |
+
);
|
| 529 |
+
realAgentEvents.emitAgentEvent({
|
| 530 |
+
runId: params.runId,
|
| 531 |
+
stream: "assistant",
|
| 532 |
+
data: { text: "secret heartbeat output", delta: "secret heartbeat output" },
|
| 533 |
+
});
|
| 534 |
+
realAgentEvents.emitAgentEvent({
|
| 535 |
+
runId: params.runId,
|
| 536 |
+
stream: "assistant",
|
| 537 |
+
data: { text: "NO_REPLY do not preview", delta: " do not preview" },
|
| 538 |
+
});
|
| 539 |
+
return { payloads: [{ text: "final" }], meta: {} };
|
| 540 |
+
});
|
| 541 |
+
|
| 542 |
+
const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
|
| 543 |
+
async (_payload) => undefined,
|
| 544 |
+
);
|
| 545 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 546 |
+
const followupRun = createFollowupRun();
|
| 547 |
+
followupRun.run.provider = "claude-cli";
|
| 548 |
+
followupRun.run.model = "claude-opus-4-6";
|
| 549 |
+
followupRun.run.silentExpected = true;
|
| 550 |
+
|
| 551 |
+
await executeAgentTurn({
|
| 552 |
+
commandBody: "hi",
|
| 553 |
+
followupRun,
|
| 554 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 555 |
+
opts: { onPartialReply },
|
| 556 |
+
typingSignals: createMockTypingSignaler(),
|
| 557 |
+
...createAgentTurnExecutionDefaults(),
|
| 558 |
+
});
|
| 559 |
+
await new Promise((resolve) => {
|
| 560 |
+
setImmediate(resolve);
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
expect(onPartialReply).not.toHaveBeenCalled();
|
| 564 |
+
});
|
| 565 |
+
|
| 566 |
+
it("bridges CLI thinking agent events into onReasoningStream with the reasoning opt-in gate", async () => {
|
| 567 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 568 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 569 |
+
result: await params.run(
|
| 570 |
+
"claude-cli",
|
| 571 |
+
"claude-opus-4-7",
|
| 572 |
+
initialFallbackAttemptOptions(params),
|
| 573 |
+
),
|
| 574 |
+
provider: "claude-cli",
|
| 575 |
+
model: "claude-opus-4-7",
|
| 576 |
+
attempts: [],
|
| 577 |
+
}));
|
| 578 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 579 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 580 |
+
"../../infra/agent-events.js",
|
| 581 |
+
);
|
| 582 |
+
realAgentEvents.emitAgentEvent({
|
| 583 |
+
runId: params.runId,
|
| 584 |
+
stream: "thinking",
|
| 585 |
+
data: { text: "Thinking", delta: "Thinking", isReasoningSnapshot: true },
|
| 586 |
+
});
|
| 587 |
+
realAgentEvents.emitAgentEvent({
|
| 588 |
+
runId: params.runId,
|
| 589 |
+
stream: "thinking",
|
| 590 |
+
data: { text: "Thinking", delta: "", isReasoningSnapshot: true },
|
| 591 |
+
});
|
| 592 |
+
realAgentEvents.emitAgentEvent({
|
| 593 |
+
runId: params.runId,
|
| 594 |
+
stream: "thinking",
|
| 595 |
+
data: { text: "Thinking about it", delta: " about it", isReasoningSnapshot: true },
|
| 596 |
+
});
|
| 597 |
+
return { payloads: [{ text: "Thinking about it" }], meta: {} };
|
| 598 |
+
});
|
| 599 |
+
|
| 600 |
+
const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
|
| 601 |
+
async (_payload) => undefined,
|
| 602 |
+
);
|
| 603 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 604 |
+
const followupRun = createFollowupRun();
|
| 605 |
+
followupRun.run.provider = "claude-cli";
|
| 606 |
+
followupRun.run.model = "claude-opus-4-7";
|
| 607 |
+
|
| 608 |
+
await executeAgentTurn({
|
| 609 |
+
commandBody: "hi",
|
| 610 |
+
followupRun,
|
| 611 |
+
sessionCtx: {
|
| 612 |
+
Provider: "telegram",
|
| 613 |
+
MessageSid: "msg",
|
| 614 |
+
} as unknown as TemplateContext,
|
| 615 |
+
opts: { onReasoningStream },
|
| 616 |
+
typingSignals: createMockTypingSignaler(),
|
| 617 |
+
...createAgentTurnExecutionDefaults(),
|
| 618 |
+
});
|
| 619 |
+
|
| 620 |
+
expect(onReasoningStream.mock.calls.map((call) => call[0])).toEqual([
|
| 621 |
+
{
|
| 622 |
+
text: "Thinking",
|
| 623 |
+
isReasoningSnapshot: true,
|
| 624 |
+
requiresReasoningProgressOptIn: true,
|
| 625 |
+
},
|
| 626 |
+
{
|
| 627 |
+
text: "Thinking about it",
|
| 628 |
+
isReasoningSnapshot: true,
|
| 629 |
+
requiresReasoningProgressOptIn: true,
|
| 630 |
+
},
|
| 631 |
+
]);
|
| 632 |
+
});
|
| 633 |
+
|
| 634 |
+
it("bridges tagged Claude CLI reasoning separately from its visible answer", async () => {
|
| 635 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 636 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 637 |
+
result: await params.run(
|
| 638 |
+
"claude-cli",
|
| 639 |
+
"claude-opus-4-7",
|
| 640 |
+
initialFallbackAttemptOptions(params),
|
| 641 |
+
),
|
| 642 |
+
provider: "claude-cli",
|
| 643 |
+
model: "claude-opus-4-7",
|
| 644 |
+
attempts: [],
|
| 645 |
+
}));
|
| 646 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 647 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 648 |
+
"../../infra/agent-events.js",
|
| 649 |
+
);
|
| 650 |
+
const parser = createCliJsonlStreamingParser({
|
| 651 |
+
backend: {
|
| 652 |
+
command: "local-cli",
|
| 653 |
+
output: "jsonl",
|
| 654 |
+
jsonlDialect: "claude-stream-json",
|
| 655 |
+
},
|
| 656 |
+
providerId: "claude-cli",
|
| 657 |
+
onAssistantDelta: (delta) =>
|
| 658 |
+
realAgentEvents.emitAgentEvent({
|
| 659 |
+
runId: params.runId,
|
| 660 |
+
stream: "assistant",
|
| 661 |
+
data: delta,
|
| 662 |
+
}),
|
| 663 |
+
onThinkingDelta: (delta) =>
|
| 664 |
+
realAgentEvents.emitAgentEvent({
|
| 665 |
+
runId: params.runId,
|
| 666 |
+
stream: "thinking",
|
| 667 |
+
data: delta,
|
| 668 |
+
}),
|
| 669 |
+
});
|
| 670 |
+
parser.push(
|
| 671 |
+
[
|
| 672 |
+
JSON.stringify({
|
| 673 |
+
type: "stream_event",
|
| 674 |
+
event: {
|
| 675 |
+
type: "content_block_delta",
|
| 676 |
+
delta: {
|
| 677 |
+
type: "text_delta",
|
| 678 |
+
text: "<thinking>Private analysis.</thinking>Visible answer.",
|
| 679 |
+
},
|
| 680 |
+
},
|
| 681 |
+
}),
|
| 682 |
+
JSON.stringify({
|
| 683 |
+
type: "result",
|
| 684 |
+
result: "<thinking>Private analysis.</thinking>Visible answer.",
|
| 685 |
+
}),
|
| 686 |
+
"",
|
| 687 |
+
].join("\n"),
|
| 688 |
+
);
|
| 689 |
+
parser.finish();
|
| 690 |
+
return { payloads: [{ text: parser.getOutput()?.text ?? "" }], meta: {} };
|
| 691 |
+
});
|
| 692 |
+
|
| 693 |
+
const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
|
| 694 |
+
async () => undefined,
|
| 695 |
+
);
|
| 696 |
+
const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
|
| 697 |
+
async () => undefined,
|
| 698 |
+
);
|
| 699 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 700 |
+
const followupRun = createFollowupRun();
|
| 701 |
+
followupRun.run.provider = "claude-cli";
|
| 702 |
+
followupRun.run.model = "claude-opus-4-7";
|
| 703 |
+
|
| 704 |
+
await executeAgentTurn({
|
| 705 |
+
commandBody: "hi",
|
| 706 |
+
followupRun,
|
| 707 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 708 |
+
opts: { onPartialReply, onReasoningStream },
|
| 709 |
+
typingSignals: createMockTypingSignaler(),
|
| 710 |
+
...createAgentTurnExecutionDefaults(),
|
| 711 |
+
});
|
| 712 |
+
|
| 713 |
+
expect(onReasoningStream.mock.calls.map(([payload]) => payload.text)).toEqual([
|
| 714 |
+
"Private analysis.",
|
| 715 |
+
]);
|
| 716 |
+
expect(onPartialReply.mock.calls.map(([payload]) => payload.text)).toEqual(["Visible answer."]);
|
| 717 |
+
});
|
| 718 |
+
|
| 719 |
+
it("does not bridge CLI thinking events to onReasoningStream when silentExpected is set", async () => {
|
| 720 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 721 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 722 |
+
result: await params.run(
|
| 723 |
+
"claude-cli",
|
| 724 |
+
"claude-opus-4-7",
|
| 725 |
+
initialFallbackAttemptOptions(params),
|
| 726 |
+
),
|
| 727 |
+
provider: "claude-cli",
|
| 728 |
+
model: "claude-opus-4-7",
|
| 729 |
+
attempts: [],
|
| 730 |
+
}));
|
| 731 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 732 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 733 |
+
"../../infra/agent-events.js",
|
| 734 |
+
);
|
| 735 |
+
realAgentEvents.emitAgentEvent({
|
| 736 |
+
runId: params.runId,
|
| 737 |
+
stream: "thinking",
|
| 738 |
+
data: { text: "heartbeat scratch text", delta: "heartbeat scratch text" },
|
| 739 |
+
});
|
| 740 |
+
realAgentEvents.emitAgentEvent({
|
| 741 |
+
runId: params.runId,
|
| 742 |
+
stream: "thinking",
|
| 743 |
+
data: { text: "NO_REPLY do not preview reasoning", delta: " do not preview reasoning" },
|
| 744 |
+
});
|
| 745 |
+
return { payloads: [{ text: "final" }], meta: {} };
|
| 746 |
+
});
|
| 747 |
+
|
| 748 |
+
const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
|
| 749 |
+
async (_payload) => undefined,
|
| 750 |
+
);
|
| 751 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 752 |
+
const followupRun = createFollowupRun();
|
| 753 |
+
followupRun.run.provider = "claude-cli";
|
| 754 |
+
followupRun.run.model = "claude-opus-4-7";
|
| 755 |
+
followupRun.run.silentExpected = true;
|
| 756 |
+
|
| 757 |
+
await executeAgentTurn({
|
| 758 |
+
commandBody: "hi",
|
| 759 |
+
followupRun,
|
| 760 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 761 |
+
opts: { onReasoningStream },
|
| 762 |
+
typingSignals: createMockTypingSignaler(),
|
| 763 |
+
...createAgentTurnExecutionDefaults(),
|
| 764 |
+
});
|
| 765 |
+
await new Promise((resolve) => {
|
| 766 |
+
setImmediate(resolve);
|
| 767 |
+
});
|
| 768 |
+
|
| 769 |
+
expect(onReasoningStream).not.toHaveBeenCalled();
|
| 770 |
+
});
|
| 771 |
+
|
| 772 |
+
it("does not bridge non-Claude CLI assistant events to onReasoningStream", async () => {
|
| 773 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 774 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 775 |
+
result: await params.run("codex-cli", "gpt-5.5", initialFallbackAttemptOptions(params)),
|
| 776 |
+
provider: "codex-cli",
|
| 777 |
+
model: "gpt-5.5",
|
| 778 |
+
attempts: [],
|
| 779 |
+
}));
|
| 780 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
|
| 781 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 782 |
+
"../../infra/agent-events.js",
|
| 783 |
+
);
|
| 784 |
+
realAgentEvents.emitAgentEvent({
|
| 785 |
+
runId: params.runId,
|
| 786 |
+
stream: "assistant",
|
| 787 |
+
data: { text: "final answer", delta: "final answer" },
|
| 788 |
+
});
|
| 789 |
+
return { payloads: [{ text: "final answer" }], meta: {} };
|
| 790 |
+
});
|
| 791 |
+
|
| 792 |
+
const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
|
| 793 |
+
async (_payload) => undefined,
|
| 794 |
+
);
|
| 795 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 796 |
+
const followupRun = createFollowupRun();
|
| 797 |
+
followupRun.run.provider = "codex-cli";
|
| 798 |
+
followupRun.run.model = "gpt-5.5";
|
| 799 |
+
|
| 800 |
+
await executeAgentTurn({
|
| 801 |
+
commandBody: "hi",
|
| 802 |
+
followupRun,
|
| 803 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 804 |
+
opts: { onReasoningStream },
|
| 805 |
+
typingSignals: createMockTypingSignaler(),
|
| 806 |
+
...createAgentTurnExecutionDefaults(),
|
| 807 |
+
});
|
| 808 |
+
await new Promise((resolve) => {
|
| 809 |
+
setImmediate(resolve);
|
| 810 |
+
});
|
| 811 |
+
|
| 812 |
+
expect(onReasoningStream).not.toHaveBeenCalled();
|
| 813 |
+
});
|
| 814 |
+
|
| 815 |
+
it("does not double-fire onReasoningStream from the bridge when the API/native runtime path is active", async () => {
|
| 816 |
+
state.isCliProviderMock.mockReturnValue(false);
|
| 817 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 818 |
+
result: await params.run(
|
| 819 |
+
"anthropic",
|
| 820 |
+
"claude-sonnet-4-7",
|
| 821 |
+
initialFallbackAttemptOptions(params),
|
| 822 |
+
),
|
| 823 |
+
provider: "anthropic",
|
| 824 |
+
model: "claude-sonnet-4-7",
|
| 825 |
+
attempts: [],
|
| 826 |
+
}));
|
| 827 |
+
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
|
| 828 |
+
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
|
| 829 |
+
"../../infra/agent-events.js",
|
| 830 |
+
);
|
| 831 |
+
realAgentEvents.emitAgentEvent({
|
| 832 |
+
runId: "api-run",
|
| 833 |
+
stream: "assistant",
|
| 834 |
+
data: { text: "assistant text from API run", delta: "assistant text from API run" },
|
| 835 |
+
});
|
| 836 |
+
await params.onAgentEvent?.({
|
| 837 |
+
stream: "assistant",
|
| 838 |
+
data: { text: "assistant text from API run", delta: "assistant text from API run" },
|
| 839 |
+
});
|
| 840 |
+
return { payloads: [{ text: "final" }], meta: {} };
|
| 841 |
+
});
|
| 842 |
+
|
| 843 |
+
const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
|
| 844 |
+
async (_payload) => undefined,
|
| 845 |
+
);
|
| 846 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 847 |
+
const followupRun = createFollowupRun();
|
| 848 |
+
followupRun.run.provider = "anthropic";
|
| 849 |
+
followupRun.run.model = "claude-sonnet-4-7";
|
| 850 |
+
|
| 851 |
+
await executeAgentTurn({
|
| 852 |
+
commandBody: "hi",
|
| 853 |
+
followupRun,
|
| 854 |
+
sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
|
| 855 |
+
opts: { onReasoningStream, runId: "api-run" },
|
| 856 |
+
typingSignals: createMockTypingSignaler(),
|
| 857 |
+
...createAgentTurnExecutionDefaults(),
|
| 858 |
+
});
|
| 859 |
+
await new Promise((resolve) => {
|
| 860 |
+
setImmediate(resolve);
|
| 861 |
+
});
|
| 862 |
+
|
| 863 |
+
expect(onReasoningStream).not.toHaveBeenCalled();
|
| 864 |
+
});
|
| 865 |
+
|
| 866 |
+
it("preserves embedded reasoning stream opt-in markers", async () => {
|
| 867 |
+
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
|
| 868 |
+
await params.onReasoningStream?.({ text: "stream thought" });
|
| 869 |
+
await params.onReasoningStream?.({
|
| 870 |
+
text: "ambient thought",
|
| 871 |
+
requiresReasoningProgressOptIn: true,
|
| 872 |
+
});
|
| 873 |
+
return { payloads: [{ text: "final" }], meta: {} };
|
| 874 |
+
});
|
| 875 |
+
|
| 876 |
+
const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
|
| 877 |
+
async (_payload) => undefined,
|
| 878 |
+
);
|
| 879 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 880 |
+
|
| 881 |
+
await executeAgentTurn(
|
| 882 |
+
createMinimalRunAgentTurnParams({
|
| 883 |
+
opts: { onReasoningStream },
|
| 884 |
+
}),
|
| 885 |
+
);
|
| 886 |
+
|
| 887 |
+
expect(
|
| 888 |
+
onReasoningStream.mock.calls.map(([payload]) => ({
|
| 889 |
+
text: payload.text,
|
| 890 |
+
requiresReasoningProgressOptIn: payload.requiresReasoningProgressOptIn,
|
| 891 |
+
})),
|
| 892 |
+
).toEqual([
|
| 893 |
+
{ text: "stream thought", requiresReasoningProgressOptIn: undefined },
|
| 894 |
+
{ text: "ambient thought", requiresReasoningProgressOptIn: true },
|
| 895 |
+
]);
|
| 896 |
+
});
|
| 897 |
+
});
|
src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from "node:path";
|
| 2 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 3 |
+
import { prepareCliPromptImagePayload } from "../../agents/cli-runner/helpers.js";
|
| 4 |
+
import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
|
| 5 |
+
import { detectAndLoadPromptImages } from "../../agents/embedded-agent-runner/run/images.js";
|
| 6 |
+
import { FailoverError } from "../../agents/failover-error.js";
|
| 7 |
+
import { installSessionPlacementAdmissionProvider } from "../../agents/session-placement-admission.js";
|
| 8 |
+
import type { SessionEntry } from "../../config/sessions.js";
|
| 9 |
+
import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
|
| 10 |
+
import { registerGeneratedMediaTaskActivity } from "../../tasks/generated-media-task-activity.js";
|
| 11 |
+
import { resetGeneratedMediaTaskActivityForTests } from "../../tasks/task-runtime.test-helpers.js";
|
| 12 |
+
import type { TemplateContext } from "../templating.js";
|
| 13 |
+
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
| 14 |
+
import {
|
| 15 |
+
setupAgentRunnerExecutionTestState,
|
| 16 |
+
getExecuteAgentTurnForTest,
|
| 17 |
+
createFollowupRun,
|
| 18 |
+
createTestUserTurnRecorder,
|
| 19 |
+
requireRecord,
|
| 20 |
+
requireMockCall,
|
| 21 |
+
expectMockCallArgFields,
|
| 22 |
+
initialFallbackAttemptOptions,
|
| 23 |
+
createMinimalRunAgentTurnParams,
|
| 24 |
+
createRunAgentTurnParams,
|
| 25 |
+
makeTestSessionStorePath,
|
| 26 |
+
} from "./agent-runner-execution.test-support.js";
|
| 27 |
+
import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
|
| 28 |
+
|
| 29 |
+
const state = await setupAgentRunnerExecutionTestState();
|
| 30 |
+
afterEach(resetGeneratedMediaTaskActivityForTests);
|
| 31 |
+
|
| 32 |
+
function rejectUnexpectedCompactionSuccessor(): never {
|
| 33 |
+
throw new Error("Unexpected compaction successor during CLI session routing test");
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
describe("executeAgentTurn: CLI session routing", () => {
|
| 37 |
+
it("carries prepared model and thread context facts into CLI execution", async () => {
|
| 38 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 39 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 40 |
+
result: await params.run(
|
| 41 |
+
"claude-cli",
|
| 42 |
+
"claude-sonnet-4-6",
|
| 43 |
+
initialFallbackAttemptOptions(params),
|
| 44 |
+
),
|
| 45 |
+
provider: "claude-cli",
|
| 46 |
+
model: "claude-sonnet-4-6",
|
| 47 |
+
attempts: [],
|
| 48 |
+
}));
|
| 49 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 50 |
+
payloads: [{ text: "done" }],
|
| 51 |
+
meta: {},
|
| 52 |
+
});
|
| 53 |
+
const followupRun = createFollowupRun();
|
| 54 |
+
followupRun.originatingThreadId = 42;
|
| 55 |
+
followupRun.run.provider = "claude-cli";
|
| 56 |
+
followupRun.run.model = "claude-sonnet-4-6";
|
| 57 |
+
followupRun.run.thinkingCatalog = [
|
| 58 |
+
{
|
| 59 |
+
provider: "claude-cli",
|
| 60 |
+
id: "claude-sonnet-4-6",
|
| 61 |
+
contextWindow: 400_000,
|
| 62 |
+
contextTokens: 321_000,
|
| 63 |
+
input: ["text", "image"],
|
| 64 |
+
},
|
| 65 |
+
];
|
| 66 |
+
|
| 67 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 68 |
+
const result = await executeAgentTurn(
|
| 69 |
+
createMinimalRunAgentTurnParams({
|
| 70 |
+
followupRun,
|
| 71 |
+
sessionCtx: {
|
| 72 |
+
Provider: "telegram",
|
| 73 |
+
MessageSid: "msg",
|
| 74 |
+
MessageThreadId: "stale-topic",
|
| 75 |
+
} as unknown as TemplateContext,
|
| 76 |
+
}),
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
expect(result.kind).toBe("success");
|
| 80 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 81 |
+
modelContextWindow: 400_000,
|
| 82 |
+
modelContextTokens: 321_000,
|
| 83 |
+
currentThreadTs: "42",
|
| 84 |
+
});
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
it("preserves queued image fields from runs created before the prepared marker", async () => {
|
| 88 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 89 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 90 |
+
result: await params.run(
|
| 91 |
+
"claude-cli",
|
| 92 |
+
"claude-opus-5",
|
| 93 |
+
initialFallbackAttemptOptions(params),
|
| 94 |
+
),
|
| 95 |
+
provider: "claude-cli",
|
| 96 |
+
model: "claude-opus-5",
|
| 97 |
+
attempts: [],
|
| 98 |
+
}));
|
| 99 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 100 |
+
payloads: [{ text: "described" }],
|
| 101 |
+
meta: {},
|
| 102 |
+
});
|
| 103 |
+
const images = [
|
| 104 |
+
{
|
| 105 |
+
type: "image" as const,
|
| 106 |
+
data: "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVR4nGP4z8Dwn4GBgYGJAQoAHxcCAr7cGDwAAAAASUVORK5CYII=",
|
| 107 |
+
mimeType: "image/png",
|
| 108 |
+
},
|
| 109 |
+
];
|
| 110 |
+
const imageOrder = ["inline" as const];
|
| 111 |
+
const followupRun = createFollowupRun();
|
| 112 |
+
followupRun.run.provider = "claude-cli";
|
| 113 |
+
followupRun.run.model = "claude-opus-5";
|
| 114 |
+
followupRun.images = images;
|
| 115 |
+
followupRun.imageOrder = imageOrder;
|
| 116 |
+
|
| 117 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 118 |
+
const result = await executeAgentTurn(
|
| 119 |
+
createMinimalRunAgentTurnParams({
|
| 120 |
+
followupRun,
|
| 121 |
+
sessionCtx: {
|
| 122 |
+
Provider: "telegram",
|
| 123 |
+
MessageSid: "msg",
|
| 124 |
+
} as unknown as TemplateContext,
|
| 125 |
+
}),
|
| 126 |
+
);
|
| 127 |
+
|
| 128 |
+
expect(result.kind).toBe("success");
|
| 129 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 130 |
+
images,
|
| 131 |
+
imageOrder,
|
| 132 |
+
});
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
it("keeps prepared current-turn images aligned with CLI media facts", async () => {
|
| 136 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 137 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 138 |
+
result: await params.run(
|
| 139 |
+
"claude-cli",
|
| 140 |
+
"claude-opus-5",
|
| 141 |
+
initialFallbackAttemptOptions(params),
|
| 142 |
+
),
|
| 143 |
+
provider: "claude-cli",
|
| 144 |
+
model: "claude-opus-5",
|
| 145 |
+
attempts: [],
|
| 146 |
+
}));
|
| 147 |
+
const images = [
|
| 148 |
+
{
|
| 149 |
+
type: "image" as const,
|
| 150 |
+
data: "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVR4nGP4z8Dwn4GBgYGJAQoAHxcCAr7cGDwAAAAASUVORK5CYII=",
|
| 151 |
+
mimeType: "image/png",
|
| 152 |
+
},
|
| 153 |
+
];
|
| 154 |
+
const imageOrder = ["inline" as const];
|
| 155 |
+
const media = [{ path: "/openclaw-test-missing/current.png", contentType: "image/png" }];
|
| 156 |
+
const mediaImageLayout = {
|
| 157 |
+
slots: [{ kind: "inline" as const, factIndex: 0 }],
|
| 158 |
+
suppressedFactIndexes: [],
|
| 159 |
+
};
|
| 160 |
+
state.runCliAgentMock.mockImplementationOnce(async (params: RunCliAgentParams) => {
|
| 161 |
+
expect(params.modelHasVision).toBe(true);
|
| 162 |
+
const internalParams = params as RunCliAgentParams & {
|
| 163 |
+
mediaImageLayout?: typeof mediaImageLayout;
|
| 164 |
+
};
|
| 165 |
+
await expect(
|
| 166 |
+
prepareCliPromptImagePayload({
|
| 167 |
+
backend: { command: "claude" },
|
| 168 |
+
prompt: params.prompt,
|
| 169 |
+
imagePrompt: params.prompt,
|
| 170 |
+
workspaceDir: params.workspaceDir,
|
| 171 |
+
images: [...images, ...images],
|
| 172 |
+
imageOrder: [...imageOrder, ...imageOrder],
|
| 173 |
+
media,
|
| 174 |
+
}),
|
| 175 |
+
).rejects.toThrow("failed to hydrate 1 structured image attachment");
|
| 176 |
+
|
| 177 |
+
const reconciled = await detectAndLoadPromptImages({
|
| 178 |
+
prompt: params.prompt,
|
| 179 |
+
media: params.media,
|
| 180 |
+
workspaceDir: params.workspaceDir,
|
| 181 |
+
model: { input: ["text", "image"] },
|
| 182 |
+
existingImages: params.images,
|
| 183 |
+
imageOrder: params.imageOrder,
|
| 184 |
+
mediaImageLayout: internalParams.mediaImageLayout,
|
| 185 |
+
});
|
| 186 |
+
expect(reconciled).toMatchObject({
|
| 187 |
+
failedMediaCount: 0,
|
| 188 |
+
images,
|
| 189 |
+
});
|
| 190 |
+
return {
|
| 191 |
+
payloads: [{ text: "described" }],
|
| 192 |
+
meta: {},
|
| 193 |
+
};
|
| 194 |
+
});
|
| 195 |
+
const followupRun = createFollowupRun();
|
| 196 |
+
followupRun.run.provider = "claude-cli";
|
| 197 |
+
followupRun.run.model = "claude-opus-5";
|
| 198 |
+
followupRun.run.thinkingCatalog = [
|
| 199 |
+
{
|
| 200 |
+
provider: "claude-cli",
|
| 201 |
+
id: "claude-opus-5",
|
| 202 |
+
input: ["text", "image"],
|
| 203 |
+
},
|
| 204 |
+
];
|
| 205 |
+
const preparedFollowupRun = followupRun as typeof followupRun & {
|
| 206 |
+
currentTurnImagesPrepared?: true;
|
| 207 |
+
mediaImageLayout?: typeof mediaImageLayout;
|
| 208 |
+
};
|
| 209 |
+
preparedFollowupRun.currentTurnImagesPrepared = true;
|
| 210 |
+
preparedFollowupRun.mediaImageLayout = mediaImageLayout;
|
| 211 |
+
followupRun.images = images;
|
| 212 |
+
followupRun.imageOrder = imageOrder;
|
| 213 |
+
followupRun.media = media;
|
| 214 |
+
|
| 215 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 216 |
+
const result = await executeAgentTurn(
|
| 217 |
+
createMinimalRunAgentTurnParams({
|
| 218 |
+
followupRun,
|
| 219 |
+
sessionCtx: {
|
| 220 |
+
Provider: "telegram",
|
| 221 |
+
MessageSid: "msg",
|
| 222 |
+
media,
|
| 223 |
+
} as unknown as TemplateContext,
|
| 224 |
+
}),
|
| 225 |
+
);
|
| 226 |
+
|
| 227 |
+
expect(result.kind).toBe("success");
|
| 228 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 229 |
+
images,
|
| 230 |
+
imageOrder,
|
| 231 |
+
mediaImageLayout,
|
| 232 |
+
media,
|
| 233 |
+
});
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
it("forwards the static extra system prompt to CLI backends", async () => {
|
| 237 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 238 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 239 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 240 |
+
provider: "codex-cli",
|
| 241 |
+
model: "gpt-5.4",
|
| 242 |
+
attempts: [],
|
| 243 |
+
}));
|
| 244 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 245 |
+
payloads: [{ text: "final" }],
|
| 246 |
+
meta: {},
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 250 |
+
const followupRun = createFollowupRun();
|
| 251 |
+
followupRun.run.agentId = "main";
|
| 252 |
+
followupRun.run.provider = "codex-cli";
|
| 253 |
+
followupRun.run.model = "gpt-5.4";
|
| 254 |
+
followupRun.run.extraSystemPrompt = "dynamic inbound metadata\n\nstable group prompt";
|
| 255 |
+
followupRun.run.extraSystemPromptStatic = "stable group prompt";
|
| 256 |
+
followupRun.run.senderId = "sender-static";
|
| 257 |
+
followupRun.run.senderName = "Sender Static";
|
| 258 |
+
followupRun.run.senderUsername = "sender-static-user";
|
| 259 |
+
followupRun.run.senderE164 = "+15550002222";
|
| 260 |
+
followupRun.run.execOverrides = { host: "node", node: "mac-a" };
|
| 261 |
+
followupRun.run.bashElevated = {
|
| 262 |
+
enabled: true,
|
| 263 |
+
allowed: true,
|
| 264 |
+
defaultLevel: "full",
|
| 265 |
+
};
|
| 266 |
+
followupRun.run.groupId = "group-static";
|
| 267 |
+
followupRun.run.groupChannel = "ops";
|
| 268 |
+
followupRun.run.groupSpace = "workspace-static";
|
| 269 |
+
followupRun.run.spawnedBy = "agent:main:telegram:group:parent";
|
| 270 |
+
followupRun.run.runtimePolicySessionKey = "agent:main:telegram:default:direct:sender-static";
|
| 271 |
+
followupRun.originatingChannel = "telegram";
|
| 272 |
+
|
| 273 |
+
const result = await executeAgentTurn(createRunAgentTurnParams(followupRun));
|
| 274 |
+
|
| 275 |
+
expect(result.kind).toBe("success");
|
| 276 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 277 |
+
modelProvider: "codex-cli",
|
| 278 |
+
extraSystemPrompt: "dynamic inbound metadata\n\nstable group prompt",
|
| 279 |
+
extraSystemPromptStatic: "stable group prompt",
|
| 280 |
+
trigger: "user",
|
| 281 |
+
messageChannel: "telegram",
|
| 282 |
+
messageProvider: "telegram",
|
| 283 |
+
senderId: "sender-static",
|
| 284 |
+
senderName: "Sender Static",
|
| 285 |
+
senderUsername: "sender-static-user",
|
| 286 |
+
senderE164: "+15550002222",
|
| 287 |
+
execOverrides: { host: "node", node: "mac-a" },
|
| 288 |
+
bashElevated: { enabled: true, allowed: true, defaultLevel: "full" },
|
| 289 |
+
groupId: "group-static",
|
| 290 |
+
groupChannel: "ops",
|
| 291 |
+
groupSpace: "workspace-static",
|
| 292 |
+
spawnedBy: "agent:main:telegram:group:parent",
|
| 293 |
+
runtimePolicySessionKey: "agent:main:telegram:default:direct:sender-static",
|
| 294 |
+
});
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
it("passes silent empty-reply policy to CLI backends for message-tool-only turns", async () => {
|
| 298 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 299 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 300 |
+
result: await params.run(
|
| 301 |
+
"claude-cli",
|
| 302 |
+
"claude-sonnet-4-6",
|
| 303 |
+
initialFallbackAttemptOptions(params),
|
| 304 |
+
),
|
| 305 |
+
provider: "claude-cli",
|
| 306 |
+
model: "claude-sonnet-4-6",
|
| 307 |
+
attempts: [],
|
| 308 |
+
}));
|
| 309 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 310 |
+
payloads: [{ text: SILENT_REPLY_TOKEN }],
|
| 311 |
+
meta: { executionTrace: { fallbackUsed: false } },
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 315 |
+
const followupRun = createFollowupRun();
|
| 316 |
+
followupRun.run.provider = "claude-cli";
|
| 317 |
+
followupRun.run.model = "claude-sonnet-4-6";
|
| 318 |
+
followupRun.run.sourceReplyDeliveryMode = "message_tool_only";
|
| 319 |
+
followupRun.run.allowEmptyAssistantReplyAsSilent = true;
|
| 320 |
+
followupRun.originatingChannel = "telegram";
|
| 321 |
+
|
| 322 |
+
const result = await executeAgentTurn(
|
| 323 |
+
createMinimalRunAgentTurnParams({
|
| 324 |
+
followupRun,
|
| 325 |
+
sessionCtx: {
|
| 326 |
+
Provider: "telegram",
|
| 327 |
+
MessageSid: "msg",
|
| 328 |
+
ChatType: "group",
|
| 329 |
+
} as unknown as TemplateContext,
|
| 330 |
+
}),
|
| 331 |
+
);
|
| 332 |
+
|
| 333 |
+
expect(result.kind).toBe("success");
|
| 334 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 335 |
+
provider: "claude-cli",
|
| 336 |
+
model: "claude-sonnet-4-6",
|
| 337 |
+
sourceReplyDeliveryMode: "message_tool_only",
|
| 338 |
+
allowEmptyAssistantReplyAsSilent: true,
|
| 339 |
+
messageChannel: "telegram",
|
| 340 |
+
messageProvider: "telegram",
|
| 341 |
+
});
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
it("passes prepared CLI user turns to the runtime persistence boundary", async () => {
|
| 345 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 346 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 347 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 348 |
+
provider: "codex-cli",
|
| 349 |
+
model: "gpt-5.4",
|
| 350 |
+
attempts: [],
|
| 351 |
+
}));
|
| 352 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 353 |
+
payloads: [{ text: "final" }],
|
| 354 |
+
meta: {},
|
| 355 |
+
});
|
| 356 |
+
|
| 357 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 358 |
+
const followupRun = createFollowupRun();
|
| 359 |
+
followupRun.run.provider = "codex-cli";
|
| 360 |
+
followupRun.run.model = "gpt-5.4";
|
| 361 |
+
const preparedUserTurnMessage = {
|
| 362 |
+
role: "user",
|
| 363 |
+
content: "describe this",
|
| 364 |
+
MediaPath: "/tmp/image.png",
|
| 365 |
+
MediaPaths: ["/tmp/image.png"],
|
| 366 |
+
MediaType: "image/png",
|
| 367 |
+
MediaTypes: ["image/png"],
|
| 368 |
+
} as never;
|
| 369 |
+
followupRun.userTurnTranscriptRecorder = createTestUserTurnRecorder(preparedUserTurnMessage);
|
| 370 |
+
const storePath = makeTestSessionStorePath();
|
| 371 |
+
const sessionEntry: SessionEntry = {
|
| 372 |
+
sessionId: "session",
|
| 373 |
+
sessionFile: path.join(path.dirname(storePath), "session.jsonl"),
|
| 374 |
+
updatedAt: 1,
|
| 375 |
+
};
|
| 376 |
+
await replaceSessionEntry({ sessionKey: "agent:main:main", storePath }, sessionEntry);
|
| 377 |
+
const activeSessionStore = { main: sessionEntry };
|
| 378 |
+
|
| 379 |
+
await replaceSessionEntry({ sessionKey: "main", storePath }, sessionEntry);
|
| 380 |
+
const result = await executeAgentTurn({
|
| 381 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 382 |
+
commandBody: "runtime prompt",
|
| 383 |
+
transcriptCommandBody: "display prompt",
|
| 384 |
+
activeSessionStore,
|
| 385 |
+
storePath,
|
| 386 |
+
getActiveSessionEntry: () => activeSessionStore.main,
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
expect(result.kind).toBe("success");
|
| 390 |
+
expect(state.runCliAgentMock).toHaveBeenCalledOnce();
|
| 391 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI runtime", {
|
| 392 |
+
sessionKey: "main",
|
| 393 |
+
agentId: "main",
|
| 394 |
+
sessionId: "session",
|
| 395 |
+
suppressNextUserMessagePersistence: false,
|
| 396 |
+
persistAssistantTranscript: true,
|
| 397 |
+
storePath,
|
| 398 |
+
sessionTarget: {
|
| 399 |
+
agentId: "main",
|
| 400 |
+
sessionId: "session",
|
| 401 |
+
sessionKey: "main",
|
| 402 |
+
storePath,
|
| 403 |
+
},
|
| 404 |
+
});
|
| 405 |
+
const call = requireMockCall(state.runCliAgentMock, 0, "CLI runtime");
|
| 406 |
+
const callParams = requireRecord(call[0], "CLI runtime");
|
| 407 |
+
expect(callParams.userTurnTranscriptRecorder).toEqual(expect.any(Object));
|
| 408 |
+
expect(requireRecord(callParams.userTurnTranscriptRecorder, "user turn recorder").message).toBe(
|
| 409 |
+
preparedUserTurnMessage,
|
| 410 |
+
);
|
| 411 |
+
expect(callParams.onUserMessagePersisted).toEqual(expect.any(Function));
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
it("reuses CLI sessions for room-event turns", async () => {
|
| 415 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 416 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 417 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 418 |
+
provider: "codex-cli",
|
| 419 |
+
model: "gpt-5.4",
|
| 420 |
+
attempts: [],
|
| 421 |
+
}));
|
| 422 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 423 |
+
payloads: [{ text: "ambient" }],
|
| 424 |
+
meta: {
|
| 425 |
+
agentMeta: {
|
| 426 |
+
sessionId: "existing-cli-session",
|
| 427 |
+
cliSessionBinding: {
|
| 428 |
+
sessionId: "existing-cli-session",
|
| 429 |
+
authProfileId: "profile",
|
| 430 |
+
},
|
| 431 |
+
},
|
| 432 |
+
},
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 436 |
+
const followupRun = createFollowupRun();
|
| 437 |
+
followupRun.currentInboundEventKind = "room_event";
|
| 438 |
+
followupRun.run.provider = "codex-cli";
|
| 439 |
+
followupRun.run.model = "gpt-5.4";
|
| 440 |
+
const sessionEntry = {
|
| 441 |
+
cliSessionBindings: {
|
| 442 |
+
"codex-cli": { sessionId: "existing-cli-session" },
|
| 443 |
+
},
|
| 444 |
+
} as unknown as SessionEntry;
|
| 445 |
+
const activeSessionStore = { main: sessionEntry };
|
| 446 |
+
|
| 447 |
+
const result = await executeAgentTurn({
|
| 448 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 449 |
+
activeSessionStore,
|
| 450 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 451 |
+
});
|
| 452 |
+
|
| 453 |
+
expect(result.kind).toBe("success");
|
| 454 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 455 |
+
currentInboundEventKind: "room_event",
|
| 456 |
+
persistAssistantTranscript: false,
|
| 457 |
+
cliSessionId: "existing-cli-session",
|
| 458 |
+
cliSessionBinding: {
|
| 459 |
+
sessionId: "existing-cli-session",
|
| 460 |
+
},
|
| 461 |
+
});
|
| 462 |
+
if (result.kind !== "success") {
|
| 463 |
+
throw new Error("expected success");
|
| 464 |
+
}
|
| 465 |
+
expect(result.runResult.meta?.agentMeta?.sessionId).toBe("existing-cli-session");
|
| 466 |
+
expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toEqual({
|
| 467 |
+
sessionId: "existing-cli-session",
|
| 468 |
+
authProfileId: "profile",
|
| 469 |
+
});
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
it("keeps the first CLI session created by a room-event turn", async () => {
|
| 473 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 474 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 475 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 476 |
+
provider: "codex-cli",
|
| 477 |
+
model: "gpt-5.4",
|
| 478 |
+
attempts: [],
|
| 479 |
+
}));
|
| 480 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 481 |
+
payloads: [{ text: "ambient" }],
|
| 482 |
+
meta: {
|
| 483 |
+
agentMeta: {
|
| 484 |
+
sessionId: "new-cli-session",
|
| 485 |
+
cliSessionBinding: {
|
| 486 |
+
sessionId: "new-cli-session",
|
| 487 |
+
authProfileId: "profile",
|
| 488 |
+
},
|
| 489 |
+
},
|
| 490 |
+
},
|
| 491 |
+
});
|
| 492 |
+
|
| 493 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 494 |
+
const followupRun = createFollowupRun();
|
| 495 |
+
followupRun.currentInboundEventKind = "room_event";
|
| 496 |
+
followupRun.run.provider = "codex-cli";
|
| 497 |
+
followupRun.run.model = "gpt-5.4";
|
| 498 |
+
const sessionEntry = {} as unknown as SessionEntry;
|
| 499 |
+
|
| 500 |
+
const result = await executeAgentTurn({
|
| 501 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 502 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 503 |
+
});
|
| 504 |
+
|
| 505 |
+
expect(result.kind).toBe("success");
|
| 506 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 507 |
+
currentInboundEventKind: "room_event",
|
| 508 |
+
cliSessionId: undefined,
|
| 509 |
+
cliSessionBinding: undefined,
|
| 510 |
+
});
|
| 511 |
+
if (result.kind !== "success") {
|
| 512 |
+
throw new Error("expected success");
|
| 513 |
+
}
|
| 514 |
+
expect(result.runResult.meta?.agentMeta?.sessionId).toBe("new-cli-session");
|
| 515 |
+
expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toEqual({
|
| 516 |
+
sessionId: "new-cli-session",
|
| 517 |
+
authProfileId: "profile",
|
| 518 |
+
});
|
| 519 |
+
});
|
| 520 |
+
|
| 521 |
+
it("drops replacement room-event CLI sessions when reuse fails", async () => {
|
| 522 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 523 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 524 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 525 |
+
provider: "codex-cli",
|
| 526 |
+
model: "gpt-5.4",
|
| 527 |
+
attempts: [],
|
| 528 |
+
}));
|
| 529 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 530 |
+
payloads: [{ text: "ambient" }],
|
| 531 |
+
meta: {
|
| 532 |
+
agentMeta: {
|
| 533 |
+
sessionId: "transient-cli-session",
|
| 534 |
+
cliSessionBinding: {
|
| 535 |
+
sessionId: "transient-cli-session",
|
| 536 |
+
authProfileId: "profile",
|
| 537 |
+
},
|
| 538 |
+
clearCliSessionBinding: true,
|
| 539 |
+
},
|
| 540 |
+
},
|
| 541 |
+
});
|
| 542 |
+
|
| 543 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 544 |
+
const followupRun = createFollowupRun();
|
| 545 |
+
followupRun.currentInboundEventKind = "room_event";
|
| 546 |
+
followupRun.run.provider = "codex-cli";
|
| 547 |
+
followupRun.run.model = "gpt-5.4";
|
| 548 |
+
const sessionEntry = {
|
| 549 |
+
cliSessionBindings: {
|
| 550 |
+
"codex-cli": { sessionId: "existing-cli-session" },
|
| 551 |
+
},
|
| 552 |
+
} as unknown as SessionEntry;
|
| 553 |
+
const activeSessionStore = { main: sessionEntry };
|
| 554 |
+
let cleanupObservedBeforePlacementRelease = false;
|
| 555 |
+
const restoreAdmission = installSessionPlacementAdmissionProvider({
|
| 556 |
+
assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
|
| 557 |
+
executeLocalTurn: async (_claim, runLocal) => {
|
| 558 |
+
const resultLocal = await runLocal();
|
| 559 |
+
expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toBeUndefined();
|
| 560 |
+
cleanupObservedBeforePlacementRelease = true;
|
| 561 |
+
return resultLocal;
|
| 562 |
+
},
|
| 563 |
+
executeTurn: async (_claim, _params, runLocal) => await runLocal(),
|
| 564 |
+
});
|
| 565 |
+
|
| 566 |
+
let result: Awaited<ReturnType<typeof executeAgentTurn>>;
|
| 567 |
+
try {
|
| 568 |
+
result = await executeAgentTurn({
|
| 569 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 570 |
+
activeSessionStore,
|
| 571 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 572 |
+
});
|
| 573 |
+
} finally {
|
| 574 |
+
restoreAdmission();
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
expect(result.kind).toBe("success");
|
| 578 |
+
expect(cleanupObservedBeforePlacementRelease).toBe(true);
|
| 579 |
+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
|
| 580 |
+
currentInboundEventKind: "room_event",
|
| 581 |
+
cliSessionId: "existing-cli-session",
|
| 582 |
+
cliSessionBinding: {
|
| 583 |
+
sessionId: "existing-cli-session",
|
| 584 |
+
},
|
| 585 |
+
});
|
| 586 |
+
if (result.kind !== "success") {
|
| 587 |
+
throw new Error("expected success");
|
| 588 |
+
}
|
| 589 |
+
expect(result.runResult.meta?.agentMeta?.sessionId).toBe("");
|
| 590 |
+
expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toBeUndefined();
|
| 591 |
+
expect(result.runResult.meta?.agentMeta?.clearCliSessionBinding).toBeUndefined();
|
| 592 |
+
expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toBeUndefined();
|
| 593 |
+
});
|
| 594 |
+
|
| 595 |
+
it("keeps room-event CLI bindings when synthetic hooks return no CLI binding", async () => {
|
| 596 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 597 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 598 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 599 |
+
provider: "codex-cli",
|
| 600 |
+
model: "gpt-5.4",
|
| 601 |
+
attempts: [],
|
| 602 |
+
}));
|
| 603 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 604 |
+
payloads: [{ text: "handled" }],
|
| 605 |
+
meta: {
|
| 606 |
+
agentMeta: {
|
| 607 |
+
sessionId: "openclaw-session",
|
| 608 |
+
provider: "codex-cli",
|
| 609 |
+
model: "gpt-5.4",
|
| 610 |
+
},
|
| 611 |
+
},
|
| 612 |
+
});
|
| 613 |
+
|
| 614 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 615 |
+
const followupRun = createFollowupRun();
|
| 616 |
+
followupRun.currentInboundEventKind = "room_event";
|
| 617 |
+
followupRun.run.provider = "codex-cli";
|
| 618 |
+
followupRun.run.model = "gpt-5.4";
|
| 619 |
+
const sessionEntry = {
|
| 620 |
+
cliSessionBindings: {
|
| 621 |
+
"codex-cli": { sessionId: "existing-cli-session" },
|
| 622 |
+
},
|
| 623 |
+
} as unknown as SessionEntry;
|
| 624 |
+
const activeSessionStore = { main: sessionEntry };
|
| 625 |
+
|
| 626 |
+
const result = await executeAgentTurn({
|
| 627 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 628 |
+
activeSessionStore,
|
| 629 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 630 |
+
});
|
| 631 |
+
|
| 632 |
+
expect(result.kind).toBe("success");
|
| 633 |
+
if (result.kind !== "success") {
|
| 634 |
+
throw new Error("expected success");
|
| 635 |
+
}
|
| 636 |
+
expect(result.runResult.meta?.agentMeta?.sessionId).toBe("");
|
| 637 |
+
expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toBeUndefined();
|
| 638 |
+
expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toEqual({
|
| 639 |
+
sessionId: "existing-cli-session",
|
| 640 |
+
});
|
| 641 |
+
});
|
| 642 |
+
|
| 643 |
+
it("clears room-event CLI bindings when an unflushed replacement is dropped", async () => {
|
| 644 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 645 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 646 |
+
result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
|
| 647 |
+
provider: "codex-cli",
|
| 648 |
+
model: "gpt-5.4",
|
| 649 |
+
attempts: [],
|
| 650 |
+
}));
|
| 651 |
+
state.runCliAgentMock.mockResolvedValueOnce({
|
| 652 |
+
payloads: [{ text: "handled" }],
|
| 653 |
+
meta: {
|
| 654 |
+
agentMeta: {
|
| 655 |
+
sessionId: "",
|
| 656 |
+
provider: "codex-cli",
|
| 657 |
+
model: "gpt-5.4",
|
| 658 |
+
clearCliSessionBinding: true,
|
| 659 |
+
},
|
| 660 |
+
},
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 664 |
+
const followupRun = createFollowupRun();
|
| 665 |
+
followupRun.currentInboundEventKind = "room_event";
|
| 666 |
+
followupRun.run.provider = "codex-cli";
|
| 667 |
+
followupRun.run.model = "gpt-5.4";
|
| 668 |
+
const sessionEntry = {
|
| 669 |
+
cliSessionBindings: {
|
| 670 |
+
"codex-cli": { sessionId: "existing-cli-session" },
|
| 671 |
+
},
|
| 672 |
+
} as unknown as SessionEntry;
|
| 673 |
+
const activeSessionStore = { main: sessionEntry };
|
| 674 |
+
|
| 675 |
+
const result = await executeAgentTurn({
|
| 676 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 677 |
+
activeSessionStore,
|
| 678 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 679 |
+
});
|
| 680 |
+
|
| 681 |
+
expect(result.kind).toBe("success");
|
| 682 |
+
if (result.kind !== "success") {
|
| 683 |
+
throw new Error("expected success");
|
| 684 |
+
}
|
| 685 |
+
expect(result.runResult.meta?.agentMeta?.sessionId).toBe("");
|
| 686 |
+
expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toBeUndefined();
|
| 687 |
+
expect(result.runResult.meta?.agentMeta?.clearCliSessionBinding).toBeUndefined();
|
| 688 |
+
expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toBeUndefined();
|
| 689 |
+
});
|
| 690 |
+
|
| 691 |
+
it("clears a fork-marked Claude CLI binding when the channel turn fails", async () => {
|
| 692 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 693 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 694 |
+
result: await params.run(
|
| 695 |
+
"claude-cli",
|
| 696 |
+
"claude-opus-4-8",
|
| 697 |
+
initialFallbackAttemptOptions(params),
|
| 698 |
+
),
|
| 699 |
+
provider: "claude-cli",
|
| 700 |
+
model: "claude-opus-4-8",
|
| 701 |
+
attempts: [],
|
| 702 |
+
}));
|
| 703 |
+
state.runCliAgentMock.mockRejectedValueOnce(
|
| 704 |
+
new FailoverError("No conversation found", {
|
| 705 |
+
reason: "session_expired",
|
| 706 |
+
provider: "claude-cli",
|
| 707 |
+
model: "claude-opus-4-8",
|
| 708 |
+
}),
|
| 709 |
+
);
|
| 710 |
+
|
| 711 |
+
const followupRun = createFollowupRun();
|
| 712 |
+
followupRun.run.provider = "claude-cli";
|
| 713 |
+
followupRun.run.model = "claude-opus-4-8";
|
| 714 |
+
const sessionEntry = {
|
| 715 |
+
sessionId: "openclaw-session",
|
| 716 |
+
updatedAt: 1,
|
| 717 |
+
cliSessionBindings: {
|
| 718 |
+
"claude-cli": { sessionId: "stale-cli-session", forkNextResume: true },
|
| 719 |
+
},
|
| 720 |
+
cliSessionIds: { "claude-cli": "stale-cli-session" },
|
| 721 |
+
claudeCliSessionId: "stale-cli-session",
|
| 722 |
+
} as SessionEntry;
|
| 723 |
+
const activeSessionStore = { main: sessionEntry };
|
| 724 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 725 |
+
|
| 726 |
+
const result = await executeAgentTurn({
|
| 727 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 728 |
+
activeSessionStore,
|
| 729 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 730 |
+
});
|
| 731 |
+
|
| 732 |
+
expect(result.kind).toBe("final");
|
| 733 |
+
expect(sessionEntry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
| 734 |
+
expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
| 735 |
+
expect(sessionEntry.claudeCliSessionId).toBeUndefined();
|
| 736 |
+
});
|
| 737 |
+
|
| 738 |
+
it("preserves a reused binding after a channel turn starts detached media", async () => {
|
| 739 |
+
const sessionKey = "agent:main:cron:media-job:run:run-1";
|
| 740 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 741 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 742 |
+
result: await params.run(
|
| 743 |
+
"claude-cli",
|
| 744 |
+
"claude-opus-4-8",
|
| 745 |
+
initialFallbackAttemptOptions(params),
|
| 746 |
+
),
|
| 747 |
+
provider: "claude-cli",
|
| 748 |
+
model: "claude-opus-4-8",
|
| 749 |
+
attempts: [],
|
| 750 |
+
}));
|
| 751 |
+
const abort = Object.assign(new Error("detached media continues"), { name: "AbortError" });
|
| 752 |
+
state.runCliAgentMock.mockImplementationOnce(async () => {
|
| 753 |
+
registerGeneratedMediaTaskActivity("tool:image_generate:run-1", sessionKey);
|
| 754 |
+
throw abort;
|
| 755 |
+
});
|
| 756 |
+
|
| 757 |
+
const followupRun = createFollowupRun();
|
| 758 |
+
followupRun.run.provider = "claude-cli";
|
| 759 |
+
followupRun.run.model = "claude-opus-4-8";
|
| 760 |
+
const sessionEntry = {
|
| 761 |
+
sessionId: "openclaw-session",
|
| 762 |
+
updatedAt: 1,
|
| 763 |
+
cliSessionBindings: { "claude-cli": { sessionId: "media-session" } },
|
| 764 |
+
cliSessionIds: { "claude-cli": "media-session" },
|
| 765 |
+
claudeCliSessionId: "media-session",
|
| 766 |
+
} as SessionEntry;
|
| 767 |
+
const activeSessionStore = { [sessionKey]: sessionEntry };
|
| 768 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 769 |
+
|
| 770 |
+
await executeAgentTurn({
|
| 771 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 772 |
+
sessionKey,
|
| 773 |
+
activeSessionStore,
|
| 774 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 775 |
+
});
|
| 776 |
+
|
| 777 |
+
expect(sessionEntry.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("media-session");
|
| 778 |
+
expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBe("media-session");
|
| 779 |
+
expect(sessionEntry.claudeCliSessionId).toBe("media-session");
|
| 780 |
+
});
|
| 781 |
+
|
| 782 |
+
it("preserves a reused binding after an operator aborts a channel turn", async () => {
|
| 783 |
+
const sessionKey = "agent:main:direct:aborted-run";
|
| 784 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 785 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 786 |
+
result: await params.run(
|
| 787 |
+
"claude-cli",
|
| 788 |
+
"claude-opus-4-8",
|
| 789 |
+
initialFallbackAttemptOptions(params),
|
| 790 |
+
),
|
| 791 |
+
provider: "claude-cli",
|
| 792 |
+
model: "claude-opus-4-8",
|
| 793 |
+
attempts: [],
|
| 794 |
+
}));
|
| 795 |
+
state.runCliAgentMock.mockRejectedValueOnce(
|
| 796 |
+
Object.assign(new Error("CLI run aborted"), { name: "AbortError" }),
|
| 797 |
+
);
|
| 798 |
+
|
| 799 |
+
const followupRun = createFollowupRun();
|
| 800 |
+
followupRun.run.provider = "claude-cli";
|
| 801 |
+
followupRun.run.model = "claude-opus-4-8";
|
| 802 |
+
const sessionEntry = {
|
| 803 |
+
sessionId: "openclaw-session",
|
| 804 |
+
updatedAt: 1,
|
| 805 |
+
cliSessionBindings: { "claude-cli": { sessionId: "aborted-session" } },
|
| 806 |
+
cliSessionIds: { "claude-cli": "aborted-session" },
|
| 807 |
+
claudeCliSessionId: "aborted-session",
|
| 808 |
+
} as SessionEntry;
|
| 809 |
+
const activeSessionStore = { [sessionKey]: sessionEntry };
|
| 810 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 811 |
+
|
| 812 |
+
await executeAgentTurn({
|
| 813 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 814 |
+
sessionKey,
|
| 815 |
+
activeSessionStore,
|
| 816 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 817 |
+
});
|
| 818 |
+
|
| 819 |
+
expect(sessionEntry.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("aborted-session");
|
| 820 |
+
expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBe("aborted-session");
|
| 821 |
+
expect(sessionEntry.claudeCliSessionId).toBe("aborted-session");
|
| 822 |
+
});
|
| 823 |
+
|
| 824 |
+
it("does not attribute media from an earlier admitted turn to a queued failure", async () => {
|
| 825 |
+
const sessionKey = "agent:main:cron:media-job:run:run-queued";
|
| 826 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 827 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 828 |
+
result: await params.run(
|
| 829 |
+
"claude-cli",
|
| 830 |
+
"claude-opus-4-8",
|
| 831 |
+
initialFallbackAttemptOptions(params),
|
| 832 |
+
),
|
| 833 |
+
provider: "claude-cli",
|
| 834 |
+
model: "claude-opus-4-8",
|
| 835 |
+
attempts: [],
|
| 836 |
+
}));
|
| 837 |
+
state.runCliAgentMock.mockRejectedValueOnce(
|
| 838 |
+
new FailoverError("queued session expired", {
|
| 839 |
+
reason: "session_expired",
|
| 840 |
+
provider: "claude-cli",
|
| 841 |
+
model: "claude-opus-4-8",
|
| 842 |
+
}),
|
| 843 |
+
);
|
| 844 |
+
|
| 845 |
+
let releaseAdmission!: () => void;
|
| 846 |
+
const admissionGate = new Promise<void>((resolve) => {
|
| 847 |
+
releaseAdmission = resolve;
|
| 848 |
+
});
|
| 849 |
+
let notifyAdmissionWait!: () => void;
|
| 850 |
+
const admissionWait = new Promise<void>((resolve) => {
|
| 851 |
+
notifyAdmissionWait = resolve;
|
| 852 |
+
});
|
| 853 |
+
const restoreAdmission = installSessionPlacementAdmissionProvider({
|
| 854 |
+
assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
|
| 855 |
+
executeLocalTurn: async (_claim, runLocal) => {
|
| 856 |
+
notifyAdmissionWait();
|
| 857 |
+
await admissionGate;
|
| 858 |
+
return await runLocal();
|
| 859 |
+
},
|
| 860 |
+
executeTurn: async (_claim, _params, runLocal) => await runLocal(),
|
| 861 |
+
});
|
| 862 |
+
|
| 863 |
+
const followupRun = createFollowupRun();
|
| 864 |
+
followupRun.run.provider = "claude-cli";
|
| 865 |
+
followupRun.run.model = "claude-opus-4-8";
|
| 866 |
+
const sessionEntry = {
|
| 867 |
+
sessionId: "openclaw-session",
|
| 868 |
+
updatedAt: 1,
|
| 869 |
+
cliSessionBindings: { "claude-cli": { sessionId: "queued-stale-session" } },
|
| 870 |
+
cliSessionIds: { "claude-cli": "queued-stale-session" },
|
| 871 |
+
claudeCliSessionId: "queued-stale-session",
|
| 872 |
+
} as SessionEntry;
|
| 873 |
+
const activeSessionStore = { [sessionKey]: sessionEntry };
|
| 874 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 875 |
+
|
| 876 |
+
try {
|
| 877 |
+
const runPromise = executeAgentTurn({
|
| 878 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 879 |
+
sessionKey,
|
| 880 |
+
activeSessionStore,
|
| 881 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 882 |
+
});
|
| 883 |
+
await admissionWait;
|
| 884 |
+
registerGeneratedMediaTaskActivity("tool:image_generate:earlier-turn", sessionKey);
|
| 885 |
+
releaseAdmission();
|
| 886 |
+
const result = await runPromise;
|
| 887 |
+
|
| 888 |
+
expect(result.kind).toBe("final");
|
| 889 |
+
expect(sessionEntry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
| 890 |
+
expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
| 891 |
+
expect(sessionEntry.claudeCliSessionId).toBeUndefined();
|
| 892 |
+
} finally {
|
| 893 |
+
releaseAdmission();
|
| 894 |
+
restoreAdmission();
|
| 895 |
+
}
|
| 896 |
+
});
|
| 897 |
+
|
| 898 |
+
it("clears a reused binding after the CLI reports an expired session", async () => {
|
| 899 |
+
state.isCliProviderMock.mockReturnValue(true);
|
| 900 |
+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
| 901 |
+
result: await params.run(
|
| 902 |
+
"claude-cli",
|
| 903 |
+
"claude-opus-4-8",
|
| 904 |
+
initialFallbackAttemptOptions(params),
|
| 905 |
+
),
|
| 906 |
+
provider: "claude-cli",
|
| 907 |
+
model: "claude-opus-4-8",
|
| 908 |
+
attempts: [],
|
| 909 |
+
}));
|
| 910 |
+
state.runCliAgentMock.mockRejectedValueOnce(
|
| 911 |
+
new FailoverError("No conversation found with session ID stale-cli-session", {
|
| 912 |
+
reason: "session_expired",
|
| 913 |
+
provider: "claude-cli",
|
| 914 |
+
model: "claude-opus-4-8",
|
| 915 |
+
}),
|
| 916 |
+
);
|
| 917 |
+
|
| 918 |
+
const followupRun = createFollowupRun();
|
| 919 |
+
followupRun.run.provider = "claude-cli";
|
| 920 |
+
followupRun.run.model = "claude-opus-4-8";
|
| 921 |
+
followupRun.run.skillsSnapshot = { prompt: "", skills: [], version: 0 };
|
| 922 |
+
followupRun.run.timeoutMs = 10_000;
|
| 923 |
+
const sessionEntry = {
|
| 924 |
+
sessionId: "openclaw-session",
|
| 925 |
+
updatedAt: 1,
|
| 926 |
+
cliSessionBindings: { "claude-cli": { sessionId: "stale-cli-session" } },
|
| 927 |
+
cliSessionIds: { "claude-cli": "stale-cli-session" },
|
| 928 |
+
claudeCliSessionId: "stale-cli-session",
|
| 929 |
+
} as SessionEntry;
|
| 930 |
+
const activeSessionStore = { main: sessionEntry };
|
| 931 |
+
const executeAgentTurn = await getExecuteAgentTurnForTest();
|
| 932 |
+
|
| 933 |
+
const result = await executeAgentTurn({
|
| 934 |
+
...createMinimalRunAgentTurnParams({ followupRun }),
|
| 935 |
+
activeSessionStore,
|
| 936 |
+
getActiveSessionEntry: () => sessionEntry,
|
| 937 |
+
});
|
| 938 |
+
|
| 939 |
+
expect(result.kind).toBe("final");
|
| 940 |
+
expect(state.runCliAgentMock.mock.calls[0]?.[0]).toMatchObject({
|
| 941 |
+
cliSessionId: "stale-cli-session",
|
| 942 |
+
});
|
| 943 |
+
expect(sessionEntry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
| 944 |
+
expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
| 945 |
+
expect(sessionEntry.claudeCliSessionId).toBeUndefined();
|
| 946 |
+
}, 15_000);
|
| 947 |
+
});
|