Compare commits

..

1 Commits

Author SHA1 Message Date
Kevin Lin
853ce969bf feat(codex): add on-request plugin approvals 2026-06-11 21:56:56 -07:00
162 changed files with 1668 additions and 7438 deletions

View File

@@ -288,7 +288,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
- name: Verify published runtime

View File

@@ -116,19 +116,11 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
fi && \
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
mkdir -p dist/extensions/qa-lab/web && \
rm -rf dist/extensions/qa-lab/web/dist && \
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
fi
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.

View File

@@ -1,2 +1,2 @@
ff7bd86cb1b243e0c94fdf9a74e7f985a7d73685b2b0cd0a8761972d145ca7a5 plugin-sdk-api-baseline.json
a65283a99e28a300adffa26ed171a3e8b215d9c95e8a1656fc5ae8fd7fc011c6 plugin-sdk-api-baseline.jsonl
8a2769df428906990ee0d1bf8b0423f2a099b053c64c816d092ff84d61e11633 plugin-sdk-api-baseline.json
28b798973f3fb2a5b33ccbb6e3c1ac0453fa234a3a1c6cdc27935c27639bd104 plugin-sdk-api-baseline.jsonl

View File

@@ -1374,6 +1374,7 @@
"pages": [
"clawhub/cli",
"clawhub/publishing",
"clawhub/plugin-validation-fixes",
"clawhub/skill-format",
"clawhub/auth",
"clawhub/telemetry",

View File

@@ -200,11 +200,12 @@ enabled.
OpenClaw sets app-level `destructive_enabled` from the effective global or
per-plugin `allow_destructive_actions` policy and lets Codex enforce
destructive tool metadata from its native app tool annotations. The `_default`
app config is disabled with `open_world_enabled: false`. Enabled plugin apps
are emitted with `open_world_enabled: true`; OpenClaw does not expose a separate
plugin open-world policy knob and does not maintain per-plugin destructive
tool-name deny lists.
destructive tool metadata from its native app tool annotations. `true` and
`"on-request"` both set `destructive_enabled: true`; `false` sets it false. The
`_default` app config is disabled with `open_world_enabled: false`. Enabled
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
expose a separate plugin open-world policy knob and does not maintain
per-plugin destructive tool-name deny lists.
Tool approval mode is automatic by default for plugin apps so non-destructive
read tools can run without a same-thread approval UI. Destructive tools remain
@@ -221,6 +222,9 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
- When policy is `false`, OpenClaw returns a deterministic decline.
- When policy is `true`, OpenClaw auto-accepts only safe schemas it can map to
an approval response, such as a boolean approve field.
- When policy is `"on-request"`, OpenClaw exposes destructive plugin actions to
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
plugin approvals before returning the Codex approval response.
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
id, or an unsafe elicitation schema declines instead of prompting.
@@ -268,8 +272,8 @@ Codex thread bindings keep the app config they started with until OpenClaw
establishes a new harness session or replaces a stale binding.
**Destructive action is declined:** check the global and per-plugin
`allow_destructive_actions` values. Even when policy is true, unsafe elicitation
schemas and ambiguous plugin identity still fail closed.
`allow_destructive_actions` values. Even when policy is true or `"on-request"`,
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
## Related

View File

@@ -3,6 +3,8 @@ import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-s
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
function createStreamDeps(): {
deps: AnthropicVertexStreamDeps;
streamAnthropicMock: ReturnType<typeof vi.fn>;
@@ -48,6 +50,8 @@ function makeModel(params: {
} as Model<"anthropic-messages">;
}
const CACHE_BOUNDARY_PROMPT = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`;
type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise<unknown>;
function streamAnthropicCall(streamAnthropicMock: ReturnType<typeof vi.fn>): unknown[] {
@@ -68,8 +72,8 @@ function streamTransportOptions(
return options as Record<string, unknown>;
}
function captureTransportPayloadHook(
onPayload: PayloadHook | undefined,
function captureCacheBoundaryPayloadHook(
onPayload: PayloadHook,
deps: AnthropicVertexStreamDeps,
streamAnthropicMock: ReturnType<typeof vi.fn>,
) {
@@ -78,8 +82,14 @@ function captureTransportPayloadHook(
void streamFn(
model,
{ messages: [{ role: "user", content: "Hello" }] } as never,
{ cacheRetention: "short", ...(onPayload ? { onPayload } : {}) } as never,
{
systemPrompt: CACHE_BOUNDARY_PROMPT,
messages: [{ role: "user", content: "Hello" }],
} as never,
{
cacheRetention: "short",
onPayload,
} as never,
);
const transportOptions = streamTransportOptions(streamAnthropicMock);
@@ -87,30 +97,26 @@ function captureTransportPayloadHook(
return { model, onPayload: transportOptions.onPayload as PayloadHook | undefined };
}
// Mirrors the shared anthropic-messages transport output: cache boundary already
// split (uncached dynamic suffix) and all four cache_control markers allocated.
function buildBudgetedTransportPayload() {
function buildExpectedCacheBoundaryPayload(messageText: string) {
return {
system: [
{ type: "text", text: "Stable prefix", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Dynamic suffix" },
],
tools: [
{ name: "exec", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } },
{
type: "text",
text: "Stable prefix",
cache_control: { type: "ephemeral" },
},
{
type: "text",
text: "Dynamic suffix",
},
],
messages: [
{
role: "user",
content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }],
},
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "exec", input: {} }] },
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "t1",
content: [],
type: "text",
text: messageText,
cache_control: { type: "ephemeral" },
},
],
@@ -119,29 +125,6 @@ function buildBudgetedTransportPayload() {
};
}
function countCacheControlMarkers(payload: unknown): number {
let count = 0;
const visit = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (!value || typeof value !== "object") {
return;
}
const record = value as Record<string, unknown>;
if (record.cache_control !== undefined) {
count += 1;
}
visit(record.content);
};
const record = payload as Record<string, unknown>;
visit(record.system);
visit(record.tools);
visit(record.messages);
return count;
}
describe("createAnthropicVertexStreamFn", () => {
beforeAll(async () => {
({ createAnthropicVertexStreamFn, createAnthropicVertexStreamFnForModel } =
@@ -360,35 +343,63 @@ describe("createAnthropicVertexStreamFn", () => {
expect(transportOptions).not.toHaveProperty("temperature");
});
it("keeps already-budgeted cache_control markers intact when forwarding payload hooks", async () => {
it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const onPayload = vi.fn(async (payload: unknown) => payload);
const { model, onPayload: transportPayloadHook } = captureTransportPayloadHook(
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
onPayload,
deps,
streamAnthropicMock,
);
const payload = buildBudgetedTransportPayload();
const payload = {
system: [
{
type: "text",
text: CACHE_BOUNDARY_PROMPT,
cache_control: { type: "ephemeral" },
},
],
messages: [{ role: "user", content: "Hello" }],
};
const nextPayload = await transportPayloadHook?.(payload, model);
expect(onPayload).toHaveBeenCalledWith(payload, model);
expect(countCacheControlMarkers(nextPayload)).toBe(4);
expect((nextPayload as ReturnType<typeof buildBudgetedTransportPayload>).system[1]).toEqual({
type: "text",
text: "Dynamic suffix",
});
const expectedPayload = buildExpectedCacheBoundaryPayload("Hello");
expect(onPayload).toHaveBeenCalledWith(expectedPayload, model);
expect(nextPayload).toEqual(expectedPayload);
});
it("omits the transport payload hook when the caller provides none", () => {
it("reapplies Anthropic cache-boundary shaping when payload hooks return a fresh payload", async () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const { onPayload: transportPayloadHook } = captureTransportPayloadHook(
undefined,
const onPayload = vi.fn(async () => ({
system: [
{
type: "text",
text: CACHE_BOUNDARY_PROMPT,
},
],
messages: [{ role: "user", content: "Hello again" }],
}));
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
onPayload,
deps,
streamAnthropicMock,
);
expect(transportPayloadHook).toBeUndefined();
const nextPayload = await transportPayloadHook?.(
{
system: [
{
type: "text",
text: CACHE_BOUNDARY_PROMPT,
},
],
messages: [{ role: "user", content: "Hello" }],
},
model,
);
expect(nextPayload).toEqual(buildExpectedCacheBoundaryPayload("Hello again"));
});
it("omits maxTokens when neither the model nor request provide a finite limit", () => {

View File

@@ -1,6 +1,6 @@
/**
* Anthropic Vertex stream runtime. It constructs Vertex SDK clients and adapts
* OpenClaw stream options for the shared Anthropic Messages transport.
* OpenClaw stream options into Anthropic Messages payload policy.
*/
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
@@ -18,6 +18,10 @@ import {
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js";
type AnthropicVertexTransportOptions = ProviderStreamOptions & {
@@ -116,6 +120,36 @@ function resolveAnthropicVertexMaxTokens(params: {
return requested ?? modelMax;
}
function createAnthropicVertexOnPayload(params: {
model: { api: string; baseUrl?: string; provider: string };
cacheRetention: ProviderStreamOptions["cacheRetention"] | undefined;
onPayload: ProviderStreamOptions["onPayload"] | undefined;
}): NonNullable<ProviderStreamOptions["onPayload"]> {
const policy = resolveAnthropicPayloadPolicy({
provider: params.model.provider,
api: params.model.api,
baseUrl: params.model.baseUrl,
cacheRetention: params.cacheRetention,
enableCacheControl: true,
});
function applyPolicy(payload: unknown): unknown {
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
applyAnthropicPayloadPolicyToParams(payload as Record<string, unknown>, policy);
}
return payload;
}
return async (payload, model) => {
const shapedPayload = applyPolicy(payload);
const nextPayload = await params.onPayload?.(shapedPayload, model);
if (nextPayload === undefined || nextPayload === shapedPayload) {
return shapedPayload;
}
return applyPolicy(nextPayload);
};
}
/**
* Create a StreamFn that routes through OpenClaw's generic model stream with an
* injected `AnthropicVertex` client. All streaming, message conversion, and
@@ -166,10 +200,11 @@ export function createAnthropicVertexStreamFn(
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
headers: options?.headers,
// The shared anthropic-messages transport already splits the system prompt
// cache boundary and budgets all cache_control markers; re-applying the
// payload policy here marked the uncached suffix and breached the 4-marker cap.
onPayload: options?.onPayload,
onPayload: createAnthropicVertexOnPayload({
model: transportModel,
cacheRetention: options?.cacheRetention,
onPayload: options?.onPayload,
}),
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
};

View File

@@ -461,24 +461,4 @@ describe("browser manage output", () => {
expect(output).toContain("OK gateway: browser control endpoint reachable");
expect(output).toContain("OK tabs: 1 visible, use tab reference t1");
});
it("prints a readable browser doctor failure when gateway auth SecretRefs are unavailable", async () => {
const error = Object.assign(new Error("gateway.auth.password unavailable"), {
code: "GATEWAY_SECRET_REF_UNAVAILABLE",
name: "GatewaySecretRefUnavailableError",
});
getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error);
const program = createBrowserManageProgram();
await expect(program.parseAsync(["browser", "doctor"], { from: "user" })).rejects.toThrow(
"__exit__:1",
);
const output = lastRuntimeLog();
expect(output).toContain(
"FAIL gateway: Gateway auth SecretRef is unavailable in this command path",
);
expect(output).toContain("OPENCLAW_GATEWAY_TOKEN");
expect(output).not.toContain("GatewaySecretRefUnavailableError");
});
});

View File

@@ -152,24 +152,6 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
return `${check.ok ? "OK" : "FAIL"} ${check.name}${check.detail ? `: ${check.detail}` : ""}`;
}
function isGatewaySecretRefUnavailableErrorShape(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const errorRecord = error as Error & { code?: unknown };
return (
errorRecord.name === "GatewaySecretRefUnavailableError" ||
errorRecord.code === "GATEWAY_SECRET_REF_UNAVAILABLE"
);
}
function formatBrowserDoctorGatewayError(error: unknown): string {
if (!isGatewaySecretRefUnavailableErrorShape(error)) {
return String(error);
}
return "Gateway auth SecretRef is unavailable in this command path; browser doctor cannot reach the admin-scoped browser.request endpoint. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD, then retry.";
}
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
const checks: BrowserDoctorCheck[] = [];
let status: BrowserStatus | null;
@@ -185,7 +167,7 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee
checks.push({
name: "gateway",
ok: false,
detail: formatBrowserDoctorGatewayError(err),
detail: String(err),
});
return { ok: false, checks };
}

View File

@@ -100,7 +100,7 @@
"default": false
},
"allow_destructive_actions": {
"type": "boolean",
"oneOf": [{ "type": "boolean" }, { "const": "on-request" }],
"default": true
},
"plugins": {
@@ -120,7 +120,7 @@
"type": "string"
},
"allow_destructive_actions": {
"type": "boolean"
"oneOf": [{ "type": "boolean" }, { "const": "on-request" }]
}
}
}
@@ -290,7 +290,7 @@
},
"codexPlugins.allow_destructive_actions": {
"label": "Allow Destructive Plugin Actions",
"help": "Default policy for plugin app write or destructive action elicitations. Defaults to true.",
"help": "Default policy for plugin app write or destructive action elicitations. Use true to auto-accept safe schemas, false to decline, or on-request to ask through plugin approvals.",
"advanced": true
},
"codexPlugins.plugins": {

View File

@@ -15,7 +15,6 @@ import {
type EmbeddedRunAttemptResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { isJsonObject } from "./protocol.js";
import type { CodexAppServerThreadBinding } from "./session-binding.js";
@@ -250,11 +249,9 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
turnScopedDeveloperInstructionFiles,
),
memoryCollaborationInstructions: shouldInjectCodexOpenClawPromptContext(params.params)
? renderCodexWorkspaceMemoryCollaborationInstructions({
? renderCodexWorkspaceMemoryReference({
files: memoryReferenceFiles,
toolNames: params.memoryToolNames,
memoryToolRouted: memoryToolsAvailable,
citationsMode: params.params.config?.memory?.citations,
})
: undefined,
heartbeatCollaborationInstructions:
@@ -808,55 +805,6 @@ export function renderCodexWorkspaceMemoryReference(params: {
return lines.join("\n").trim();
}
function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
files: EmbeddedContextFile[];
toolNames: readonly string[];
memoryToolRouted: boolean;
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
}): string | undefined {
const memoryRecallInstructions = params.memoryToolRouted
? renderCodexMemoryRecallInstructions({
toolNames: params.toolNames,
citationsMode: params.citationsMode,
})
: undefined;
const memoryReferenceInstructions = renderCodexWorkspaceMemoryReference({
files: params.files,
toolNames: params.toolNames,
});
const sections = [memoryRecallInstructions, memoryReferenceInstructions].filter(isNonEmptyString);
return sections.length > 0 ? sections.join("\n\n") : undefined;
}
function renderCodexMemoryRecallInstructions(params: {
toolNames: readonly string[];
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
}): string | undefined {
const availableTools = new Set(params.toolNames);
const memoryPrompt = buildMemorySystemPromptAddition({
availableTools,
citationsMode: params.citationsMode,
});
if (!memoryPrompt) {
// Memory recall policy belongs to the active memory plugin.
// Codex-side fallback text can mask plugin lifecycle bugs or misdescribe third-party memory tools.
return undefined;
}
const toolSearchBridge = renderCodexMemoryToolSearchBridge(params.toolNames);
return [memoryPrompt, toolSearchBridge].filter(isNonEmptyString).join("\n").trim();
}
function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string | undefined {
const memoryToolNames = toolNames
.map((name) => normalizeCodexDynamicToolName(name))
.filter((name) => CODEX_MEMORY_TOOL_NAMES.has(name))
.toSorted();
if (memoryToolNames.length === 0) {
return undefined;
}
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
}
/** Returns whether the current dynamic tool list can serve workspace memory. */
export function hasCodexWorkspaceMemoryTools(tools: readonly { name: string }[]): boolean {
return getCodexWorkspaceMemoryToolNames(tools).length > 0;

View File

@@ -859,6 +859,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
configured: true,
enabled: true,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
pluginPolicies: [
{
configKey: "google-calendar",
@@ -866,6 +867,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
},
{
configKey: "slack",
@@ -873,11 +875,88 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
pluginName: "slack",
enabled: false,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
},
],
});
});
it("parses on-request native Codex plugin destructive policy", () => {
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "on-request",
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
slack: {
marketplaceName: "openai-curated",
pluginName: "slack",
allow_destructive_actions: false,
},
gmail: {
marketplaceName: "openai-curated",
pluginName: "gmail",
allow_destructive_actions: true,
},
},
},
});
expect(config.codexPlugins?.allow_destructive_actions).toBe("on-request");
expect(resolveCodexPluginsPolicy(config)).toEqual({
configured: true,
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
pluginPolicies: [
{
configKey: "gmail",
marketplaceName: "openai-curated",
pluginName: "gmail",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
},
{
configKey: "google-calendar",
marketplaceName: "openai-curated",
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
},
{
configKey: "slack",
marketplaceName: "openai-curated",
pluginName: "slack",
enabled: true,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
},
],
});
});
it("rejects unsupported native Codex plugin destructive policy strings", () => {
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "ask",
plugins: {
slack: {
marketplaceName: "openai-curated",
pluginName: "slack",
},
},
},
});
expect(config.codexPlugins).toBeUndefined();
});
it("defaults native Codex plugin destructive policy to enabled", () => {
const policy = resolveCodexPluginsPolicy({
codexPlugins: {
@@ -895,6 +974,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
configured: true,
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
pluginPolicies: [
{
configKey: "slack",
@@ -902,6 +982,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
pluginName: "slack",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
},
],
});

View File

@@ -67,7 +67,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
export type CodexDynamicToolsLoading = "searchable" | "direct";
export type CodexPluginDestructivePolicy = boolean;
export type CodexPluginDestructivePolicy = boolean | "on-request";
export type CodexPluginDestructiveApprovalMode = "auto" | "deny" | "on-request";
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
@@ -115,13 +116,15 @@ export type ResolvedCodexPluginPolicy = {
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
pluginName: string;
enabled: boolean;
allowDestructiveActions: CodexPluginDestructivePolicy;
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
};
export type ResolvedCodexPluginsPolicy = {
configured: boolean;
enabled: boolean;
allowDestructiveActions: CodexPluginDestructivePolicy;
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
pluginPolicies: ResolvedCodexPluginPolicy[];
};
@@ -258,6 +261,7 @@ const codexAppServerApprovalPolicySchema = z.enum([
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("on-request")]);
const codexAppServerServiceTierSchema = z
.preprocess(
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
@@ -275,14 +279,14 @@ const codexPluginEntryConfigSchema = z
enabled: z.boolean().optional(),
marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(),
pluginName: z.string().trim().min(1).optional(),
allow_destructive_actions: z.boolean().optional(),
allow_destructive_actions: codexPluginDestructivePolicySchema.optional(),
})
.strict();
const codexPluginsConfigSchema = z
.object({
enabled: z.boolean().optional(),
allow_destructive_actions: z.boolean().optional(),
allow_destructive_actions: codexPluginDestructivePolicySchema.optional(),
plugins: z.record(z.string(), codexPluginEntryConfigSchema).optional(),
})
.strict();
@@ -380,19 +384,25 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
const config = readCodexPluginConfig(pluginConfig).codexPlugins;
const configured = config !== undefined;
const enabled = config?.enabled === true;
const allowDestructiveActions = config?.allow_destructive_actions ?? true;
const destructivePolicy = resolveCodexPluginDestructivePolicy(
config?.allow_destructive_actions ?? true,
);
const pluginPolicies = Object.entries(config?.plugins ?? {})
.flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => {
if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) {
return [];
}
const entryDestructivePolicy = resolveCodexPluginDestructivePolicy(
entry.allow_destructive_actions ?? config?.allow_destructive_actions ?? true,
);
return [
{
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: entry.pluginName,
enabled: enabled && entry.enabled !== false,
allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions,
allowDestructiveActions: entryDestructivePolicy.allowDestructiveActions,
destructiveApprovalMode: entryDestructivePolicy.destructiveApprovalMode,
},
];
})
@@ -400,11 +410,25 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
return {
configured,
enabled,
allowDestructiveActions,
allowDestructiveActions: destructivePolicy.allowDestructiveActions,
destructiveApprovalMode: destructivePolicy.destructiveApprovalMode,
pluginPolicies,
};
}
function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolicy): {
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
} {
if (policy === "on-request") {
return { allowDestructiveActions: true, destructiveApprovalMode: "on-request" };
}
return {
allowDestructiveActions: policy,
destructiveApprovalMode: policy ? "auto" : "deny",
};
}
export function resolveCodexAppServerRuntimeOptions(
params: {
pluginConfig?: unknown;

View File

@@ -157,6 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
function createPluginAppPolicyContext(
params: {
allowDestructiveActions?: boolean;
destructiveApprovalMode?: "auto" | "deny" | "on-request";
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
} = {},
) {
@@ -177,6 +178,9 @@ function createPluginAppPolicyContext(
marketplaceName: "openai-curated" as const,
pluginName: app.pluginName,
allowDestructiveActions: params.allowDestructiveActions ?? false,
...(params.destructiveApprovalMode
? { destructiveApprovalMode: params.destructiveApprovalMode }
: {}),
mcpServerNames: app.mcpServerNames,
},
]),
@@ -831,6 +835,242 @@ describe("Codex app-server elicitation bridge", () => {
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("routes on-request connector-id plugin app elicitations through plugin approvals", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-calendar", decision: "allow-once" });
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "deny"],
title: "Allow Google Calendar to create an event?",
toolName: "codex_mcp_tool_approval",
twoPhase: true,
});
});
it("maps on-request plugin allow-always only when Codex offers always persistence", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-calendar-always",
decision: "allow-always",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session", "always"],
tool_title: "create_event",
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: {
persist: "always",
},
});
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
});
it("does not expose allow-always for on-request plugin session-only persistence", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-session", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-calendar-session",
decision: "allow-once",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session"],
tool_title: "create_event",
},
requestedSchema: {
type: "object",
properties: {
approve: {
type: "boolean",
title: "Approve this app action",
},
persist: {
type: "string",
title: "Persist choice",
enum: ["session", "always"],
},
},
required: ["approve"],
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: {
approve: true,
},
_meta: null,
});
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "deny"],
});
});
it("declines denied on-request plugin app approvals", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", decision: "deny" });
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
});
it("fails closed when on-request plugin approval routing is unavailable", async () => {
mockCallGatewayTool.mockResolvedValueOnce({
id: "plugin:approval-calendar-unavailable",
decision: null,
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
]);
});
it("cancels on-request plugin app approvals when the turn aborts", async () => {
const abortController = new AbortController();
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-abort", status: "accepted" })
.mockImplementationOnce(() => {
abortController.abort(new Error("turn stopped"));
return new Promise(() => undefined);
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
signal: abortController.signal,
});
expect(result).toEqual({ action: "cancel", content: null, _meta: null });
});
it("declines connector-id plugin app elicitations when destructive actions are disabled", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),

View File

@@ -9,6 +9,7 @@ import {
mapExecDecisionToOutcome,
requestPluginApproval,
type AppServerApprovalOutcome,
type ExecApprovalDecision,
waitForPluginApprovalDecision,
} from "./plugin-approval-roundtrip.js";
import type {
@@ -28,6 +29,8 @@ type BridgeableApprovalElicitation = {
description: string;
requestedSchema: JsonObject;
meta: JsonObject;
persistHintsMode?: "legacy" | "explicit";
allowedDecisions?: ExecApprovalDecision[];
};
type PluginElicitationResolution =
@@ -111,7 +114,12 @@ export async function handleCodexAppServerElicitationRequest(params: {
logPluginElicitationDecline("missing_active_turn", requestParams);
return declineElicitationResponse();
}
return buildPluginPolicyElicitationResponse(pluginResolution.entry, requestParams);
return await buildPluginPolicyElicitationResponse({
entry: pluginResolution.entry,
requestParams,
paramsForRun: params.paramsForRun,
signal: params.signal,
});
}
const approvalPrompt =
@@ -125,9 +133,10 @@ export async function handleCodexAppServerElicitationRequest(params: {
paramsForRun: params.paramsForRun,
title: approvalPrompt.title,
description: approvalPrompt.description,
allowedDecisions: approvalPrompt.allowedDecisions,
signal: params.signal,
});
return buildElicitationResponse(approvalPrompt.requestedSchema, approvalPrompt.meta, outcome);
return buildElicitationResponse(approvalPrompt, outcome);
}
function matchesCurrentThread(requestParams: JsonObject | undefined, threadId: string): boolean {
@@ -284,28 +293,104 @@ function normalizePluginIdentityText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function buildPluginPolicyElicitationResponse(
entry: PluginAppPolicyContextEntry,
requestParams: JsonObject,
): JsonValue {
if (!entry.allowDestructiveActions) {
logPluginElicitationDecline("destructive_actions_disabled", requestParams);
async function buildPluginPolicyElicitationResponse(params: {
entry: PluginAppPolicyContextEntry;
requestParams: JsonObject;
paramsForRun: EmbeddedRunAttemptParams;
signal?: AbortSignal;
}): Promise<JsonValue> {
const mode = resolvePluginDestructiveApprovalMode(params.entry);
if (mode === "deny") {
logPluginElicitationDecline("destructive_actions_disabled", params.requestParams);
return declineElicitationResponse();
}
const approvalPrompt = readPluginApprovalElicitation(params.entry, params.requestParams);
if (!approvalPrompt) {
logPluginElicitationDecline("unsupported_schema", params.requestParams);
return declineElicitationResponse();
}
const response = buildElicitationResponse(approvalPrompt, "approved-once");
if (isJsonObject(response) && response.action === "accept") {
if (mode === "auto") {
return response;
}
const outcome = await requestPluginApprovalOutcome({
paramsForRun: params.paramsForRun,
title: approvalPrompt.title,
description: approvalPrompt.description,
allowedDecisions: approvalPrompt.allowedDecisions,
signal: params.signal,
});
return buildElicitationResponse(approvalPrompt, outcome);
}
logPluginElicitationDecline("unmappable_schema", params.requestParams);
return declineElicitationResponse();
}
function resolvePluginDestructiveApprovalMode(
entry: PluginAppPolicyContextEntry,
): "auto" | "deny" | "on-request" {
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "auto" : "deny");
}
function readPluginApprovalElicitation(
entry: PluginAppPolicyContextEntry,
requestParams: JsonObject,
): BridgeableApprovalElicitation | undefined {
if (
readString(requestParams, "mode") !== "form" ||
!isJsonObject(requestParams.requestedSchema)
) {
logPluginElicitationDecline("unsupported_schema", requestParams);
return declineElicitationResponse();
return undefined;
}
const requestedSchema = requestParams.requestedSchema;
const meta = isJsonObject(requestParams["_meta"]) ? requestParams["_meta"] : {};
const response = buildElicitationResponse(requestParams.requestedSchema, meta, "approved-once");
if (isJsonObject(response) && response.action === "accept") {
return response;
const title =
sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex plugin approval";
const descriptionMeta: JsonObject = { ...meta };
if (!readString(descriptionMeta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY)) {
descriptionMeta[MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY] = entry.pluginName;
}
logPluginElicitationDecline("unmappable_schema", requestParams);
return declineElicitationResponse();
return {
title,
description: buildApprovalDescription({
title,
meta: descriptionMeta,
requestedSchema,
serverName: sanitizeOptionalDisplayText(readString(requestParams, "serverName")),
}),
requestedSchema,
meta,
persistHintsMode: "explicit",
allowedDecisions: buildApprovalAllowedDecisions(requestedSchema, meta),
};
}
function buildApprovalAllowedDecisions(
requestedSchema: JsonObject,
meta: JsonObject,
): ExecApprovalDecision[] {
return canMapPersistentApproval(requestedSchema, meta)
? ["allow-once", "allow-always", "deny"]
: ["allow-once", "deny"];
}
function canMapPersistentApproval(requestedSchema: JsonObject, meta: JsonObject): boolean {
const persistHints = readPersistHints(meta, "explicit");
if (persistHints.length > 0) {
return persistHints.includes("always");
}
const properties = isJsonObject(requestedSchema.properties) ? requestedSchema.properties : {};
return Object.entries(properties).some(([name, value]) => {
const schema = isJsonObject(value) ? value : undefined;
if (!schema) {
return false;
}
return (
isPersistField({ name, schema, required: false }) &&
chooseAlwaysPersistOptionValue(readEnumOptions(schema)) !== undefined
);
});
}
function declineElicitationResponse(): JsonValue {
@@ -558,6 +643,7 @@ async function requestPluginApprovalOutcome(params: {
paramsForRun: EmbeddedRunAttemptParams;
title: string;
description: string;
allowedDecisions?: ExecApprovalDecision[];
signal?: AbortSignal;
}): Promise<AppServerApprovalOutcome> {
try {
@@ -567,6 +653,7 @@ async function requestPluginApprovalOutcome(params: {
description: params.description,
severity: "warning",
toolName: "codex_mcp_tool_approval",
allowedDecisions: params.allowedDecisions,
});
const approvalId = requestResult?.id;
@@ -584,10 +671,13 @@ async function requestPluginApprovalOutcome(params: {
}
function buildElicitationResponse(
requestedSchema: JsonObject,
meta: JsonObject,
approvalPrompt: Pick<
BridgeableApprovalElicitation,
"requestedSchema" | "meta" | "persistHintsMode"
>,
outcome: AppServerApprovalOutcome,
): JsonValue {
const { requestedSchema, meta } = approvalPrompt;
if (outcome === "cancelled") {
return { action: "cancel", content: null, _meta: null };
}
@@ -595,13 +685,13 @@ function buildElicitationResponse(
return { action: "decline", content: null, _meta: null };
}
const content = buildAcceptedContent(requestedSchema, meta, outcome);
const content = buildAcceptedContent(approvalPrompt, outcome);
if (!content) {
if (hasNoSchemaProperties(requestedSchema)) {
return {
action: "accept",
content: null,
_meta: buildAcceptedMeta(meta, outcome),
_meta: buildAcceptedMeta(meta, outcome, approvalPrompt.persistHintsMode ?? "legacy"),
};
}
embeddedAgentLog.warn("codex MCP approval elicitation approved without a mappable response", {
@@ -611,14 +701,21 @@ function buildElicitationResponse(
});
return { action: "decline", content: null, _meta: null };
}
return { action: "accept", content, _meta: buildAcceptedMeta(meta, outcome) };
return {
action: "accept",
content,
_meta: buildAcceptedMeta(meta, outcome, approvalPrompt.persistHintsMode ?? "legacy"),
};
}
function buildAcceptedContent(
requestedSchema: JsonObject,
meta: JsonObject,
approvalPrompt: Pick<
BridgeableApprovalElicitation,
"requestedSchema" | "meta" | "persistHintsMode"
>,
outcome: AppServerApprovalOutcome,
): JsonObject | undefined {
const { requestedSchema, meta } = approvalPrompt;
const properties = isJsonObject(requestedSchema.properties)
? requestedSchema.properties
: undefined;
@@ -641,7 +738,7 @@ function buildAcceptedContent(
const property = { name, schema, required: required.has(name) };
const next =
readApprovalFieldValue(property, outcome) ??
readPersistFieldValue(property, meta, outcome) ??
readPersistFieldValue(property, meta, outcome, approvalPrompt.persistHintsMode ?? "legacy") ??
readFallbackFieldValue(property, outcome);
if (next === undefined) {
@@ -691,11 +788,12 @@ function readPersistFieldValue(
property: ApprovalPropertyContext,
meta: JsonObject,
outcome: AppServerApprovalOutcome,
persistHintsMode: "legacy" | "explicit",
): JsonValue | undefined {
if (!isPersistField(property) || outcome !== "approved-session") {
return undefined;
}
const persistHints = readPersistHints(meta);
const persistHints = readPersistHints(meta, persistHintsMode);
const options = readEnumOptions(property.schema);
if (options.length === 0) {
return undefined;
@@ -707,6 +805,9 @@ function readPersistFieldValue(
);
return match?.value;
}
if (persistHintsMode === "explicit") {
return chooseAlwaysPersistOptionValue(options);
}
return undefined;
}
@@ -744,7 +845,7 @@ function propertyText(property: ApprovalPropertyContext): string {
.join(" ");
}
function readPersistHints(meta: JsonObject): string[] {
function readPersistHints(meta: JsonObject, mode: "legacy" | "explicit" = "legacy"): string[] {
const raw = meta.persist;
if (typeof raw === "string") {
return [raw];
@@ -752,14 +853,18 @@ function readPersistHints(meta: JsonObject): string[] {
if (Array.isArray(raw)) {
return raw.filter((entry): entry is string => typeof entry === "string");
}
return ["session", "always"];
return mode === "legacy" ? ["session", "always"] : [];
}
function buildAcceptedMeta(meta: JsonObject, outcome: AppServerApprovalOutcome): JsonObject | null {
function buildAcceptedMeta(
meta: JsonObject,
outcome: AppServerApprovalOutcome,
persistHintsMode: "legacy" | "explicit",
): JsonObject | null {
if (outcome !== "approved-session") {
return null;
}
const persist = choosePersistHint(readPersistHints(meta));
const persist = choosePersistHint(readPersistHints(meta, persistHintsMode));
return persist ? { persist } : null;
}
@@ -773,6 +878,20 @@ function choosePersistHint(persistHints: string[]): "always" | "session" | undef
return undefined;
}
function chooseAlwaysPersistOptionValue(
options: Array<{ value: string; label: string }>,
): string | undefined {
const always = options.find((option) => optionMatchesPersist(option, "always"));
return always?.value;
}
function optionMatchesPersist(
option: { value: string; label: string },
persist: "always" | "session",
): boolean {
return option.value.toLowerCase() === persist || option.label.toLowerCase() === persist;
}
function hasNoSchemaProperties(requestedSchema: JsonObject): boolean {
const properties = isJsonObject(requestedSchema.properties) ? requestedSchema.properties : {};
return Object.keys(properties).length === 0;

View File

@@ -303,6 +303,7 @@ function identity(pluginName: string): ResolvedCodexPluginPolicy {
pluginName,
enabled: true,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
};
}

View File

@@ -12,7 +12,7 @@ const DEFAULT_CODEX_APPROVAL_TIMEOUT_MS = 120_000;
const MAX_PLUGIN_APPROVAL_TITLE_LENGTH = 80;
const MAX_PLUGIN_APPROVAL_DESCRIPTION_LENGTH = 256;
type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
/** Normalized Codex app-server approval outcome after a gateway decision. */
export type AppServerApprovalOutcome =
@@ -40,6 +40,7 @@ export async function requestPluginApproval(params: {
severity: "info" | "warning";
toolName: string;
toolCallId?: string;
allowedDecisions?: ExecApprovalDecision[];
}): Promise<ApprovalRequestResult | undefined> {
const timeoutMs = DEFAULT_CODEX_APPROVAL_TIMEOUT_MS;
return callGatewayTool(
@@ -60,6 +61,7 @@ export async function requestPluginApproval(params: {
turnSourceThreadId: params.paramsForRun.currentThreadTs,
timeoutMs,
twoPhase: true,
...(params.allowedDecisions ? { allowedDecisions: params.allowedDecisions } : {}),
},
{ expectFinal: false },
) as Promise<ApprovalRequestResult | undefined>;

View File

@@ -73,6 +73,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
mcpServerNames: ["google-calendar"],
});
expect(config.diagnostics).toStrictEqual([]);
@@ -107,6 +108,9 @@ describe("Codex plugin thread config", () => {
expect(
pluginOverrideDisabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions,
).toBe(false);
expect(
pluginOverrideDisabled.policyContext.apps["google-calendar-app"]?.destructiveApprovalMode,
).toBe("deny");
const pluginOverrideEnabled = await buildReadyGoogleCalendarThreadConfig({
codexPlugins: {
@@ -134,6 +138,36 @@ describe("Codex plugin thread config", () => {
expect(
pluginOverrideEnabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions,
).toBe(true);
expect(
pluginOverrideEnabled.policyContext.apps["google-calendar-app"]?.destructiveApprovalMode,
).toBe("auto");
});
it("exposes destructive app access while marking on-request approval mode", async () => {
const config = await buildReadyGoogleCalendarThreadConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "on-request",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
});
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
expect(apps?.["google-calendar-app"]).toEqual({
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "auto",
});
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
allowDestructiveActions: true,
destructiveApprovalMode: "on-request",
});
});
it("builds a restrictive app config when native plugin support is disabled", async () => {
@@ -267,6 +301,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
mcpServerNames: [],
});
expect(config.diagnostics).toStrictEqual([]);
@@ -338,6 +373,7 @@ describe("Codex plugin thread config", () => {
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
},
message: "google-calendar-app is not accessible or enabled for google-calendar.",
},
@@ -408,6 +444,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
mcpServerNames: [],
});
expect(config.diagnostics).toStrictEqual([]);
@@ -498,6 +535,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
mcpServerNames: [],
});
expect(config.diagnostics).toStrictEqual([]);

View File

@@ -13,6 +13,7 @@ import {
} from "./app-inventory-cache.js";
import {
resolveCodexPluginsPolicy,
type CodexPluginDestructiveApprovalMode,
type ResolvedCodexPluginPolicy,
type ResolvedCodexPluginsPolicy,
} from "./config.js";
@@ -36,6 +37,7 @@ export type PluginAppPolicyContextEntry = {
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"];
pluginName: string;
allowDestructiveActions: boolean;
destructiveApprovalMode?: CodexPluginDestructiveApprovalMode;
mcpServerNames: string[];
};
@@ -246,6 +248,7 @@ export async function buildCodexPluginThreadConfig(
marketplaceName: record.policy.marketplaceName,
pluginName: record.policy.pluginName,
allowDestructiveActions: record.policy.allowDestructiveActions,
destructiveApprovalMode: record.policy.destructiveApprovalMode,
mcpServerNames: [...(record.detail?.mcpServers ?? [])].toSorted(),
};
}
@@ -425,12 +428,14 @@ function policyFingerprint(policy: ResolvedCodexPluginsPolicy): JsonValue {
return {
enabled: policy.enabled,
allowDestructiveActions: policy.allowDestructiveActions,
destructiveApprovalMode: policy.destructiveApprovalMode,
plugins: policy.pluginPolicies.map((plugin) => ({
configKey: plugin.configKey,
marketplaceName: plugin.marketplaceName,
pluginName: plugin.pluginName,
enabled: plugin.enabled,
allowDestructiveActions: plugin.allowDestructiveActions,
destructiveApprovalMode: plugin.destructiveApprovalMode,
})),
};
}

View File

@@ -10,7 +10,6 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime";
import { clearInternalHooks, resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime";
import { clearMemoryPluginState } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, expect, vi } from "vitest";
@@ -496,7 +495,6 @@ export function setupRunAttemptTestHooks(): void {
beforeEach(async () => {
vi.useRealTimers();
clearInternalHooks();
clearMemoryPluginState();
resetAgentEventsForTest();
resetDiagnosticEventsForTest();
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
@@ -514,7 +512,6 @@ export function setupRunAttemptTestHooks(): void {
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
resetCodexRateLimitCacheForTests();
nativeHookRelayTesting.clearNativeHookRelaysForTests();
clearMemoryPluginState();
clearPluginCommands();
resetAgentEventsForTest();
resetDiagnosticEventsForTest();

View File

@@ -12,7 +12,6 @@ import {
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { initializeGlobalHookRunner, registerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
import { registerMemoryCapability } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
@@ -398,37 +397,6 @@ function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
};
}
function registerMemoryPromptForTest() {
registerMemoryCapability("memory-core", {
promptBuilder({ availableTools }) {
const hasMemorySearch = availableTools.has("memory_search");
const hasMemoryGet = availableTools.has("memory_get");
if (hasMemorySearch && hasMemoryGet) {
return [
"## Memory Recall",
"Test recall: run memory_search on MEMORY.md + memory/*.md + indexed session transcripts; then use memory_get.",
"",
];
}
if (hasMemorySearch) {
return [
"## Memory Recall",
"Test recall: run memory_search on MEMORY.md + memory/*.md + indexed session transcripts.",
"",
];
}
if (hasMemoryGet) {
return [
"## Memory Recall",
"Test recall: run memory_get for a specific memory file or note.",
"",
];
}
return [];
},
});
}
function buildEmptyCodexToolTelemetry(): CodexAppServerToolTelemetry {
return {
didSendViaMessagingTool: false,
@@ -2235,7 +2203,6 @@ describe("runCodexAppServerAttempt", () => {
await fs.writeFile(path.join(workspaceDir, "TOOLS.md"), toolGuidance);
await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile);
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
@@ -2269,20 +2236,12 @@ describe("runCodexAppServerAttempt", () => {
expect(collaborationInstructions).toContain(identityGuidance);
expect(collaborationInstructions).not.toContain(toolGuidance);
expect(collaborationInstructions).toContain(userProfile);
expect(collaborationInstructions).toContain("## Memory Recall");
expect(collaborationInstructions).toContain("MEMORY.md + memory/*.md");
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).toContain(
"MEMORY.md exists in the active agent workspace as a memory file, not an instruction file",
);
expect(collaborationInstructions).toContain("memory_search");
expect(collaborationInstructions).toContain("memory_get");
expect(collaborationInstructions).toContain(
"When the memory guidance above calls for memory recall, use an already-loaded memory tool directly.",
);
expect(collaborationInstructions).toContain(
"If the needed memory tool is deferred and not currently callable, use `tool_search` to load it, then call that memory tool.",
);
expect(collaborationInstructions).not.toContain(memorySummary);
expect(inputText).not.toContain("OpenClaw runtime context for this turn:");
expect(inputText).not.toContain("does not override Codex system/developer instructions");
@@ -2338,65 +2297,6 @@ describe("runCodexAppServerAttempt", () => {
});
});
it("adds memory recall guidance when dated memory notes exist without root MEMORY.md", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const datedMemory = "User avoids Chase cards while over 5/24.";
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "memory/2026-06-09.md"), datedMemory);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
]);
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setAgentWorkspaceForTest(params, workspaceDir);
const { collaborationInstructions, inputText } = await buildCodexTurnContextForTest(
params,
workspaceDir,
);
expect(collaborationInstructions).toContain("## Memory Recall");
expect(collaborationInstructions).toContain("MEMORY.md + memory/*.md");
expect(collaborationInstructions).toContain("memory_search");
expect(collaborationInstructions).toContain("memory_get");
expect(collaborationInstructions).not.toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).not.toContain(datedMemory);
expect(inputText).toBe("hello");
expect(inputText).not.toContain(datedMemory);
});
it("does not synthesize memory recall guidance without a registered memory prompt builder", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const memorySummary = "User avoids Chase cards while over 5/24.";
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
]);
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setAgentWorkspaceForTest(params, workspaceDir);
const { collaborationInstructions, inputText } = await buildCodexTurnContextForTest(
params,
workspaceDir,
);
expect(collaborationInstructions).not.toContain("## Memory Recall");
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).not.toContain("Use `tool_search` first");
expect(collaborationInstructions).not.toContain(memorySummary);
expect(inputText).toBe("hello");
expect(inputText).not.toContain(memorySummary);
});
it("sends workspace bootstrap instructions through Codex app-server payloads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -2505,7 +2405,6 @@ describe("runCodexAppServerAttempt", () => {
const memorySummary = "Memory summary goes here.";
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("memory_get")]);
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
@@ -2518,7 +2417,6 @@ describe("runCodexAppServerAttempt", () => {
expect(inputText).not.toContain("memory_get");
expect(inputText).not.toContain("memory_search");
expect(inputText).not.toContain(memorySummary);
expect(collaborationInstructions).toContain("## Memory Recall");
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
expect(collaborationInstructions).toContain("memory_get");
expect(collaborationInstructions).not.toContain("memory_search");
@@ -2697,7 +2595,6 @@ describe("runCodexAppServerAttempt", () => {
const memorySummary = "Memory summary goes here.";
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
registerMemoryPromptForTest();
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("memory_search"),
createRuntimeDynamicTool("memory_get"),
@@ -2707,10 +2604,10 @@ describe("runCodexAppServerAttempt", () => {
params.runtimePlan = createCodexRuntimePlanFixture();
setAgentWorkspaceForTest(params, path.join(tempDir, "memory-workspace"));
const { collaborationInstructions, inputText, systemPromptReport } =
await buildCodexTurnContextForTest(params, workspaceDir);
expect(collaborationInstructions).not.toContain("## Memory Recall");
expect(collaborationInstructions).not.toContain("OpenClaw Workspace Memory");
const { inputText, systemPromptReport } = await buildCodexTurnContextForTest(
params,
workspaceDir,
);
expect(inputText).not.toContain("OpenClaw Workspace Memory");
expect(inputText).toContain(memorySummary);

View File

@@ -108,6 +108,35 @@ describe("codex app-server session binding", () => {
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("round-trips plugin app policy context destructive approval mode", async () => {
const sessionFile = path.join(tempDir, "session.json");
const pluginAppPolicyContext = {
fingerprint: "plugin-policy-1",
apps: {
"google-calendar-app": {
configKey: "google-calendar",
marketplaceName: "openai-curated" as const,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "on-request" as const,
mcpServerNames: ["google-calendar"],
},
},
pluginAppIds: {
"google-calendar": ["google-calendar-app"],
},
};
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-123",
cwd: tempDir,
pluginAppPolicyContext,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("round-trips context-engine binding metadata", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(sessionFile, {

View File

@@ -333,6 +333,7 @@ function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | un
entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
typeof entry.pluginName !== "string" ||
typeof entry.allowDestructiveActions !== "boolean" ||
!isValidDestructiveApprovalMode(entry.destructiveApprovalMode) ||
!Array.isArray(entry.mcpServerNames) ||
entry.mcpServerNames.some((serverName) => typeof serverName !== "string")
) {
@@ -343,6 +344,9 @@ function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | un
marketplaceName: entry.marketplaceName,
pluginName: entry.pluginName,
allowDestructiveActions: entry.allowDestructiveActions,
...(entry.destructiveApprovalMode
? { destructiveApprovalMode: entry.destructiveApprovalMode }
: {}),
mcpServerNames: entry.mcpServerNames,
};
}
@@ -366,6 +370,12 @@ function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | un
};
}
function isValidDestructiveApprovalMode(
value: unknown,
): value is PluginAppPolicyContext["apps"][string]["destructiveApprovalMode"] | undefined {
return value === undefined || value === "auto" || value === "deny" || value === "on-request";
}
/** Removes the Codex app-server binding sidecar if present. */
export async function clearCodexAppServerBinding(
sessionFile: string,

View File

@@ -23,7 +23,7 @@ export type CodexPluginConfigEntry = {
enabled?: boolean;
marketplaceName?: string;
pluginName?: string;
allow_destructive_actions?: boolean;
allow_destructive_actions?: boolean | "on-request";
};
export type CodexPluginsConfigBlock = {

View File

@@ -508,6 +508,7 @@ function readCodexPluginPolicy(item: MigrationItem): ResolvedCodexPluginPolicy |
pluginName,
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
};
}

View File

@@ -258,12 +258,12 @@ export function readCodexPluginMigrationConfigEntry(
function readExistingAllowDestructiveActions(
config: MigrationProviderContext["config"],
): boolean | undefined {
): boolean | "on-request" | undefined {
const value = readMigrationConfigPath(config as Record<string, unknown>, [
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
"allow_destructive_actions",
]);
return asBoolean(value);
return value === "on-request" ? "on-request" : asBoolean(value);
}
export function buildCodexPluginsConfigValue(

View File

@@ -171,7 +171,6 @@ import {
type DiagnosticEventPrivateData,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import {
emitDiagnosticEventWithTrustedTraceContext,
emitInternalDiagnosticEventForTest,
logMessageDispatchStarted,
logMessageProcessed,
@@ -363,11 +362,7 @@ function histogramCreateOptions(name: string) {
async function emitAndCaptureLog(
event: Omit<Extract<Parameters<typeof emitDiagnosticEvent>[0], { type: "log.record" }>, "type">,
options: {
captureContent?: OtelContextFlags["captureContent"];
trusted?: boolean;
trustedTraceContext?: boolean;
} = {},
options: { captureContent?: OtelContextFlags["captureContent"]; trusted?: boolean } = {},
) {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {
@@ -375,11 +370,7 @@ async function emitAndCaptureLog(
...(options.captureContent !== undefined ? { captureContent: options.captureContent } : {}),
});
await service.start(ctx);
const emit = options.trusted
? emitTrustedDiagnosticEvent
: options.trustedTraceContext
? emitDiagnosticEventWithTrustedTraceContext
: emitDiagnosticEvent;
const emit = options.trusted ? emitTrustedDiagnosticEvent : emitDiagnosticEvent;
emit({
type: "log.record",
...event,
@@ -1400,28 +1391,6 @@ describe("diagnostics-otel service", () => {
expect(emitCall?.context).toBeUndefined();
});
test("attaches trace-only trusted context to exported logs", async () => {
const emitCall = await emitAndCaptureLog(
{
level: "INFO",
message: "traceable log",
trace: {
traceId: TRACE_ID,
spanId: SPAN_ID,
traceFlags: "01",
},
},
{ trustedTraceContext: true },
);
expect(emitCall?.body).toBe("log");
expect(telemetryState.tracer.setSpanContext).toHaveBeenCalledTimes(1);
const emitContext = emitCall?.context as { spanContext?: Record<string, unknown> } | undefined;
const emitSpanContext = emitContext?.spanContext;
expect(emitSpanContext?.traceId).toBe(TRACE_ID);
expect(emitSpanContext?.spanId).toBe(SPAN_ID);
});
test("attaches trusted diagnostic trace context to exported logs", async () => {
const emitCall = await emitAndCaptureLog(
{

View File

@@ -1031,9 +1031,7 @@ function contextForTrustedTraceContext(
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) {
return metadata.trusted || metadata.trustedTraceContext === true
? contextForTraceContext(evt.trace)
: undefined;
return metadata.trusted ? contextForTraceContext(evt.trace) : undefined;
}
function addTraceAttributes(
@@ -1628,7 +1626,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.code?.functionName) {
assignOtelLogAttribute(attributes, "code.function", evt.code.functionName);
}
if (metadata.trusted || metadata.trustedTraceContext === true) {
if (metadata.trusted) {
addTraceAttributes(attributes, evt.trace);
}

View File

@@ -3,7 +3,6 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import YAML from "yaml";
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
const cleanups: Array<() => Promise<void>> = [];
@@ -14,19 +13,6 @@ afterEach(async () => {
}
});
function parseComposeServices(compose: string) {
const parsed = YAML.parse(compose) as {
services?: Record<
string,
{
environment?: Record<string, string>;
volumes?: string[];
}
>;
};
return parsed.services ?? {};
}
describe("qa docker harness", () => {
it("writes compose, env, config, and workspace scaffold files", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-test-"));
@@ -59,21 +45,8 @@ describe("qa docker harness", () => {
}
const compose = await readFile(path.join(outputDir, "docker-compose.qa.yml"), "utf8");
const services = parseComposeServices(compose);
expect(compose).toContain("image: openclaw:qa-local-prebaked");
expect(compose).toContain("qa-mock-openai:");
expect(services["qa-mock-openai"]?.environment).toMatchObject({
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
OPENCLAW_PROFILE: "",
});
expect(services["qa-mock-openai"]?.environment).not.toHaveProperty("OPENCLAW_CONFIG_PATH");
expect(services["qa-mock-openai"]?.volumes).toBeUndefined();
expect(services["qa-lab"]?.environment).toMatchObject({
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
OPENCLAW_CONFIG_PATH: "/opt/openclaw-scaffold/openclaw.json",
OPENCLAW_STATE_DIR: "/tmp/openclaw/state",
});
expect(services["qa-lab"]?.volumes).toContain("./state:/opt/openclaw-scaffold:ro");
expect(compose).toContain(' - "127.0.0.1:18889:18789"');
expect(compose).toContain(' - "127.0.0.1:43124:43123"');
expect(compose).toContain(":/opt/openclaw-qa-lab-ui:ro");
@@ -102,21 +75,13 @@ describe("qa docker harness", () => {
expect(envExample).toContain("QA_PROVIDER_BASE_URL=http://host.docker.internal:45123/v1");
expect(envExample).toContain("QA_LAB_URL=http://127.0.0.1:43124");
const configText = await readFile(path.join(outputDir, "state", "openclaw.json"), "utf8");
const config = JSON.parse(configText) as {
plugins?: {
allow?: string[];
entries?: Record<string, { enabled?: boolean }>;
};
};
expect(configText).toContain('"allowInsecureAuth": true');
expect(configText).toContain('"pluginToolsMcpBridge": true');
expect(configText).toContain('"openClawToolsMcpBridge": true');
expect(configText).toContain("/app/dist/control-ui");
expect(configText).toContain("C-3PO QA");
expect(configText).toContain('"/tmp/openclaw/workspace"');
expect(config.plugins?.allow).toContain("qa-lab");
expect(config.plugins?.entries?.["qa-lab"]?.enabled).toBe(true);
const config = await readFile(path.join(outputDir, "state", "openclaw.json"), "utf8");
expect(config).toContain('"allowInsecureAuth": true');
expect(config).toContain('"pluginToolsMcpBridge": true');
expect(config).toContain('"openClawToolsMcpBridge": true');
expect(config).toContain("/app/dist/control-ui");
expect(config).toContain("C-3PO QA");
expect(config).toContain('"/tmp/openclaw/workspace"');
const kickoff = await readFile(
path.join(outputDir, "state", "seed-workspace", "QA_KICKOFF_TASK.md"),

View File

@@ -60,9 +60,6 @@ ${imageBlock} pull_policy: never
timeout: 5s
retries: 6
start_period: 3s
environment:
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_PROFILE: ""
command:
- node
- dist/index.js
@@ -91,9 +88,6 @@ ${params.bindUiDist ? ` - ${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro\n` :
retries: 6
start_period: 5s
environment:
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_CONFIG_PATH: /opt/openclaw-scaffold/openclaw.json
OPENCLAW_STATE_DIR: /tmp/openclaw/state
OPENCLAW_SKIP_GMAIL_WATCHER: "1"
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1"
OPENCLAW_SKIP_CANVAS_HOST: "1"

View File

@@ -52,11 +52,6 @@ function getModelFallbacks(value: unknown): string[] | undefined {
return undefined;
}
function expectQaLabPluginEnabled(cfg: ReturnType<typeof buildQaGatewayConfig>) {
expect(cfg.plugins?.allow).toContain("qa-lab");
expect(cfg.plugins?.entries?.["qa-lab"]).toEqual({ enabled: true });
}
describe("buildQaGatewayConfig", () => {
it("keeps mock-openai as the default provider lane", () => {
const cfg = buildQaGatewayConfig({
@@ -83,8 +78,7 @@ describe("buildQaGatewayConfig", () => {
expect(cfg.models?.providers?.openai?.request).toEqual({ allowPrivateNetwork: true });
expect(cfg.models?.providers?.anthropic?.baseUrl).toBe("http://127.0.0.1:44080");
expect(cfg.models?.providers?.anthropic?.request).toEqual({ allowPrivateNetwork: true });
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "qa-channel"]);
expectQaLabPluginEnabled(cfg);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-channel"]);
expect(cfg.plugins?.slots?.memory).toBe("memory-core");
expect(cfg.plugins?.entries?.acpx).toEqual({
enabled: true,
@@ -130,7 +124,7 @@ describe("buildQaGatewayConfig", () => {
expect(cfg.models?.providers?.anthropic?.models.map((model) => model.id)).toContain(
"claude-opus-4-8",
);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab"]);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core"]);
});
it("falls back to provider defaults for blank model refs", () => {
@@ -181,7 +175,7 @@ describe("buildQaGatewayConfig", () => {
transportConfig: {},
});
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab"]);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core"]);
expect(cfg.plugins?.entries?.["qa-channel"]).toBeUndefined();
expect(cfg.channels?.["qa-channel"]).toBeUndefined();
});
@@ -197,13 +191,7 @@ describe("buildQaGatewayConfig", () => {
...createQaChannelTransportParams(),
});
expect(cfg.plugins?.allow).toEqual([
"acpx",
"memory-core",
"qa-lab",
"active-memory",
"qa-channel",
]);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "active-memory", "qa-channel"]);
expect(cfg.plugins?.entries?.["active-memory"]).toEqual({ enabled: true });
});
@@ -225,7 +213,7 @@ describe("buildQaGatewayConfig", () => {
expect(getModelFallbacks(cfg.agents?.defaults?.model)).toBeUndefined();
expect(getModelFallbacks(cfg.agents?.list?.[0]?.model)).toBeUndefined();
expect(cfg.models).toBeUndefined();
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "openai", "qa-channel"]);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]);
expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true });
expect(cfg.agents?.defaults?.models?.["openai/gpt-5.5"]).toEqual({
params: { transport: "sse", openaiWsWarmup: false, fastMode: true },
@@ -248,7 +236,6 @@ describe("buildQaGatewayConfig", () => {
expect(cfg.plugins?.allow).toEqual([
"acpx",
"memory-core",
"qa-lab",
"anthropic",
"google",
"qa-channel",
@@ -274,7 +261,7 @@ describe("buildQaGatewayConfig", () => {
});
expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("codex-cli/test-model");
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "openai", "qa-channel"]);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]);
expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true });
expect(cfg.plugins?.entries?.["codex-cli"]).toBeUndefined();
});
@@ -314,7 +301,7 @@ describe("buildQaGatewayConfig", () => {
expect(cfg.models?.mode).toBe("merge");
expect(cfg.models?.providers?.["custom-openai"]?.api).toBe("openai-responses");
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "openai", "qa-channel"]);
expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]);
});
it("can set a QA default thinking level for judge turns", () => {

View File

@@ -23,7 +23,6 @@ export const DEFAULT_QA_CONTROL_UI_ALLOWED_ORIGINS = Object.freeze([
]);
export const QA_BASE_RUNTIME_PLUGIN_IDS = Object.freeze(["acpx", "memory-core"]);
export const QA_LAB_PLUGIN_ID = "qa-lab";
export function mergeQaControlUiAllowedOrigins(extraOrigins?: string[]) {
const normalizedExtra = (extraOrigins ?? [])
@@ -113,12 +112,7 @@ export function buildQaGatewayConfig(params: {
transportPluginIds.map((pluginId) => [pluginId, { enabled: true }]),
);
const allowedPlugins = [
...new Set([
...QA_BASE_RUNTIME_PLUGIN_IDS,
QA_LAB_PLUGIN_ID,
...selectedPluginIds,
...transportPluginIds,
]),
...new Set([...QA_BASE_RUNTIME_PLUGIN_IDS, ...selectedPluginIds, ...transportPluginIds]),
];
const resolveModelParams = (modelRef: string) =>
provider.resolveModelParams({
@@ -149,9 +143,6 @@ export function buildQaGatewayConfig(params: {
"memory-core": {
enabled: true,
},
[QA_LAB_PLUGIN_ID]: {
enabled: true,
},
...pluginEntries,
...transportPluginEntries,
},

View File

@@ -28,12 +28,6 @@ import type { TelegramBotDeps } from "./bot-deps.js";
import { registerTelegramHandlers } from "./bot-handlers.runtime.js";
import { createTelegramMessageProcessor } from "./bot-message.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
import {
getTelegramSpooledReplayDeferredParticipant,
isTelegramSpooledReplayUpdate,
runWithTelegramUpdateProcessingFrame,
TelegramSpooledReplayProcessingError,
} from "./bot-processing-outcome.js";
import { createTelegramUpdateTracker } from "./bot-update-tracker.js";
import type { TelegramUpdateKeyContext } from "./bot-updates.js";
import { resolveDefaultAgentId } from "./bot.agent.runtime.js";
@@ -218,29 +212,7 @@ export function createTelegramBotCore(
return;
}
try {
const { result } = await runWithTelegramUpdateProcessingFrame(async () => {
await next();
});
const deferredWork = getTelegramSpooledReplayDeferredParticipant();
if (deferredWork) {
void deferredWork.task
.then((deferredResult) => {
updateTracker.finishUpdate(begin.update, {
completed: deferredResult.kind !== "failed-retryable",
});
})
.catch(() => {
updateTracker.finishUpdate(begin.update, { completed: false });
});
return;
}
if (result?.kind === "failed-retryable") {
if (isTelegramSpooledReplayUpdate(ctx.update)) {
throw new TelegramSpooledReplayProcessingError(result.error);
}
updateTracker.finishUpdate(begin.update, { completed: true });
return;
}
await next();
updateTracker.finishUpdate(begin.update, { completed: true });
} catch (error) {
updateTracker.finishUpdate(begin.update, { completed: false });

View File

@@ -72,12 +72,6 @@ import type {
} from "./bot-message-context.types.js";
import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
createTelegramSpooledReplayDeferredParticipant,
isTelegramSpooledReplayUpdate,
type TelegramMessageProcessingResult,
type TelegramSpooledReplayDeferredParticipant,
} from "./bot-processing-outcome.js";
import {
MEDIA_GROUP_TIMEOUT_MS,
type MediaGroupEntry,
@@ -139,7 +133,6 @@ import {
claimTelegramMessageDispatchReplay,
commitTelegramMessageDispatchReplay,
createTelegramMessageDispatchReplayGuard,
forgetTelegramMessageDispatchReplay,
releaseTelegramMessageDispatchReplay,
} from "./message-dispatch-dedupe.js";
import {
@@ -152,10 +145,6 @@ import {
type ProviderInfo,
} from "./model-buttons.js";
import { parseTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
import {
isTelegramEditTargetMissingError,
isTelegramMessageHasNoTextError,
} from "./network-errors.js";
import { buildInlineKeyboard } from "./send.js";
export const registerTelegramHandlers = ({
@@ -215,7 +204,6 @@ export const registerTelegramHandlers = ({
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
dispatchDedupeKeys: string[];
spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[];
};
const mediaGroupBuffer = new Map<string, BufferedMediaGroupEntry>();
@@ -235,7 +223,6 @@ export const registerTelegramHandlers = ({
messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>;
promptContextMinTimestampMs?: number;
dispatchDedupeKeys: string[];
spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[];
timer: ReturnType<typeof setTimeout>;
};
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
@@ -270,26 +257,6 @@ export const registerTelegramHandlers = ({
threadId?: number;
promptContextMinTimestampMs?: number;
dispatchDedupeKeys: string[];
spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant;
};
const resolveTelegramDebounceEntryMs = (entry: TelegramDebounceEntry): number =>
entry.debounceLane === "forward" ? FORWARD_BURST_DEBOUNCE_MS : debounceMs;
const shouldDebounceTelegramEntry = (entry: TelegramDebounceEntry): boolean => {
const text = getTelegramTextParts(entry.msg).text;
const hasDebounceableText = shouldDebounceTextInbound({
text,
cfg,
commandOptions: { botUsername: entry.botUsername },
});
if (entry.debounceLane === "forward") {
// Forwarded bursts often split text + media into adjacent updates.
// Debounce media-only forward entries too so they can coalesce.
return hasDebounceableText || entry.allMedia.length > 0;
}
if (!hasDebounceableText) {
return false;
}
return entry.allMedia.length === 0;
};
const normalizePromptContextMinTimestampMs = (timestampMs?: number) =>
typeof timestampMs === "number" && Number.isFinite(timestampMs) ? timestampMs : undefined;
@@ -328,32 +295,6 @@ export const registerTelegramHandlers = ({
keys,
});
};
const forgetDispatchDedupeKeys = async (keys: readonly string[]) => {
await forgetTelegramMessageDispatchReplay({
guard: messageDispatchReplayGuard,
keys,
});
};
const buildFailedProcessingResult = (error: unknown): TelegramMessageProcessingResult => ({
kind: "failed-retryable",
error,
});
const settleSpooledReplayParticipants = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
result: TelegramMessageProcessingResult,
) => {
for (const participant of new Set(participants)) {
participant.settle(result);
}
};
const createSpooledReplayParticipantForBufferedWork = (
key: string,
): TelegramSpooledReplayDeferredParticipant | undefined =>
createTelegramSpooledReplayDeferredParticipant(key) ?? undefined;
const spooledReplayOptions = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
): Pick<TelegramMessageContextOptions, "spooledReplay"> =>
participants.length > 0 ? { spooledReplay: true } : {};
const claimMessageDispatchDedupe = async (
msg: Message,
): Promise<{ process: true; keys: string[] } | { process: false }> => {
@@ -534,96 +475,84 @@ export const registerTelegramHandlers = ({
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
debounceMs,
serializeImmediate: true,
resolveDebounceMs: resolveTelegramDebounceEntryMs,
resolveDebounceMs: (entry) =>
entry.debounceLane === "forward" ? FORWARD_BURST_DEBOUNCE_MS : debounceMs,
buildKey: (entry) => entry.debounceKey,
shouldDebounce: shouldDebounceTelegramEntry,
shouldDebounce: (entry) => {
const text = getTelegramTextParts(entry.msg).text;
const hasDebounceableText = shouldDebounceTextInbound({
text,
cfg,
commandOptions: { botUsername: entry.botUsername },
});
if (entry.debounceLane === "forward") {
// Forwarded bursts often split text + media into adjacent updates.
// Debounce media-only forward entries too so they can coalesce.
return hasDebounceableText || entry.allMedia.length > 0;
}
if (!hasDebounceableText) {
return false;
}
return entry.allMedia.length === 0;
},
onFlush: async (entries) => {
const spooledReplayParticipants = entries
.map((entry) => entry.spooledReplayParticipant)
.filter(
(participant): participant is TelegramSpooledReplayDeferredParticipant =>
participant !== undefined,
);
const last = entries.at(-1);
if (!last) {
return;
}
try {
if (entries.length === 1) {
const result = await processMessageWithReplyChain({
ctx: last.ctx,
msg: last.msg,
allMedia: last.allMedia,
storeAllowFrom: last.storeAllowFrom,
options: {
receivedAtMs: last.receivedAtMs,
ingressBuffer: "inbound-debounce",
...promptContextBoundaryOptions(last.promptContextMinTimestampMs),
...spooledReplayOptions(spooledReplayParticipants),
},
dispatchDedupeKeys: last.dispatchDedupeKeys,
});
settleSpooledReplayParticipants(spooledReplayParticipants, result);
return;
}
const combinedText = entries
.map((entry) => getTelegramTextParts(entry.msg).text)
.filter(Boolean)
.join("\n");
const combinedMedia = entries.flatMap((entry) => entry.allMedia);
if (!combinedText.trim() && combinedMedia.length === 0) {
settleSpooledReplayParticipants(spooledReplayParticipants, { kind: "skipped" });
return;
}
const first = entries[0];
const promptContextMinTimestampMs = latestPromptContextMinTimestampMs(
...entries.map((entry) => entry.promptContextMinTimestampMs),
);
const baseCtx = first.ctx;
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
date: last.msg.date ?? first.msg.date,
});
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage);
const result = await processMessageWithReplyChain({
ctx: syntheticCtx,
msg: syntheticMessage,
allMedia: combinedMedia,
storeAllowFrom: first.storeAllowFrom,
if (entries.length === 1) {
await processMessageWithReplyChain({
ctx: last.ctx,
msg: last.msg,
allMedia: last.allMedia,
storeAllowFrom: last.storeAllowFrom,
options: {
...(messageIdOverride ? { messageIdOverride } : {}),
receivedAtMs: first.receivedAtMs,
receivedAtMs: last.receivedAtMs,
ingressBuffer: "inbound-debounce",
...promptContextBoundaryOptions(promptContextMinTimestampMs),
...spooledReplayOptions(spooledReplayParticipants),
...promptContextBoundaryOptions(last.promptContextMinTimestampMs),
},
dispatchDedupeKeys: mergeDispatchDedupeKeys(
...entries.map((entry) => entry.dispatchDedupeKeys),
),
dispatchDedupeKeys: last.dispatchDedupeKeys,
});
settleSpooledReplayParticipants(spooledReplayParticipants, result);
} catch (err) {
settleSpooledReplayParticipants(
spooledReplayParticipants,
buildFailedProcessingResult(err),
);
throw err;
}
},
onError: (err, items) => {
const spooledReplayParticipants = items
.map((item) => item.spooledReplayParticipant)
.filter(
(participant): participant is TelegramSpooledReplayDeferredParticipant =>
participant !== undefined,
);
settleSpooledReplayParticipants(spooledReplayParticipants, buildFailedProcessingResult(err));
runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`));
if (spooledReplayParticipants.length > 0) {
return;
}
const combinedText = entries
.map((entry) => getTelegramTextParts(entry.msg).text)
.filter(Boolean)
.join("\n");
const combinedMedia = entries.flatMap((entry) => entry.allMedia);
if (!combinedText.trim() && combinedMedia.length === 0) {
return;
}
const first = entries[0];
const promptContextMinTimestampMs = latestPromptContextMinTimestampMs(
...entries.map((entry) => entry.promptContextMinTimestampMs),
);
const baseCtx = first.ctx;
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
date: last.msg.date ?? first.msg.date,
});
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage);
await processMessageWithReplyChain({
ctx: syntheticCtx,
msg: syntheticMessage,
allMedia: combinedMedia,
storeAllowFrom: first.storeAllowFrom,
options: {
...(messageIdOverride ? { messageIdOverride } : {}),
receivedAtMs: first.receivedAtMs,
ingressBuffer: "inbound-debounce",
...promptContextBoundaryOptions(promptContextMinTimestampMs),
},
dispatchDedupeKeys: mergeDispatchDedupeKeys(
...entries.map((entry) => entry.dispatchDedupeKeys),
),
});
},
onError: (err, items) => {
runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`));
const chatId = items[0]?.msg.chat.id;
if (chatId != null) {
const threadId = items[0]?.msg.message_thread_id;
@@ -639,15 +568,6 @@ export const registerTelegramHandlers = ({
}
},
onCancel: (items) => {
settleSpooledReplayParticipants(
items
.map((item) => item.spooledReplayParticipant)
.filter(
(participant): participant is TelegramSpooledReplayDeferredParticipant =>
participant !== undefined,
),
{ kind: "skipped" },
);
releaseDispatchDedupeKeys(
mergeDispatchDedupeKeys(...items.map((item) => item.dispatchDedupeKeys)),
);
@@ -888,7 +808,6 @@ export const registerTelegramHandlers = ({
const primaryEntry = captionMsg ?? entry.messages[0];
if (!primaryEntry) {
releaseDispatchDedupeKeys(entry.dispatchDedupeKeys);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
@@ -909,7 +828,6 @@ export const registerTelegramHandlers = ({
})
) {
releaseDispatchDedupeKeys(entry.dispatchDedupeKeys);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
@@ -964,24 +882,16 @@ export const registerTelegramHandlers = ({
}).catch(() => {});
}
const result = await processMessageWithReplyChain({
await processMessageWithReplyChain({
ctx: primaryEntry.ctx,
msg: primaryEntry.msg,
allMedia,
storeAllowFrom: entry.storeAllowFrom,
options: {
...promptContextBoundaryOptions(entry.promptContextMinTimestampMs),
...spooledReplayOptions(entry.spooledReplayParticipants),
},
options: promptContextBoundaryOptions(entry.promptContextMinTimestampMs),
dispatchDedupeKeys: entry.dispatchDedupeKeys,
});
settleSpooledReplayParticipants(entry.spooledReplayParticipants, result);
} catch (err) {
releaseDispatchDedupeKeys(entry.dispatchDedupeKeys, err);
settleSpooledReplayParticipants(
entry.spooledReplayParticipants,
buildFailedProcessingResult(err),
);
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
}
};
@@ -994,14 +904,12 @@ export const registerTelegramHandlers = ({
const last = entry.messages.at(-1);
if (!first || !last) {
releaseDispatchDedupeKeys(entry.dispatchDedupeKeys);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
const combinedText = entry.messages.map((m) => m.msg.text ?? "").join("");
if (!combinedText.trim()) {
releaseDispatchDedupeKeys(entry.dispatchDedupeKeys);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
@@ -1015,7 +923,7 @@ export const registerTelegramHandlers = ({
const baseCtx = first.ctx;
const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage);
const result = await processMessageWithReplyChain({
await processMessageWithReplyChain({
ctx: syntheticCtx,
msg: syntheticMessage,
allMedia: [],
@@ -1025,17 +933,11 @@ export const registerTelegramHandlers = ({
receivedAtMs: first.receivedAtMs,
ingressBuffer: "text-fragment",
...promptContextBoundaryOptions(entry.promptContextMinTimestampMs),
...spooledReplayOptions(entry.spooledReplayParticipants),
},
dispatchDedupeKeys: entry.dispatchDedupeKeys,
});
settleSpooledReplayParticipants(entry.spooledReplayParticipants, result);
} catch (err) {
releaseDispatchDedupeKeys(entry.dispatchDedupeKeys, err);
settleSpooledReplayParticipants(
entry.spooledReplayParticipants,
buildFailedProcessingResult(err),
);
runtime.error?.(danger(`text fragment handler failed: ${String(err)}`));
}
};
@@ -1219,15 +1121,8 @@ export const registerTelegramHandlers = ({
storeAllowFrom: string[];
options?: TelegramMessageContextOptions;
dispatchDedupeKeys?: string[];
}): Promise<TelegramMessageProcessingResult> => {
}) => {
let dispatchDedupeCommitted = false;
let dispatchDedupeRollbackAttempted = false;
const spooledReplay =
params.options?.spooledReplay === true || isTelegramSpooledReplayUpdate(params.ctx.update);
const forgetCommittedDispatchDedupeKeys = async () => {
dispatchDedupeRollbackAttempted = true;
await forgetDispatchDedupeKeys(params.dispatchDedupeKeys ?? []);
};
try {
const replyChainNodes = await buildReplyChainForMessage(params.msg);
const { replyMedia, replyChain } = await resolveReplyMediaForChain(
@@ -1239,7 +1134,7 @@ export const registerTelegramHandlers = ({
replyChainNodes,
params.options,
);
const result = await processMessage(
const dispatched = await processMessage(
params.ctx,
params.allMedia,
params.storeAllowFrom,
@@ -1254,18 +1149,11 @@ export const registerTelegramHandlers = ({
},
},
);
if (result.kind === "completed" && !dispatchDedupeCommitted) {
await commitDispatchDedupeKeys(params.dispatchDedupeKeys ?? []);
} else if (result.kind === "failed-retryable" && dispatchDedupeCommitted && spooledReplay) {
await forgetCommittedDispatchDedupeKeys();
} else if (result.kind !== "completed" && !dispatchDedupeCommitted) {
if (!dispatched && !dispatchDedupeCommitted) {
releaseDispatchDedupeKeys(params.dispatchDedupeKeys ?? []);
}
return result;
} catch (err) {
if (dispatchDedupeCommitted && spooledReplay && !dispatchDedupeRollbackAttempted) {
await forgetCommittedDispatchDedupeKeys();
} else if (!dispatchDedupeCommitted) {
if (!dispatchDedupeCommitted) {
releaseDispatchDedupeKeys(params.dispatchDedupeKeys ?? [], err);
}
throw err;
@@ -1415,8 +1303,11 @@ export const registerTelegramHandlers = ({
}
}
const TELEGRAM_PERMANENT_CALLBACK_EDIT_ERROR_RE =
/400:\s*Bad Request:\s*message to edit not found|400:\s*Bad Request:\s*there is no text in the message to edit|MESSAGE_ID_INVALID|400:\s*Bad Request:\s*message can't be edited/i;
const isPermanentTelegramCallbackEditError = (err: unknown): boolean =>
isTelegramEditTargetMissingError(err) || isTelegramMessageHasNoTextError(err);
TELEGRAM_PERMANENT_CALLBACK_EDIT_ERROR_RE.test(String(err));
const resolveTelegramEventAuthorizationContext = async (params: {
chatId: number;
@@ -1835,12 +1726,6 @@ export const registerTelegramHandlers = ({
existing.messages.length + 1 <= TELEGRAM_TEXT_FRAGMENT_MAX_PARTS &&
nextTotalChars <= TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS
) {
const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork(
`text-fragment:${key}:${msg.message_id}`,
);
if (spooledReplayParticipant) {
existing.spooledReplayParticipants.push(spooledReplayParticipant);
}
existing.messages.push({ msg, ctx, receivedAtMs: nowMs });
existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs(
existing.promptContextMinTimestampMs,
@@ -1863,14 +1748,10 @@ export const registerTelegramHandlers = ({
const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS;
if (shouldStart) {
const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork(
`text-fragment:${key}:${msg.message_id}`,
);
const entry: TextFragmentEntry = {
key,
messages: [{ msg, ctx, receivedAtMs: nowMs }],
dispatchDedupeKeys,
spooledReplayParticipants: spooledReplayParticipant ? [spooledReplayParticipant] : [],
...promptContextBoundaryOptions(promptContextMinTimestampMs),
timer: setTimeout(() => {}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS),
};
@@ -1887,7 +1768,6 @@ export const registerTelegramHandlers = ({
clearTimeout(existing.timer);
textFragmentBuffer.delete(key);
releaseDispatchDedupeKeys(existing.dispatchDedupeKeys);
settleSpooledReplayParticipants(existing.spooledReplayParticipants, { kind: "skipped" });
}
}
@@ -1898,12 +1778,6 @@ export const registerTelegramHandlers = ({
const mediaGroupKey = `media:${chatId}:${threadId ?? "main"}:${mediaGroupId}`;
const existing = mediaGroupBuffer.get(mediaGroupKey);
if (existing) {
const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork(
`media-group:${mediaGroupKey}:${msg.message_id}`,
);
if (spooledReplayParticipant) {
existing.spooledReplayParticipants.push(spooledReplayParticipant);
}
clearTimeout(existing.timer);
existing.messages.push({ msg, ctx });
existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs(
@@ -1921,9 +1795,6 @@ export const registerTelegramHandlers = ({
});
}, mediaGroupTimeoutMs);
} else {
const spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork(
`media-group:${mediaGroupKey}:${msg.message_id}`,
);
const entry: BufferedMediaGroupEntry = {
messages: [{ msg, ctx }],
storeAllowFrom,
@@ -1937,7 +1808,6 @@ export const registerTelegramHandlers = ({
groupConfig,
topicConfig,
dispatchDedupeKeys,
spooledReplayParticipants: spooledReplayParticipant ? [spooledReplayParticipant] : [],
...promptContextBoundaryOptions(promptContextMinTimestampMs),
timer: setTimeout(() => {
mediaGroupBuffer.delete(mediaGroupKey);
@@ -2057,7 +1927,7 @@ export const registerTelegramHandlers = ({
);
}
}
const debounceEntry: TelegramDebounceEntry = {
await inboundDebouncer.enqueue({
ctx,
msg,
allMedia,
@@ -2068,17 +1938,7 @@ export const registerTelegramHandlers = ({
botUsername,
...promptContextBoundaryOptions(promptContextMinTimestampMs),
dispatchDedupeKeys,
};
if (
debounceEntry.debounceKey &&
resolveTelegramDebounceEntryMs(debounceEntry) > 0 &&
shouldDebounceTelegramEntry(debounceEntry)
) {
debounceEntry.spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork(
`inbound-debounce:${debounceEntry.debounceKey}`,
);
}
await inboundDebouncer.enqueue(debounceEntry);
});
};
bot.on("callback_query", async (ctx) => {
const callback = ctx.callbackQuery;

View File

@@ -25,7 +25,6 @@ export type TelegramMessageContextOptions = {
receivedAtMs?: number;
ingressBuffer?: "inbound-debounce" | "text-fragment";
promptContextMinTimestampMs?: number;
spooledReplay?: boolean;
};
export type TelegramPromptContextEntry = NonNullable<

View File

@@ -523,12 +523,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramDeps?: TelegramBotDeps;
bot?: Bot;
replyToMode?: Parameters<typeof dispatchTelegramMessage>[0]["replyToMode"];
retryDispatchErrors?: boolean;
suppressFailureFallback?: boolean;
textLimit?: number;
}) {
const bot = params.bot ?? createBot();
return await dispatchTelegramMessage({
await dispatchTelegramMessage({
context: params.context,
bot,
cfg: params.cfg ?? {},
@@ -539,8 +537,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: params.telegramCfg ?? {},
telegramDeps: params.telegramDeps ?? telegramDepsForTest,
opts: { token: "token" },
retryDispatchErrors: params.retryDispatchErrors,
suppressFailureFallback: params.suppressFailureFallback,
});
}
@@ -2405,42 +2401,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).toHaveBeenCalledTimes(1);
});
it("returns retryable when spooled replay suppresses fallback after non-silent delivery skip", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
dispatcherOptions.onSkip?.({ text: "final answer" }, { kind: "final", reason: "empty" });
return { queuedFinal: false };
});
const result = await dispatchWithContext({
context: createContext(),
retryDispatchErrors: true,
suppressFailureFallback: true,
});
expect(result).toMatchObject({ kind: "failed-retryable" });
expect((result as { error?: unknown }).error).toBeInstanceOf(Error);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not return retryable after spooled replay already showed visible output", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "partial answer" }, { kind: "block" });
dispatcherOptions.onSkip?.({ text: "final answer" }, { kind: "final", reason: "empty" });
return { queuedFinal: false };
});
const result = await dispatchWithContext({
context: createContext(),
retryDispatchErrors: true,
suppressFailureFallback: true,
});
expect(result).toEqual({ kind: "completed" });
expect(answerDraftStream.update).toHaveBeenCalledWith("partial answer");
expect(deliverReplies).not.toHaveBeenCalled();
});
it("keeps tool progress visible after a partial-streamed intermediate block", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(

View File

@@ -251,14 +251,8 @@ type DispatchTelegramMessageParams = {
telegramCfg: TelegramAccountConfig;
telegramDeps?: TelegramBotDeps;
opts: Pick<TelegramBotOptions, "token" | "mediaMaxMb">;
retryDispatchErrors?: boolean;
suppressFailureFallback?: boolean;
};
export type TelegramDispatchResult =
| { kind: "completed" }
| { kind: "failed-retryable"; error: unknown };
type TelegramReasoningLevel = "off" | "on" | "stream";
type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] };
@@ -727,9 +721,7 @@ export const dispatchTelegramMessage = async ({
telegramCfg,
telegramDeps: injectedTelegramDeps,
opts,
retryDispatchErrors = false,
suppressFailureFallback = false,
}: DispatchTelegramMessageParams): Promise<TelegramDispatchResult> => {
}: DispatchTelegramMessageParams) => {
const dispatchStartedAt = Date.now();
const dispatchContext = resolveDispatchTelegramContext({ cfg, context });
const telegramDeps =
@@ -2479,7 +2471,7 @@ export const dispatchTelegramMessage = async ({
},
});
if (!turnResult.dispatched) {
return { kind: "completed" };
return;
}
({ queuedFinal } = turnResult.dispatchResult);
suppressSilentReplyFallback =
@@ -2547,13 +2539,12 @@ export const dispatchTelegramMessage = async ({
if (!isRoomEvent || deliveryState.snapshot().delivered) {
clearGroupHistory();
}
return { kind: "completed" };
return;
}
let sentFallback = false;
const deliverySummary = deliveryState.snapshot();
const shouldSendFailureFallback =
!isRoomEvent &&
!suppressFailureFallback &&
(dispatchError ||
(!deliverySummary.delivered &&
(deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0)));
@@ -2608,16 +2599,6 @@ export const dispatchTelegramMessage = async ({
const hasFinalResponse =
deliverySummary.delivered || sentFallback || suppressSilentReplyFallback || queuedFinal;
const deliveryFailureWithoutFinalResponse =
!deliverySummary.delivered &&
(deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0);
const retryableDispatchFailure =
dispatchError ??
(deliveryFailureWithoutFinalResponse
? new Error(
`Telegram reply delivery failed without a final response (failed=${deliverySummary.failedNonSilent}, skipped=${deliverySummary.skippedNonSilent})`,
)
: null);
if (statusReactionController && !hasFinalResponse) {
void finalizeTelegramStatusReaction({ outcome: "error", hasFinalResponse: false }).catch(
@@ -2630,16 +2611,12 @@ export const dispatchTelegramMessage = async ({
const shouldClearGroupHistory =
!isRoomEvent || deliverySummary.delivered || sentFallback || queuedFinal;
if (retryableDispatchFailure && retryDispatchErrors && !hasFinalResponse) {
return { kind: "failed-retryable", error: retryableDispatchFailure };
}
if (!hasFinalResponse) {
if (!shouldClearGroupHistory) {
return { kind: "completed" };
return;
}
clearGroupHistory();
return { kind: "completed" };
return;
}
// Fire-and-forget: auto-rename DM topic on first message.
@@ -2713,5 +2690,4 @@ export const dispatchTelegramMessage = async ({
if (shouldClearGroupHistory) {
clearGroupHistory();
}
return { kind: "completed" };
};

View File

@@ -30,15 +30,11 @@ vi.mock("./bot-message-dispatch.js", () => ({
let createTelegramMessageProcessor: typeof import("./bot-message.js").createTelegramMessageProcessor;
let formatTelegramInboundLogLine: typeof import("./bot-message.js").formatTelegramInboundLogLine;
let runWithTelegramUpdateProcessingFrame: typeof import("./bot-processing-outcome.js").runWithTelegramUpdateProcessingFrame;
let withTelegramSpooledReplayUpdate: typeof import("./bot-processing-outcome.js").withTelegramSpooledReplayUpdate;
describe("telegram bot message processor", () => {
beforeAll(async () => {
({ createTelegramMessageProcessor, formatTelegramInboundLogLine } =
await import("./bot-message.js"));
({ runWithTelegramUpdateProcessingFrame, withTelegramSpooledReplayUpdate } =
await import("./bot-processing-outcome.js"));
});
beforeEach(() => {
@@ -78,8 +74,6 @@ describe("telegram bot message processor", () => {
async function processSampleMessage(
processMessage: ReturnType<typeof createTelegramMessageProcessor>,
lifecycle?: import("./bot-message.js").TelegramMessageProcessorLifecycle,
primaryCtxOverrides: Record<string, unknown> = {},
options: Parameters<typeof processMessage>[3] = {},
) {
return await processMessage(
{
@@ -87,11 +81,10 @@ describe("telegram bot message processor", () => {
chat: { id: 123, type: "private", title: "chat" },
message_id: 456,
},
...primaryCtxOverrides,
} as unknown as Parameters<typeof processMessage>[0],
[],
[],
options,
{},
undefined,
undefined,
undefined,
@@ -104,15 +97,14 @@ describe("telegram bot message processor", () => {
sendMessage: ReturnType<typeof vi.fn>,
) {
const runtimeError = vi.fn();
const dispatchError = new Error("dispatch exploded");
buildTelegramMessageContext.mockResolvedValue(createMessageContext(context));
dispatchTelegramMessage.mockRejectedValue(dispatchError);
dispatchTelegramMessage.mockRejectedValue(new Error("dispatch exploded"));
const processMessage = createTelegramMessageProcessor({
...baseDeps,
bot: { api: { sendMessage } },
runtime: { error: runtimeError },
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0]);
return { processMessage, runtimeError, dispatchError };
return { processMessage, runtimeError };
}
function createMessageContext(context: Record<string, unknown> = {}) {
@@ -140,7 +132,7 @@ describe("telegram bot message processor", () => {
);
const processMessage = createTelegramMessageProcessor(baseDeps);
await expect(processSampleMessage(processMessage)).resolves.toEqual({ kind: "completed" });
await expect(processSampleMessage(processMessage)).resolves.toBe(true);
expect(sendTyping).toHaveBeenCalledTimes(1);
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
@@ -162,9 +154,7 @@ describe("telegram bot message processor", () => {
);
const processMessage = createTelegramMessageProcessor(baseDeps);
await expect(processSampleMessage(processMessage, { onDispatchStart })).resolves.toEqual({
kind: "completed",
});
await expect(processSampleMessage(processMessage, { onDispatchStart })).resolves.toBe(true);
expect(sendTyping).toHaveBeenCalledTimes(1);
expect(onDispatchStart).toHaveBeenCalledTimes(1);
@@ -182,9 +172,7 @@ describe("telegram bot message processor", () => {
buildTelegramMessageContext.mockResolvedValue(null);
const processMessage = createTelegramMessageProcessor(baseDeps);
await expect(processSampleMessage(processMessage, { onDispatchStart })).resolves.toEqual({
kind: "skipped",
});
await expect(processSampleMessage(processMessage, { onDispatchStart })).resolves.toBe(false);
expect(onDispatchStart).not.toHaveBeenCalled();
expect(dispatchTelegramMessage).not.toHaveBeenCalled();
@@ -206,7 +194,7 @@ describe("telegram bot message processor", () => {
);
const processMessage = createTelegramMessageProcessor(baseDeps);
await expect(processSampleMessage(processMessage)).resolves.toEqual({ kind: "completed" });
await expect(processSampleMessage(processMessage)).resolves.toBe(true);
expect(sendTyping).not.toHaveBeenCalled();
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
@@ -215,7 +203,7 @@ describe("telegram bot message processor", () => {
it("skips dispatch when no context is produced", async () => {
buildTelegramMessageContext.mockResolvedValue(null);
const processMessage = createTelegramMessageProcessor(baseDeps);
await expect(processSampleMessage(processMessage)).resolves.toEqual({ kind: "skipped" });
await expect(processSampleMessage(processMessage)).resolves.toBe(false);
expect(dispatchTelegramMessage).not.toHaveBeenCalled();
expect(telegramInboundInfo).not.toHaveBeenCalled();
});
@@ -249,7 +237,7 @@ describe("telegram bot message processor", () => {
);
const processMessage = createTelegramMessageProcessor(baseDeps);
await expect(processSampleMessage(processMessage)).resolves.toEqual({ kind: "completed" });
await expect(processSampleMessage(processMessage)).resolves.toBe(true);
expect(sendTyping).toHaveBeenCalledTimes(1);
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
@@ -257,7 +245,7 @@ describe("telegram bot message processor", () => {
it("sends user-visible fallback when dispatch throws", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage, runtimeError, dispatchError } = createDispatchFailureHarness(
const { processMessage, runtimeError } = createDispatchFailureHarness(
{
chatId: 123,
threadSpec: { id: 456, scope: "forum" },
@@ -265,9 +253,7 @@ describe("telegram bot message processor", () => {
},
sendMessage,
);
const result = await processSampleMessage(processMessage);
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
await expect(processSampleMessage(processMessage)).resolves.toBe(true);
expect(sendMessage).toHaveBeenCalledWith(
123,
@@ -279,104 +265,9 @@ describe("telegram bot message processor", () => {
);
});
it("suppresses user-visible fallback while replaying a spooled update", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage, runtimeError, dispatchError } = createDispatchFailureHarness(
{
chatId: 123,
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
const update = { update_id: 123456 };
const result = await withTelegramSpooledReplayUpdate(update, async () =>
processSampleMessage(processMessage, undefined, { update }),
);
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
expect(sendMessage).not.toHaveBeenCalled();
expect(runtimeError).toHaveBeenCalledWith(
"telegram message processing failed: Error: dispatch exploded",
);
});
it("suppresses user-visible fallback for synthetic buffered spooled replay contexts", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage, runtimeError, dispatchError } = createDispatchFailureHarness(
{
chatId: 123,
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
const result = await processSampleMessage(
processMessage,
undefined,
{},
{ spooledReplay: true },
);
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
expect(sendMessage).not.toHaveBeenCalled();
expect(dispatchTelegramMessage).toHaveBeenCalledWith(
expect.objectContaining({
retryDispatchErrors: true,
suppressFailureFallback: true,
}),
);
expect(runtimeError).toHaveBeenCalledWith(
"telegram message processing failed: Error: dispatch exploded",
);
});
it("does not record buffered spooled replay failures into the ambient update frame", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage, dispatchError } = createDispatchFailureHarness(
{
chatId: 123,
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
const frame = await runWithTelegramUpdateProcessingFrame(async () =>
processSampleMessage(processMessage, undefined, {}, { spooledReplay: true }),
);
expect(frame.value).toEqual({ kind: "failed-retryable", error: dispatchError });
expect(frame.result).toBeUndefined();
});
it("propagates spooled dispatcher failure results without sending fallback", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const dispatchError = new Error("agent dispatch failed");
const runtimeError = vi.fn();
buildTelegramMessageContext.mockResolvedValue(createMessageContext({ chatId: 123 }));
dispatchTelegramMessage.mockResolvedValue({ kind: "failed-retryable", error: dispatchError });
const processMessage = createTelegramMessageProcessor({
...baseDeps,
bot: { api: { sendMessage } },
runtime: { error: runtimeError },
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0]);
const update = { update_id: 123457 };
const result = await withTelegramSpooledReplayUpdate(update, async () =>
processSampleMessage(processMessage, undefined, { update }),
);
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
expect(sendMessage).not.toHaveBeenCalled();
expect(dispatchTelegramMessage).toHaveBeenCalledWith(
expect.objectContaining({
retryDispatchErrors: true,
suppressFailureFallback: true,
}),
);
expect(runtimeError).not.toHaveBeenCalled();
});
it("omits message_thread_id for General-topic fallback replies", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage, dispatchError } = createDispatchFailureHarness(
const { processMessage } = createDispatchFailureHarness(
{
chatId: 123,
threadSpec: { id: 1, scope: "forum" },
@@ -384,9 +275,7 @@ describe("telegram bot message processor", () => {
},
sendMessage,
);
const result = await processSampleMessage(processMessage);
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
await expect(processSampleMessage(processMessage)).resolves.toBe(true);
expect(sendMessage).toHaveBeenCalledWith(
123,
@@ -397,16 +286,14 @@ describe("telegram bot message processor", () => {
it("swallows fallback delivery failures after dispatch throws", async () => {
const sendMessage = vi.fn().mockRejectedValue(new Error("blocked by user"));
const { processMessage, runtimeError, dispatchError } = createDispatchFailureHarness(
const { processMessage, runtimeError } = createDispatchFailureHarness(
{
chatId: 123,
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
const result = await processSampleMessage(processMessage);
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
await expect(processSampleMessage(processMessage)).resolves.toBe(true);
expect(sendMessage).toHaveBeenCalledWith(
123,

View File

@@ -17,11 +17,6 @@ import {
import type { TelegramMessageContextOptions } from "./bot-message-context.types.js";
import type { TelegramPromptContextEntry } from "./bot-message-context.types.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
type TelegramMessageProcessingResult,
} from "./bot-processing-outcome.js";
import type { TelegramBotOptions } from "./bot.types.js";
import { buildTelegramThreadParams } from "./bot/helpers.js";
import type { TelegramContext, TelegramStreamMode } from "./bot/types.js";
@@ -123,12 +118,6 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
const ingressDebugEnabled =
shouldLogVerbose() || process.env.OPENCLAW_DEBUG_TELEGRAM_INGRESS === "1";
const ingressContextStartMs = ingressReceivedAtMs ? Date.now() : undefined;
const recordCurrentUpdateProcessingResult = (result: TelegramMessageProcessingResult) => {
if (options?.spooledReplay === true) {
return;
}
recordTelegramMessageProcessingResult(result);
};
const context = await buildTelegramMessageContext({
primaryCtx,
allMedia,
@@ -163,9 +152,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
const result: TelegramMessageProcessingResult = { kind: "skipped" };
recordCurrentUpdateProcessingResult(result);
return result;
return false;
}
if (ingressDebugEnabled && ingressReceivedAtMs && ingressContextStartMs) {
logVerbose(
@@ -194,10 +181,8 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
}),
);
await lifecycle?.onDispatchStart?.();
const spooledReplay =
options?.spooledReplay === true || isTelegramSpooledReplayUpdate(primaryCtx.update);
try {
const dispatchResult = await dispatchTelegramMessage({
await dispatchTelegramMessage({
context,
bot,
cfg,
@@ -208,43 +193,23 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
telegramCfg,
telegramDeps,
opts,
retryDispatchErrors: spooledReplay,
suppressFailureFallback: spooledReplay,
});
if (dispatchResult?.kind === "failed-retryable") {
const result: TelegramMessageProcessingResult = {
kind: "failed-retryable",
error: dispatchResult.error,
};
recordCurrentUpdateProcessingResult(result);
return result;
}
if (ingressDebugEnabled && ingressReceivedAtMs) {
logVerbose(
`telegram ingress: chatId=${context.chatId} dispatchCompleteMs=${Date.now() - ingressReceivedAtMs}` +
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
const result: TelegramMessageProcessingResult = { kind: "completed" };
recordCurrentUpdateProcessingResult(result);
return result;
} catch (err) {
runtime.error?.(danger(`telegram message processing failed: ${String(err)}`));
if (!spooledReplay) {
try {
await bot.api.sendMessage(
context.chatId,
"Something went wrong while processing your request. Please try again.",
buildTelegramThreadParams(context.threadSpec),
);
} catch {}
}
const result: TelegramMessageProcessingResult = {
kind: "failed-retryable",
error: err,
};
recordCurrentUpdateProcessingResult(result);
return result;
try {
await bot.api.sendMessage(
context.chatId,
"Something went wrong while processing your request. Please try again.",
buildTelegramThreadParams(context.threadSpec),
);
} catch {}
}
return true;
};
};

View File

@@ -65,7 +65,6 @@ import {
syncTelegramMenuCommands as syncTelegramMenuCommandsRuntime,
type TelegramMenuCommand,
} from "./bot-native-command-menu.js";
import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js";
import type { TelegramUpdateKeyContext } from "./bot-updates.js";
import type { TelegramBotOptions } from "./bot.types.js";
import {
@@ -445,7 +444,7 @@ export type RegisterTelegramHandlerParams = {
replyChain?: import("./message-cache.js").TelegramReplyChainEntry[],
promptContext?: import("./bot-message-context.types.js").TelegramPromptContextEntry[],
lifecycle?: import("./bot-message.js").TelegramMessageProcessorLifecycle,
) => Promise<TelegramMessageProcessingResult>;
) => Promise<boolean>;
logger: ReturnType<typeof getChildLogger>;
};

View File

@@ -1,126 +0,0 @@
// Telegram plugin module tracks per-update processing outcomes.
import { AsyncLocalStorage } from "node:async_hooks";
export type TelegramMessageProcessingResult =
| { kind: "completed" }
| { kind: "skipped" }
| { kind: "failed-retryable"; error: unknown };
type TelegramUpdateProcessingFrame = {
result?: TelegramMessageProcessingResult;
};
type TelegramSpooledReplayFrame = {
deferredWork?: TelegramSpooledReplayDeferredParticipant;
};
export type TelegramSpooledReplayDeferredParticipant = {
key: string;
task: Promise<TelegramMessageProcessingResult>;
settle: (result: TelegramMessageProcessingResult) => void;
};
const telegramUpdateProcessingFrames = new AsyncLocalStorage<TelegramUpdateProcessingFrame>();
const telegramSpooledReplayFrames = new AsyncLocalStorage<TelegramSpooledReplayFrame>();
const telegramSpooledReplayUpdates = new WeakSet<object>();
export class TelegramSpooledReplayProcessingError extends Error {
override readonly cause: unknown;
constructor(cause: unknown) {
super(`telegram spooled update processing failed: ${String(cause)}`);
this.name = "TelegramSpooledReplayProcessingError";
this.cause = cause;
}
}
export async function runWithTelegramUpdateProcessingFrame<T>(
fn: () => Promise<T>,
): Promise<{ value: T; result?: TelegramMessageProcessingResult }> {
const frame: TelegramUpdateProcessingFrame = {};
const value = await telegramUpdateProcessingFrames.run(frame, fn);
return frame.result ? { value, result: frame.result } : { value };
}
export function recordTelegramMessageProcessingResult(
result: TelegramMessageProcessingResult,
): void {
const frame = telegramUpdateProcessingFrames.getStore();
if (!frame) {
return;
}
if (result.kind === "failed-retryable") {
frame.result = result;
return;
}
if (!frame.result || frame.result.kind === "skipped") {
frame.result = result;
}
}
function createTelegramSpooledReplayParticipant(
key: string,
): TelegramSpooledReplayDeferredParticipant {
let settled = false;
let resolveTask: (result: TelegramMessageProcessingResult) => void = () => {};
const task = new Promise<TelegramMessageProcessingResult>((resolve) => {
resolveTask = resolve;
});
return {
key,
task,
settle: (result) => {
if (settled) {
return;
}
settled = true;
resolveTask(result);
},
};
}
export function createTelegramSpooledReplayDeferredParticipant(
key: string,
): TelegramSpooledReplayDeferredParticipant | null {
const frame = telegramSpooledReplayFrames.getStore();
if (!frame) {
return null;
}
const participant = createTelegramSpooledReplayParticipant(key);
frame.deferredWork = participant;
return participant;
}
export function getTelegramSpooledReplayDeferredParticipant():
| TelegramSpooledReplayDeferredParticipant
| undefined {
return telegramSpooledReplayFrames.getStore()?.deferredWork;
}
export async function runWithTelegramSpooledReplayUpdate<T>(
update: object,
fn: () => Promise<T>,
): Promise<{ value: T; deferredWork?: TelegramSpooledReplayDeferredParticipant }> {
const frame: TelegramSpooledReplayFrame = {};
telegramSpooledReplayUpdates.add(update);
try {
const value = await telegramSpooledReplayFrames.run(frame, fn);
return frame.deferredWork ? { value, deferredWork: frame.deferredWork } : { value };
} finally {
telegramSpooledReplayUpdates.delete(update);
}
}
export async function withTelegramSpooledReplayUpdate<T>(
update: object,
fn: () => Promise<T>,
): Promise<T> {
return (await runWithTelegramSpooledReplayUpdate(update, fn)).value;
}
export function isTelegramSpooledReplayUpdate(update: unknown): boolean {
return (
telegramSpooledReplayFrames.getStore() !== undefined ||
(typeof update === "object" && update !== null && telegramSpooledReplayUpdates.has(update))
);
}

View File

@@ -63,13 +63,6 @@ const {
resolveTelegramScopedGroupConfig,
setTelegramBotRuntimeForTest,
} = await import("./bot-core.js");
const {
createTelegramSpooledReplayDeferredParticipant,
recordTelegramMessageProcessingResult,
runWithTelegramSpooledReplayUpdate,
TelegramSpooledReplayProcessingError,
withTelegramSpooledReplayUpdate,
} = await import("./bot-processing-outcome.js");
const { resolveTelegramConversationRoute } = await import("./conversation-route.js");
const { clearAccountThrottlersForTest } = await import("./account-throttler.js");
const {
@@ -848,146 +841,6 @@ describe("createTelegramBot", () => {
},
);
it("settles spooled replay participants when stop cancels pending inbound debounce", async () => {
const DEBOUNCE_MS = 4321;
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
messages: {
inbound: {
debounceMs: DEBOUNCE_MS,
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
installPerKeySequentializer();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
createTelegramBot({ token: "tok" });
const messageHandler = getOnHandler("message") as (
ctx: TelegramMiddlewareTestContext,
) => Promise<void>;
const firstUpdate = { update_id: 201 };
const replay = await runWithTelegramSpooledReplayUpdate(firstUpdate, async () => {
await runTelegramMiddlewareChain({
ctx: {
update: firstUpdate,
message: {
chat: { id: 7, type: "private" },
text: "first",
date: 1736380800,
message_id: 201,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
},
finalHandler: messageHandler,
});
});
const deferredWork = replay.deferredWork;
expect(deferredWork).toBeDefined();
if (!deferredWork) {
throw new Error("Expected spooled replay deferred work");
}
await runTelegramMiddlewareChain({
ctx: {
update: { update_id: 202 },
message: {
chat: { id: 7, type: "private" },
text: "stop",
date: 1736380801,
message_id: 202,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
},
finalHandler: messageHandler,
});
await expect(deferredWork.task).resolves.toEqual({ kind: "skipped" });
const debounceCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
(call) => call[1] === DEBOUNCE_MS,
);
expect(debounceCallIndex).toBeGreaterThanOrEqual(0);
clearTimeout(
setTimeoutSpy.mock.results[debounceCallIndex]?.value as ReturnType<typeof setTimeout>,
);
} finally {
setTimeoutSpy.mockRestore();
}
});
it("settles spooled replay participants when stop cancels pending text fragments", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
installPerKeySequentializer();
createTelegramBot({ token: "tok" });
const messageHandler = getOnHandler("message") as (
ctx: TelegramMiddlewareTestContext,
) => Promise<void>;
const firstUpdate = { update_id: 211 };
const replay = await runWithTelegramSpooledReplayUpdate(firstUpdate, async () => {
await runTelegramMiddlewareChain({
ctx: {
update: firstUpdate,
message: {
chat: { id: 7, type: "private" },
text: "A".repeat(4050),
date: 1736380800,
message_id: 211,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
},
finalHandler: messageHandler,
});
});
const deferredWork = replay.deferredWork;
expect(deferredWork).toBeDefined();
if (!deferredWork) {
throw new Error("Expected spooled replay deferred work");
}
await runTelegramMiddlewareChain({
ctx: {
update: { update_id: 212 },
message: {
chat: { id: 7, type: "private" },
text: "stop",
date: 1736380801,
message_id: 212,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
},
finalHandler: messageHandler,
});
await expect(deferredWork.task).resolves.toEqual({ kind: "skipped" });
});
it("lets stop cancel pending same-chat forwarded debounce", async () => {
const DEBOUNCE_MS = 4321;
loadConfig.mockReturnValue({
@@ -2496,213 +2349,6 @@ describe("createTelegramBot", () => {
expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([202]);
});
it("persists recorded dispatch failures during normal polling", async () => {
sequentializeSpy.mockImplementationOnce(
() => async (_ctx: unknown, next: () => Promise<void>) => {
await next();
},
);
const onUpdateId = vi.fn();
createTelegramBot({
token: "tok",
updateOffset: {
lastUpdateId: 500,
onUpdateId,
},
});
type Middleware = (
ctx: Record<string, unknown>,
next: () => Promise<void>,
) => Promise<void> | void;
const middlewares = middlewareUseSpy.mock.calls
.map((call) => call[0])
.filter((fn): fn is Middleware => typeof fn === "function");
const runMiddlewareChain = async (
ctx: Record<string, unknown>,
finalNext: () => Promise<void>,
) => {
let idx = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= idx) {
throw new Error("middleware dispatch called multiple times");
}
idx = i;
const fn = middlewares[i];
if (!fn) {
await finalNext();
return;
}
await fn(ctx, async () => dispatch(i + 1));
};
await dispatch(0);
};
const dispatchError = new Error("dispatch exploded");
await runMiddlewareChain({ update: { update_id: 501 } }, async () => {
recordTelegramMessageProcessingResult({
kind: "failed-retryable",
error: dispatchError,
});
});
await flushTelegramTestMicrotasks();
expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([501]);
await runMiddlewareChain({ update: { update_id: 502 } }, async () => {});
await flushTelegramTestMicrotasks();
expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([501, 502]);
});
it("rejects recorded dispatch failures during isolated spool replay", async () => {
sequentializeSpy.mockImplementationOnce(
() => async (_ctx: unknown, next: () => Promise<void>) => {
await next();
},
);
const onUpdateId = vi.fn();
createTelegramBot({
token: "tok",
updateOffset: {
lastUpdateId: 600,
onUpdateId,
},
});
type Middleware = (
ctx: Record<string, unknown>,
next: () => Promise<void>,
) => Promise<void> | void;
const middlewares = middlewareUseSpy.mock.calls
.map((call) => call[0])
.filter((fn): fn is Middleware => typeof fn === "function");
const runMiddlewareChain = async (
ctx: Record<string, unknown>,
finalNext: () => Promise<void>,
) => {
let idx = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= idx) {
throw new Error("middleware dispatch called multiple times");
}
idx = i;
const fn = middlewares[i];
if (!fn) {
await finalNext();
return;
}
await fn(ctx, async () => dispatch(i + 1));
};
await dispatch(0);
};
const update = { update_id: 601 };
const dispatchError = new Error("dispatch exploded");
await expect(
withTelegramSpooledReplayUpdate(update, async () => {
await runMiddlewareChain({ update }, async () => {
recordTelegramMessageProcessingResult({
kind: "failed-retryable",
error: dispatchError,
});
});
}),
).rejects.toMatchObject({
name: TelegramSpooledReplayProcessingError.name,
cause: dispatchError,
});
await flushTelegramTestMicrotasks();
expect(onUpdateId).not.toHaveBeenCalled();
});
it("keeps deferred spooled failures retryable in the same bot tracker", async () => {
sequentializeSpy.mockImplementationOnce(
() => async (_ctx: unknown, next: () => Promise<void>) => {
await next();
},
);
const onUpdateId = vi.fn();
createTelegramBot({
token: "tok",
updateOffset: {
lastUpdateId: 700,
onUpdateId,
},
});
type Middleware = (
ctx: Record<string, unknown>,
next: () => Promise<void>,
) => Promise<void> | void;
const middlewares = middlewareUseSpy.mock.calls
.map((call) => call[0])
.filter((fn): fn is Middleware => typeof fn === "function");
const runMiddlewareChain = async (
ctx: Record<string, unknown>,
finalNext: () => Promise<void>,
) => {
let idx = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= idx) {
throw new Error("middleware dispatch called multiple times");
}
idx = i;
const fn = middlewares[i];
if (!fn) {
await finalNext();
return;
}
await fn(ctx, async () => dispatch(i + 1));
};
await dispatch(0);
};
const update = { update_id: 701 };
const replay = await runWithTelegramSpooledReplayUpdate(update, async () => {
await runMiddlewareChain({ update }, async () => {
const participant = createTelegramSpooledReplayDeferredParticipant("test:deferred");
if (!participant) {
throw new Error("expected spooled replay participant");
}
});
});
const deferredWork = replay.deferredWork;
expect(deferredWork).toBeDefined();
if (!deferredWork) {
throw new Error("Expected deferred spooled work");
}
await flushTelegramTestMicrotasks();
expect(onUpdateId).not.toHaveBeenCalled();
deferredWork.settle({
kind: "failed-retryable",
error: new Error("deferred dispatch failed"),
});
await flushTelegramTestMicrotasks();
expect(onUpdateId).not.toHaveBeenCalled();
let retried = false;
await runWithTelegramSpooledReplayUpdate(update, async () => {
await runMiddlewareChain({ update }, async () => {
retried = true;
});
});
await flushTelegramTestMicrotasks();
expect(retried).toBe(true);
expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([701]);
});
it("skips replayed update ids even when the semantic update key differs", async () => {
sequentializeSpy.mockImplementationOnce(
() => async (_ctx: unknown, next: () => Promise<void>) => {

View File

@@ -19,7 +19,6 @@ export type TelegramGetChat = (chatId: number | string) => Promise<TelegramChatD
*/
export type TelegramContext = {
message: Message;
update?: unknown;
me?: UserFromGetMe;
getFile: TelegramGetFile;
};

View File

@@ -378,20 +378,11 @@ describe("createTelegramDraftStream", () => {
expect(stream.sendMayHaveLanded?.()).toBe(expected);
}
it("retries pre-connect first preview send failures instead of stopping", async () => {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(
it("clears sendMayHaveLanded on pre-connect first preview send failures", async () => {
await expectSendMayHaveLandedStateAfterFirstFailure(
Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
false,
);
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expect(stream.sendMayHaveLanded?.()).toBe(false);
expect(stream.messageId()).toBe(17);
});
it("clears sendMayHaveLanded on Telegram 4xx client rejections", async () => {
@@ -401,103 +392,6 @@ describe("createTelegramDraftStream", () => {
);
});
it("treats message-is-not-modified edits as delivered", async () => {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValueOnce(
Object.assign(
new Error("Call to 'editMessageText' failed! (400: Bad Request: message is not modified)"),
{ error_code: 400 },
),
);
const warn = vi.fn();
const stream = createDraftStream(api, { warn });
stream.update("Hello");
await stream.flush();
stream.update("Hello again");
await stream.flush();
stream.update("Hello more");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
expect(warn).not.toHaveBeenCalled();
});
it("retries the preview edit after a transient network failure", async () => {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValueOnce(
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
);
const warn = vi.fn();
const stream = createDraftStream(api, { warn });
stream.update("Hello");
await stream.flush();
stream.update("Hello again");
await stream.flush();
expect(warn).toHaveBeenCalledWith(
"telegram stream preview edit failed (retrying): read ECONNRESET",
);
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello again");
expect(stream.lastDeliveredText?.()).toBe("Hello again");
});
it("suspends preview edits for retry_after during flood control", async () => {
vi.useFakeTimers();
try {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValueOnce(
Object.assign(
new Error("Call to 'editMessageText' failed! (429: Too Many Requests: retry after 1)"),
{ error_code: 429, parameters: { retry_after: 1 } },
),
);
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
stream.update("Hello again");
await stream.flush();
stream.update("Hello more");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1100);
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
} finally {
vi.useRealTimers();
}
});
it("stops the preview after repeated retryable edit failures", async () => {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValue(
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
);
const warn = vi.fn();
const stream = createDraftStream(api, { warn });
stream.update("Hello");
await stream.flush();
stream.update("Hello again");
await stream.flush();
await stream.flush();
await stream.flush();
await stream.flush();
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(4);
expect(warn).toHaveBeenCalledWith("telegram stream preview failed: read ECONNRESET");
});
it("supports rendered previews with parse_mode", async () => {
const api = createMockDraftApi();
const stream = createTelegramDraftStream({

View File

@@ -6,25 +6,11 @@ import {
} from "openclaw/plugin-sdk/channel-outbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
import {
isRecoverableTelegramNetworkError,
isSafeToRetrySendError,
isTelegramClientRejection,
isTelegramMessageNotModifiedError,
isTelegramRateLimitError,
readTelegramRetryAfterMs,
} from "./network-errors.js";
import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js";
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
const TELEGRAM_STREAM_MAX_CHARS = 4096;
const DEFAULT_THROTTLE_MS = 1000;
// Retryable preview failures keep the latest text pending for the next throttle
// tick; cap consecutive misses so a persistent outage stops the preview instead
// of warn-spamming for the rest of the run.
const MAX_CONSECUTIVE_PREVIEW_FAILURES = 3;
// Flood waits beyond this freeze the preview longer than it is useful; clamp so
// a large retry_after cannot park the suspension past the run's lifetime.
const MAX_PREVIEW_FLOOD_SUSPEND_MS = 60_000;
export type TelegramDraftStream = {
update: (text: string) => void;
@@ -123,8 +109,6 @@ export function createTelegramDraftStream(params: {
const streamState = { stopped: false, final: false };
let messageSendAttempted = false;
let suspendedUntilMs = 0;
let consecutivePreviewFailures = 0;
let streamMessageId: number | undefined;
let streamVisibleSinceMs: number | undefined;
let lastSentText = "";
@@ -214,12 +198,6 @@ export function createTelegramDraftStream(params: {
if (streamState.stopped && !streamState.final) {
return false;
}
// Flood-control suspension: returning false keeps the newest text pending,
// so the first tick after retry_after delivers it. Final flushes still try
// so the last text has a chance to land.
if (!streamState.final && Date.now() < suspendedUntilMs) {
return false;
}
const trimmed = text.trimEnd();
if (!trimmed) {
return false;
@@ -284,8 +262,6 @@ export function createTelegramDraftStream(params: {
}
}
const previousSentText = lastSentText;
const previousSentParseMode = lastSentParseMode;
lastSentText = renderedText;
lastSentParseMode = renderedParseMode;
try {
@@ -297,40 +273,9 @@ export function createTelegramDraftStream(params: {
if (sent) {
previewRevision += 1;
lastDeliveredText = trimmed;
consecutivePreviewFailures = 0;
suspendedUntilMs = 0;
}
return sent;
} catch (err) {
const isEdit = typeof streamMessageId === "number";
if (isEdit && isTelegramMessageNotModifiedError(err)) {
// Telegram already shows exactly this text; count the edit as delivered.
consecutivePreviewFailures = 0;
lastDeliveredText = trimmed;
return true;
}
// Roll back the dedupe snapshot so the retried tick is not skipped as a no-op.
lastSentText = previousSentText;
lastSentParseMode = previousSentParseMode;
// Flood control is always retryable: Telegram rejected the call outright.
// Beyond that, edits retry on any transient network error (re-editing the
// same content is idempotent) while an unsent first preview retries only
// on provably pre-connect failures — anything ambiguous could duplicate
// the preview message.
const retryable =
isTelegramRateLimitError(err) ||
(isEdit ? isRecoverableTelegramNetworkError(err) : isSafeToRetrySendError(err));
consecutivePreviewFailures += 1;
if (retryable && consecutivePreviewFailures <= MAX_CONSECUTIVE_PREVIEW_FAILURES) {
const retryAfterMs = readTelegramRetryAfterMs(err);
if (retryAfterMs !== undefined) {
suspendedUntilMs = Date.now() + Math.min(retryAfterMs, MAX_PREVIEW_FLOOD_SUSPEND_MS);
}
params.warn?.(
`telegram stream preview ${isEdit ? "edit" : "send"} failed (retrying): ${formatErrorMessage(err)}`,
);
return false;
}
streamState.stopped = true;
params.warn?.(`telegram stream preview failed: ${formatErrorMessage(err)}`);
return false;

View File

@@ -11,11 +11,8 @@ import {
claimTelegramMessageDispatchReplay,
commitTelegramMessageDispatchReplay,
createTelegramMessageDispatchReplayGuard,
forgetTelegramMessageDispatchReplay,
releaseTelegramMessageDispatchReplay,
TELEGRAM_MESSAGE_DISPATCH_DEDUPE_NAMESPACE,
TelegramMessageDispatchReplayForgetError,
type TelegramMessageDispatchReplayGuard,
} from "./message-dispatch-dedupe.js";
const tempDirs: string[] = [];
@@ -207,27 +204,4 @@ describe("Telegram message dispatch replay guard", () => {
key: first.key,
});
});
it("fails rollback when a committed dispatch key cannot be forgotten", async () => {
const guard = {
claim: async () => ({ kind: "claimed" }),
commit: async () => true,
forget: async (key: string) => key !== "failed-key",
hasRecent: async () => false,
warmup: async () => 0,
clearMemory: () => {},
memorySize: () => 0,
release: () => {},
} satisfies TelegramMessageDispatchReplayGuard;
await expect(
forgetTelegramMessageDispatchReplay({
guard,
keys: ["ok-key", "failed-key", "failed-key"],
}),
).rejects.toMatchObject({
name: TelegramMessageDispatchReplayForgetError.name,
failures: [{ key: "failed-key" }],
});
});
});

View File

@@ -11,40 +11,13 @@ export const TELEGRAM_MESSAGE_DISPATCH_DEDUPE_STATE_PLUGIN_ID = "telegram-messag
export const TELEGRAM_MESSAGE_DISPATCH_DEDUPE_MEMORY_MAX_ENTRIES = 50_000;
export const TELEGRAM_MESSAGE_DISPATCH_DEDUPE_STATE_MAX_ENTRIES = 50_000;
export type TelegramMessageDispatchReplayGuard = ClaimableDedupe &
Required<Pick<ClaimableDedupe, "forget">>;
export type TelegramMessageDispatchReplayGuard = ClaimableDedupe;
export type TelegramMessageDispatchClaim =
| { kind: "claimed"; key: string }
| { kind: "duplicate" }
| { kind: "invalid" };
export type TelegramMessageDispatchReplayForgetFailure = {
key: string;
error?: unknown;
};
export class TelegramMessageDispatchReplayForgetError extends Error {
readonly failures: TelegramMessageDispatchReplayForgetFailure[];
override readonly cause: unknown;
constructor(failures: readonly TelegramMessageDispatchReplayForgetFailure[]) {
const count = failures.length;
super(`telegram message dispatch dedupe rollback failed for ${count} key(s)`, {
cause: failures.find((failure) => failure.error !== undefined)?.error,
});
this.name = "TelegramMessageDispatchReplayForgetError";
this.failures = [...failures];
this.cause = failures.find((failure) => failure.error !== undefined)?.error;
}
}
export function isTelegramMessageDispatchReplayForgetError(
error: unknown,
): error is TelegramMessageDispatchReplayForgetError {
return error instanceof TelegramMessageDispatchReplayForgetError;
}
function sanitizeFileSegment(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
@@ -158,30 +131,6 @@ export async function commitTelegramMessageDispatchReplay(params: {
);
}
export async function forgetTelegramMessageDispatchReplay(params: {
guard: TelegramMessageDispatchReplayGuard;
keys?: readonly string[];
}): Promise<void> {
const keys = normalizeReplayKeys(params.keys);
const failures = (
await Promise.all(
keys.map(async (key): Promise<TelegramMessageDispatchReplayForgetFailure | null> => {
try {
const forgotten = await params.guard.forget(key, {
namespace: TELEGRAM_MESSAGE_DISPATCH_DEDUPE_NAMESPACE,
});
return forgotten ? null : { key };
} catch (error) {
return { key, error };
}
}),
)
).filter((failure): failure is TelegramMessageDispatchReplayForgetFailure => Boolean(failure));
if (failures.length > 0) {
throw new TelegramMessageDispatchReplayForgetError(failures);
}
}
export function releaseTelegramMessageDispatchReplay(params: {
guard: TelegramMessageDispatchReplayGuard;
keys?: readonly string[];

View File

@@ -225,8 +225,7 @@ function hasTelegramErrorCode(err: unknown, matches: (code: number) => boolean):
return false;
}
/** Reads Telegram's flood-control retry_after hint (in ms) from any error nesting shape. */
export function readTelegramRetryAfterMs(err: unknown): number | undefined {
function hasTelegramRetryAfter(err: unknown): boolean {
for (const candidate of collectTelegramErrorCandidates(err)) {
if (!candidate || typeof candidate !== "object") {
continue;
@@ -251,10 +250,10 @@ export function readTelegramRetryAfterMs(err: unknown): number | undefined {
?.retry_after
: undefined;
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
return retryAfter * 1000;
return true;
}
}
return undefined;
return false;
}
/** Returns true for HTTP 5xx server errors (error may have been processed). */
@@ -265,32 +264,10 @@ export function isTelegramServerError(err: unknown): boolean {
export function isTelegramRateLimitError(err: unknown): boolean {
return (
hasTelegramErrorCode(err, (code) => code === 429) ||
(readTelegramRetryAfterMs(err) !== undefined &&
/(?:^|\b)429\b|too many requests/i.test(formatErrorMessage(err)))
(hasTelegramRetryAfter(err) && /(?:^|\b)429\b|too many requests/i.test(formatErrorMessage(err)))
);
}
const MESSAGE_NOT_MODIFIED_RE =
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
const MESSAGE_HAS_NO_TEXT_RE = /400:\s*Bad Request:\s*there is no text in the message to edit/i;
const EDIT_TARGET_MISSING_RE =
/400:\s*Bad Request:\s*message to edit not found|400:\s*Bad Request:\s*message can't be edited|MESSAGE_ID_INVALID/i;
/** True when Telegram rejected an edit because the content is unchanged; the message already shows the requested text. */
export function isTelegramMessageNotModifiedError(err: unknown): boolean {
return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err));
}
/** True when the edit target has no text body (e.g. media message needing a caption edit). */
export function isTelegramMessageHasNoTextError(err: unknown): boolean {
return MESSAGE_HAS_NO_TEXT_RE.test(formatErrorMessage(err));
}
/** True when the edit target is gone or locked (deleted message, invalid id); retrying the same edit cannot succeed. */
export function isTelegramEditTargetMissingError(err: unknown): boolean {
return EDIT_TARGET_MISSING_RE.test(formatErrorMessage(err));
}
/** Returns true for HTTP 4xx client errors (Telegram explicitly rejected, not applied). */
export function isTelegramClientRejection(err: unknown): boolean {
return hasTelegramErrorCode(err, (code) => code >= 400 && code < 500);

View File

@@ -73,12 +73,6 @@ let listTelegramSpooledUpdateClaims: typeof import("./telegram-ingress-spool.js"
let listTelegramSpooledUpdates: typeof import("./telegram-ingress-spool.js").listTelegramSpooledUpdates;
let recoverStaleTelegramSpooledUpdateClaims: typeof import("./telegram-ingress-spool.js").recoverStaleTelegramSpooledUpdateClaims;
let writeTelegramSpooledUpdate: typeof import("./telegram-ingress-spool.js").writeTelegramSpooledUpdate;
let createTelegramSpooledReplayDeferredParticipant: typeof import("./bot-processing-outcome.js").createTelegramSpooledReplayDeferredParticipant;
let TelegramMessageDispatchReplayForgetError: typeof import("./message-dispatch-dedupe.js").TelegramMessageDispatchReplayForgetError;
type TelegramMessageProcessingResult =
import("./bot-processing-outcome.js").TelegramMessageProcessingResult;
type TelegramSpooledReplayDeferredParticipant =
import("./bot-processing-outcome.js").TelegramSpooledReplayDeferredParticipant;
let beginTelegramReplyFence: typeof import("./telegram-reply-fence.js").beginTelegramReplyFence;
let buildTelegramReplyFenceLaneKey: typeof import("./telegram-reply-fence.js").buildTelegramReplyFenceLaneKey;
let endTelegramReplyFence: typeof import("./telegram-reply-fence.js").endTelegramReplyFence;
@@ -110,7 +104,6 @@ type WorkerPollSuccessListener = (message: {
type WorkerPollErrorListener = (message: {
type: "poll-error";
message: string;
errorCode?: number;
finishedAt: number;
}) => void;
type WorkerMessageListener = (message: TelegramIngressWorkerMessage) => void;
@@ -618,9 +611,6 @@ describe("TelegramPollingSession", () => {
recoverStaleTelegramSpooledUpdateClaims,
writeTelegramSpooledUpdate,
} = await import("./telegram-ingress-spool.js"));
({ createTelegramSpooledReplayDeferredParticipant } =
await import("./bot-processing-outcome.js"));
({ TelegramMessageDispatchReplayForgetError } = await import("./message-dispatch-dedupe.js"));
({
beginTelegramReplyFence,
buildTelegramReplyFenceLaneKey,
@@ -1351,187 +1341,6 @@ describe("TelegramPollingSession", () => {
});
});
it("holds buffered spooled claims until deferred processing settles without blocking same-lane buffering", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const participants: TelegramSpooledReplayDeferredParticipant[] = [];
const events: string[] = [];
await writeSpooledTestUpdates(tempDir, [
topicUpdate(42, 10, "first buffered topic 10 turn"),
topicUpdate(43, 10, "second buffered topic 10 turn"),
]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
drainIntervalMs: 10,
handleUpdate: async (update) => {
events.push(`topic10:${update.update_id}`);
const participant = createTelegramSpooledReplayDeferredParticipant(
`test-buffer:${update.update_id}`,
);
if (!participant) {
throw new Error("expected spooled replay participant");
}
participants.push(participant);
},
});
await vi.waitFor(() => expect(events).toEqual(["topic10:42", "topic10:43"]));
await vi.waitFor(async () =>
expect(
(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).map(
(claim) => claim.updateId,
),
).toEqual([42, 43]),
);
expect(await pendingUpdateIds(tempDir, "all")).toEqual([]);
const completed: TelegramMessageProcessingResult = { kind: "completed" };
for (const participant of participants) {
participant.settle(completed);
}
await vi.waitFor(async () =>
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]),
);
expect(await pendingUpdateIds(tempDir, "all")).toEqual([]);
abort.abort();
stopWorker();
await runPromise;
});
});
it("releases buffered spooled claims for retry when deferred processing fails", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const log = vi.fn();
const participants: TelegramSpooledReplayDeferredParticipant[] = [];
await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "buffered failure")]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
log,
drainIntervalMs: 10,
handleUpdate: async (update) => {
const participant = createTelegramSpooledReplayDeferredParticipant(
`test-buffer:${update.update_id}`,
);
if (!participant) {
throw new Error("expected spooled replay participant");
}
participants.push(participant);
},
});
await vi.waitFor(() => expect(participants).toHaveLength(1));
await vi.waitFor(async () =>
expect(
(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).map(
(claim) => claim.updateId,
),
).toEqual([42]),
);
abort.abort();
participants[0]?.settle({
kind: "failed-retryable",
error: new Error("buffered dispatch failed"),
});
await vi.waitFor(async () => expect(await pendingUpdateIds(tempDir, "all")).toEqual([42]));
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]);
expectLogIncludes(log, "spooled update 42 failed; keeping for retry");
stopWorker();
await runPromise;
});
});
it("dead-letters buffered spooled claims when dispatch dedupe rollback fails", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const log = vi.fn();
const participants: TelegramSpooledReplayDeferredParticipant[] = [];
const events: string[] = [];
let attempts = 0;
await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "buffered rollback failure")]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
log,
drainIntervalMs: 10,
handleUpdate: async (update) => {
attempts += 1;
if (attempts === 1) {
events.push(`dispatch:${update.update_id}`);
const participant = createTelegramSpooledReplayDeferredParticipant(
`test-buffer:${update.update_id}`,
);
if (!participant) {
throw new Error("expected spooled replay participant");
}
participants.push(participant);
return;
}
events.push(`duplicate-skip:${update.update_id}`);
},
});
await vi.waitFor(() => expect(participants).toHaveLength(1));
participants[0]?.settle({
kind: "failed-retryable",
error: new TelegramMessageDispatchReplayForgetError([{ key: "committed-dispatch-key" }]),
});
await vi.waitFor(async () => expect(await failedUpdateIds(tempDir)).toEqual([42]));
expect(events).toEqual(["dispatch:42"]);
expect(await pendingUpdateIds(tempDir, "all")).toEqual([]);
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]);
expectLogIncludes(log, "non-retryable dispatch-dedupe-rollback-failed");
expectLogExcludes(log, "spooled update 42 failed; keeping for retry");
abort.abort();
stopWorker();
await runPromise;
});
});
it("fails buffered spooled claims instead of requeueing when deferred processing times out", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const log = vi.fn();
const participants: TelegramSpooledReplayDeferredParticipant[] = [];
await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "buffered timeout")]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
log,
drainIntervalMs: 10,
spooledUpdateHandlerTimeoutMs: 20,
handleUpdate: async (update) => {
const participant = createTelegramSpooledReplayDeferredParticipant(
`test-buffer:${update.update_id}`,
);
if (!participant) {
throw new Error("expected spooled replay participant");
}
participants.push(participant);
},
});
await vi.waitFor(() => expect(participants).toHaveLength(1));
await vi.waitFor(async () => expect(await failedUpdateIds(tempDir)).toEqual([42]));
expect(await pendingUpdateIds(tempDir, "all")).toEqual([]);
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]);
expectLogIncludes(log, "buffered processing timed out behind update 42");
expectLogExcludes(log, "spooled update 42 failed; keeping for retry");
abort.abort();
stopWorker();
await runPromise;
});
});
it("dead-letters missing harness failures so later same-lane updates can drain", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
@@ -2547,89 +2356,6 @@ describe("TelegramPollingSession", () => {
}
});
it("restarts isolated ingress on a getUpdates conflict instead of crashing the account", async () => {
const abort = new AbortController();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
const log = vi.fn();
const setStatus = vi.fn();
// 409 conflicts are not "recoverable network errors"; the conflict branch
// must restart the cycle before that classifier is consulted.
isRecoverableTelegramNetworkErrorMock.mockReturnValue(false);
const deleteWebhook = vi.fn(async () => true);
createTelegramBotMock.mockImplementation(() => ({
api: {
deleteWebhook,
config: { use: vi.fn() },
},
init: vi.fn(async () => undefined),
handleUpdate: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
}));
const transport1 = makeTelegramTransport();
const transport2 = makeTelegramTransport();
const createTelegramTransport = vi
.fn<() => ReturnType<typeof makeTelegramTransport>>()
.mockReturnValueOnce(transport2);
let workerCycle = 0;
let listener: WorkerPollErrorListener | undefined;
const createWorker = vi.fn(() => ({
onMessage: vi.fn((next: WorkerPollErrorListener) => {
listener = next;
return () => undefined;
}),
stop: vi.fn(async () => undefined),
task: vi.fn(async () => {
workerCycle += 1;
if (workerCycle === 1) {
listener?.({
type: "poll-error",
message: "Conflict: terminated by other getUpdates request",
errorCode: 409,
finishedAt: Date.now(),
});
throw new Error("Telegram ingress worker exited with code 1");
}
abort.abort();
}),
}));
try {
const session = createPollingSession({
abortSignal: abort.signal,
log,
setStatus,
telegramTransport: transport1,
createTelegramTransport,
isolatedIngress: {
enabled: true,
spoolDir: tempDir,
createWorker,
drainIntervalMs: 100,
},
});
await session.runUntilAbort();
expect(createWorker).toHaveBeenCalledTimes(2);
// The conflict resets webhook cleanup so the next cycle re-runs deleteWebhook.
expect(deleteWebhook).toHaveBeenCalledTimes(2);
// The conflict marks the transport dirty so the next cycle gets a fresh socket.
expect(createTelegramTransport).toHaveBeenCalledTimes(1);
expectLogIncludes(log, "Another OpenClaw gateway, script, or Telegram poller");
expect(
statusPatches(setStatus).some(
(patch) =>
patch.connected === false &&
String(patch.lastError).includes("Another OpenClaw gateway"),
),
).toBe(true);
} finally {
abort.abort();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("keeps active spooled lanes blocked across account restarts", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const firstAbort = new AbortController();
@@ -3807,11 +3533,9 @@ describe("TelegramPollingSession", () => {
const watchdogHarness = installPollingStallWatchdogHarness();
const log = vi.fn();
const setStatus = vi.fn();
const session = createPollingSession({
abortSignal: abort.signal,
log,
setStatus,
});
try {
@@ -3843,14 +3567,6 @@ describe("TelegramPollingSession", () => {
abort.abort();
resolveFirstTask();
await runPromise;
// The stall must reach channel status, not just the gateway log.
expect(
statusPatches(setStatus).some(
(patch) =>
patch.connected === false && String(patch.lastError).includes("Polling stall detected"),
),
).toBe(true);
} finally {
watchdogHarness.restore();
}
@@ -3898,7 +3614,6 @@ describe("TelegramPollingSession", () => {
it("logs an actionable duplicate-poller hint for getUpdates conflicts", async () => {
const abort = new AbortController();
const log = vi.fn();
const setStatus = vi.fn();
const conflictError = Object.assign(
new Error("Conflict: terminated by other getUpdates request"),
{
@@ -3913,19 +3628,11 @@ describe("TelegramPollingSession", () => {
const session = createPollingSession({
abortSignal: abort.signal,
log,
setStatus,
});
await session.runUntilAbort();
expectLogIncludes(log, "Another OpenClaw gateway, script, or Telegram poller");
// The hint must reach channel status, not just the gateway log.
expect(
statusPatches(setStatus).some(
(patch) =>
patch.connected === false && String(patch.lastError).includes("Another OpenClaw gateway"),
),
).toBe(true);
});
it("logs polling cycle start after a transport rebuild", async () => {

View File

@@ -19,14 +19,8 @@ import {
} from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
runWithTelegramSpooledReplayUpdate,
type TelegramMessageProcessingResult,
type TelegramSpooledReplayDeferredParticipant,
} from "./bot-processing-outcome.js";
import { createTelegramBot } from "./bot.js";
import type { TelegramTransport } from "./fetch.js";
import { isTelegramMessageDispatchReplayForgetError } from "./message-dispatch-dedupe.js";
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
import { TelegramPollingLivenessTracker } from "./polling-liveness.js";
import { createTelegramPollingStatusPublisher } from "./polling-status.js";
@@ -116,11 +110,6 @@ function resolveTelegramRestartDelayMs(
return { delayMs, stopTimeoutSuffix };
}
// Surfaced in logs and channel status when getUpdates returns 409; the only
// user-fixable causes are a second poller on the same token or a stale webhook.
const TELEGRAM_GET_UPDATES_CONFLICT_HINT =
" Another OpenClaw gateway, script, or Telegram poller may be using this bot token; stop the duplicate poller or switch this account to webhook mode.";
const DEFAULT_POLL_STALL_THRESHOLD_MS = 120_000;
const MIN_POLL_STALL_THRESHOLD_MS = 30_000;
const MAX_POLL_STALL_THRESHOLD_MS = 600_000;
@@ -142,7 +131,7 @@ function normalizeTelegramAccountId(accountId?: string | null): string {
}
type NonRetryableSpooledUpdateFailure = {
reason: "missing-agent-harness" | "dispatch-dedupe-rollback-failed";
reason: "missing-agent-harness";
message: string;
};
@@ -154,11 +143,6 @@ function resolveNonRetryableSpooledUpdateFailure(
current.error,
])) {
const message = formatErrorMessage(candidate);
if (isTelegramMessageDispatchReplayForgetError(candidate)) {
// A committed dispatch key that cannot be rolled back makes retry unsafe:
// the next replay can be duplicate-suppressed and then deleted.
return { reason: "dispatch-dedupe-rollback-failed", message };
}
if (
readErrorName(candidate) === MISSING_AGENT_HARNESS_ERROR_NAME ||
MISSING_AGENT_HARNESS_MESSAGE_RE.test(message)
@@ -274,22 +258,6 @@ type SpooledUpdateHandlerState = {
timeoutMessage?: string;
};
type DeferredSpooledUpdateClaimState = {
claimKey: string;
laneKey: string;
task: Promise<void>;
timer?: ReturnType<typeof setTimeout>;
timedOutMessage?: string;
update: ClaimedTelegramSpooledUpdate;
updateId: number;
};
const deferredSpooledUpdateClaimsByKey = new Map<string, DeferredSpooledUpdateClaimState>();
function buildDeferredSpooledUpdateClaimKey(update: ClaimedTelegramSpooledUpdate): string {
return `${update.pendingPath}:${update.claim?.claimToken ?? update.claim?.processId ?? "claimed"}`;
}
type SpooledUpdateDrainResult = {
blockedByLane: Set<string>;
started: number;
@@ -331,7 +299,6 @@ export class TelegramPollingSession {
#activeRunner: ReturnType<typeof run> | undefined;
#activeFetchAbort: AbortController | undefined;
#spooledUpdateHandlerKeys = new Set<string>();
#deferredSpooledUpdateClaimKeys = new Set<string>();
#transportState: TelegramPollingTransportState;
#status: ReturnType<typeof createTelegramPollingStatusPublisher>;
#stallThresholdMs: number;
@@ -555,12 +522,10 @@ export class TelegramPollingSession {
bot: TelegramBot;
update: ClaimedTelegramSpooledUpdate;
}): Promise<boolean> {
let replay: { deferredWork?: TelegramSpooledReplayDeferredParticipant };
try {
const update = params.update.update as Parameters<typeof params.bot.handleUpdate>[0];
replay = await runWithTelegramSpooledReplayUpdate(update, async () => {
await params.bot.handleUpdate(update);
});
await params.bot.handleUpdate(
params.update.update as Parameters<typeof params.bot.handleUpdate>[0],
);
} catch (err) {
await this.#releaseFailedSpooledUpdate({
err,
@@ -568,14 +533,6 @@ export class TelegramPollingSession {
});
return false;
}
if (replay.deferredWork) {
this.#registerDeferredSpooledUpdate({
deferredWork: replay.deferredWork,
laneKey: this.#spooledUpdateLaneKey(params.update),
update: params.update,
});
return true;
}
try {
await deleteTelegramSpooledUpdate(params.update);
return true;
@@ -587,115 +544,6 @@ export class TelegramPollingSession {
}
}
#registerDeferredSpooledUpdate(params: {
deferredWork: TelegramSpooledReplayDeferredParticipant;
laneKey: string;
update: ClaimedTelegramSpooledUpdate;
}): void {
const claimKey = buildDeferredSpooledUpdateClaimKey(params.update);
const previous = deferredSpooledUpdateClaimsByKey.get(claimKey);
if (previous) {
if (previous.timer) {
clearTimeout(previous.timer);
}
deferredSpooledUpdateClaimsByKey.delete(claimKey);
}
let settled = false;
const finish = async (result: TelegramMessageProcessingResult): Promise<void> => {
if (settled) {
return;
}
settled = true;
if (state.timer) {
clearTimeout(state.timer);
}
if (deferredSpooledUpdateClaimsByKey.get(claimKey) === state) {
deferredSpooledUpdateClaimsByKey.delete(claimKey);
}
this.#deferredSpooledUpdateClaimKeys.delete(claimKey);
if (result.kind === "failed-retryable") {
if (state.timedOutMessage) {
await this.#failTimedOutDeferredSpooledUpdate(state);
return;
}
await this.#releaseFailedSpooledUpdate({
err: result.error,
update: params.update,
});
return;
}
try {
await deleteTelegramSpooledUpdate(params.update);
} catch (err) {
this.opts.log(
`[telegram][diag] spooled update ${params.update.updateId} completed after buffered processing but processing marker cleanup failed: ${formatErrorMessage(err)}`,
);
}
};
const state: DeferredSpooledUpdateClaimState = {
claimKey,
laneKey: params.laneKey,
task: params.deferredWork.task.then(finish, async (err: unknown) => {
await finish({ kind: "failed-retryable", error: err });
}),
update: params.update,
updateId: params.update.updateId,
};
state.timer = setTimeout(() => {
const age = formatDurationPrecise(this.#spooledUpdateHandlerTimeoutMs);
state.timedOutMessage = `Telegram isolated polling spool buffered processing timed out behind update ${params.update.updateId} on lane ${params.laneKey} after ${age}; marking the update failed, aborting active reply work, and keeping the claim out of retry while the buffered task settles.`;
params.deferredWork.settle({
kind: "failed-retryable",
error: new Error(state.timedOutMessage),
});
}, this.#spooledUpdateHandlerTimeoutMs);
state.timer.unref?.();
deferredSpooledUpdateClaimsByKey.set(claimKey, state);
this.#deferredSpooledUpdateClaimKeys.add(claimKey);
}
#isDeferredSpooledUpdateClaim(update: ClaimedTelegramSpooledUpdate): boolean {
return deferredSpooledUpdateClaimsByKey.has(buildDeferredSpooledUpdateClaimKey(update));
}
async #failTimedOutDeferredSpooledUpdate(state: DeferredSpooledUpdateClaimState): Promise<void> {
const message =
state.timedOutMessage ??
`Telegram isolated polling spool buffered processing timed out behind update ${state.updateId} on lane ${state.laneKey}; marking the update failed.`;
try {
const failed = await failTelegramSpooledUpdateClaim({
update: state.update,
reason: "handler-timeout",
message,
});
if (!failed) {
this.opts.log(
`[telegram][diag] timed out buffered spooled update ${state.updateId} no longer had a processing marker to fail.`,
);
this.#status.notePollingError(message);
return;
}
} catch (err) {
this.opts.log(
`[telegram][diag] timed out buffered spooled update ${state.updateId} could not be marked failed: ${formatErrorMessage(err)}`,
);
this.#status.notePollingError(message);
return;
}
const scopedReplyFenceLaneKey = buildTelegramReplyFenceLaneKey({
accountId: this.opts.accountId,
sequentialKey: state.laneKey,
});
const abortedReplyWork = supersedeTelegramReplyFenceLane(scopedReplyFenceLaneKey);
if (!abortedReplyWork) {
this.opts.log(
`[telegram][diag] timed out buffered spooled update ${state.updateId} had no active reply fence on lane ${state.laneKey}.`,
);
}
this.opts.log(`[telegram] ${message}`);
this.#status.notePollingError(message);
}
async #releaseFailedSpooledUpdate(params: {
err: unknown;
update: ClaimedTelegramSpooledUpdate;
@@ -738,14 +586,11 @@ export class TelegramPollingSession {
}
async #waitForSpooledUpdateHandlers(): Promise<void> {
await Promise.allSettled([
...[...this.#spooledUpdateHandlerKeys]
await Promise.allSettled(
[...this.#spooledUpdateHandlerKeys]
.map((handlerKey) => activeSpooledUpdateHandlersByLane.get(handlerKey)?.task)
.filter((task): task is Promise<boolean> => Boolean(task)),
...[...this.#deferredSpooledUpdateClaimKeys]
.map((claimKey) => deferredSpooledUpdateClaimsByKey.get(claimKey)?.task)
.filter((task): task is Promise<void> => Boolean(task)),
]);
);
}
#spooledUpdateLaneKey(update: TelegramSpooledUpdate): string {
@@ -774,7 +619,6 @@ export class TelegramPollingSession {
spoolDir: params.spoolDir,
staleMs: 0,
shouldRecover: (claim) =>
!this.#isDeferredSpooledUpdateClaim(claim) &&
!activeLaneKeys.has(this.#spooledUpdateLaneKey(claim)) &&
!isTelegramSpooledUpdateClaimOwnedByOtherLiveProcess(claim),
});
@@ -783,9 +627,7 @@ export class TelegramPollingSession {
await listTelegramSpooledUpdateClaims({
spoolDir: params.spoolDir,
})
)
.filter((claim) => !this.#isDeferredSpooledUpdateClaim(claim))
.map((claim) => this.#spooledUpdateLaneKey(claim)),
).map((claim) => this.#spooledUpdateLaneKey(claim)),
);
const updates = await listTelegramSpooledUpdates({
spoolDir: params.spoolDir,
@@ -971,12 +813,10 @@ export class TelegramPollingSession {
offset: number | null;
outcome: string;
error?: string;
errorCode: number | null;
} = {
startedAt: null,
offset: null,
outcome: "not-started",
errorCode: null,
};
const liveness = new TelegramPollingLivenessTracker();
let consecutiveDrainFailures = 0;
@@ -1013,7 +853,6 @@ export class TelegramPollingSession {
pollState.offset = message.offset;
pollState.outcome = "started";
delete pollState.error;
pollState.errorCode = null;
return;
}
if (message.type === "poll-success") {
@@ -1032,7 +871,6 @@ export class TelegramPollingSession {
liveness.noteGetUpdatesFinished();
pollState.outcome = "error";
pollState.error = message.message;
pollState.errorCode = message.errorCode ?? null;
return;
}
if (message.type === "update") {
@@ -1164,24 +1002,14 @@ export class TelegramPollingSession {
if (this.opts.abortSignal?.aborted) {
return "exit";
}
// The worker only issues getUpdates, so a 409 is always a duplicate
// poller (or stale webhook) conflict. Mirror the classic polling
// cycle: re-clear the webhook, rotate the transport (#69787), and
// restart with backoff instead of crashing the whole account.
const isConflict = pollState.errorCode === 409;
if (isConflict) {
this.#webhookCleared = false;
this.#transportState.markDirty();
} else if (
if (
pollState.error &&
!isRecoverableTelegramNetworkError(new Error(pollState.error), { context: "polling" })
) {
this.#status.notePollingError(pollState.error);
throw new Error(pollState.error, { cause: err });
}
const message = isConflict
? `Telegram getUpdates conflict: ${pollState.error}.${TELEGRAM_GET_UPDATES_CONFLICT_HINT}`
: formatErrorMessage(err);
const message = formatErrorMessage(err);
this.opts.log(`[telegram][diag] isolated polling ingress failed: ${message}`);
this.#status.notePollingError(message);
clearForceCycleTimer();
@@ -1334,7 +1162,6 @@ export class TelegramPollingSession {
this.#transportState.markDirty();
stalledRestart = true;
this.opts.log(`[telegram] ${stall.message}`);
this.#status.notePollingError(stall.message);
requestStopForRestart();
}
}, POLL_WATCHDOG_INTERVAL_MS);
@@ -1383,16 +1210,12 @@ export class TelegramPollingSession {
}
const reason = isConflict ? "getUpdates conflict" : "network error";
const errMsg = formatErrorMessage(err);
const conflictHint = isConflict ? TELEGRAM_GET_UPDATES_CONFLICT_HINT : "";
const conflictHint = isConflict
? " Another OpenClaw gateway, script, or Telegram poller may be using this bot token; stop the duplicate poller or switch this account to webhook mode."
: "";
this.opts.log(
`[telegram][diag] polling cycle error reason=${reason} ${liveness.formatDiagnosticFields("lastGetUpdatesError")} err=${errMsg}${conflictHint}`,
);
// Conflicts carry a user-fixable diagnosis, so surface them in channel
// status. Recoverable network blips stay log-only; the stall watchdog
// owns status for extended outages (see detectStall above).
if (isConflict) {
this.#status.notePollingError(`Telegram ${reason}: ${errMsg}.${conflictHint}`);
}
clearForceCycleTimer();
const shouldRestart = await this.#waitBeforeRestart(
(delay) => `Telegram ${reason}: ${errMsg};${conflictHint} retrying in ${delay}.`,

View File

@@ -29,8 +29,6 @@ import { buildInlineKeyboard } from "./inline-keyboard.js";
import {
isRecoverableTelegramNetworkError,
isSafeToRetrySendError,
isTelegramMessageHasNoTextError,
isTelegramMessageNotModifiedError,
isTelegramRateLimitError,
isTelegramServerError,
} from "./network-errors.js";
@@ -232,6 +230,9 @@ function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): vo
}
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const MESSAGE_NOT_MODIFIED_RE =
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
const MESSAGE_HAS_NO_TEXT_RE = /400:\s*Bad Request:\s*there is no text in the message to edit/i;
const MESSAGE_DELETE_NOOP_RE =
/message to delete not found|message can't be deleted|MESSAGE_ID_INVALID|MESSAGE_DELETE_FORBIDDEN/i;
const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i;
@@ -421,6 +422,14 @@ function normalizeMessageId(raw: string | number): number {
throw new Error("Message id is required for Telegram actions");
}
function isTelegramMessageNotModifiedError(err: unknown): boolean {
return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err));
}
function isTelegramMessageHasNoTextError(err: unknown): boolean {
return MESSAGE_HAS_NO_TEXT_RE.test(formatErrorMessage(err));
}
function isTelegramMessageDeleteNoopError(err: unknown): boolean {
return MESSAGE_DELETE_NOOP_RE.test(formatErrorMessage(err));
}

View File

@@ -51,26 +51,6 @@ function formatErrorMessage(err: unknown): string {
return String(err);
}
function readTelegramErrorCode(err: unknown): number | undefined {
if (err && typeof err === "object" && "error_code" in err) {
const code = (err as { error_code: unknown }).error_code;
if (typeof code === "number") {
return code;
}
}
return undefined;
}
function postPollError(err: unknown): void {
const errorCode = readTelegramErrorCode(err);
post({
type: "poll-error",
message: formatErrorMessage(err),
...(errorCode === undefined ? {} : { errorCode }),
finishedAt: Date.now(),
});
}
function resolveBackoff(attempt: number): number {
return Math.min(retryMaxMs, retryInitialMs * 2 ** Math.max(0, attempt - 1));
}
@@ -142,21 +122,15 @@ async function fetchJson(params: {
});
const json = (await response.json()) as {
ok?: unknown;
error_code?: unknown;
result?: unknown;
description?: unknown;
};
if (!response.ok || json.ok !== true) {
const message =
throw new Error(
typeof json.description === "string"
? json.description
: `Telegram getUpdates failed with HTTP ${response.status}`;
// Preserve the Bot API error_code across the worker boundary so the
// parent session can distinguish getUpdates conflicts (409) from fatal
// errors (401) without parsing description strings.
throw typeof json.error_code === "number"
? Object.assign(new Error(message), { error_code: json.error_code })
: new Error(message);
: `Telegram getUpdates failed with HTTP ${response.status}`,
);
}
return json.result;
} finally {
@@ -221,7 +195,11 @@ async function main(): Promise<void> {
break;
}
failures += 1;
postPollError(err);
post({
type: "poll-error",
message: formatErrorMessage(err),
finishedAt: Date.now(),
});
if (!isRecoverableTelegramNetworkError(err, { context: "polling" })) {
throw err;
}
@@ -238,7 +216,7 @@ main()
parentPort?.close();
})
.catch((err: unknown) => {
postPollError(err);
post({ type: "poll-error", message: formatErrorMessage(err), finishedAt: Date.now() });
parentPort?.close();
process.exitCode = stopped ? 0 : 1;
});

View File

@@ -17,8 +17,6 @@ export type TelegramIngressWorkerMessage =
| {
type: "poll-error";
message: string;
/** Telegram Bot API error_code (e.g. 409 for getUpdates conflicts). */
errorCode?: number;
finishedAt: number;
}
| {

View File

@@ -1,17 +1,9 @@
// Agent Core tests cover agent loop behavior.
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue } from "./agent-loop.js";
import { createAssistantMessageEventStream } from "./llm.js";
import type { AssistantMessage, Message, Model } from "./llm.js";
import type {
AgentContext,
AgentEvent,
AgentLoopConfig,
AgentMessage,
AgentTool,
StreamFn,
} from "./types.js";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
const model: Model = {
id: "test-model",
@@ -151,146 +143,6 @@ describe("agentLoop streaming updates", () => {
});
});
describe("agentLoop tool termination", () => {
function makeAssistantMessage(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
timestamp: 1,
};
}
function makeTool(name: string, executed: string[]): AgentTool {
return {
name,
label: name,
description: name,
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => {
executed.push(name);
return {
content: [{ type: "text", text: `${name} result` }],
details: { name },
};
},
};
}
it("continues after a side-effect tool result when afterToolCall records it without terminate", async () => {
const executed: string[] = [];
let turn = 0;
const streamFn: StreamFn = () => {
turn += 1;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message =
turn === 1
? makeAssistantMessage([
{ type: "toolCall", id: "call-message", name: "message", arguments: {} },
])
: turn === 2
? makeAssistantMessage([
{ type: "toolCall", id: "call-exec", name: "exec", arguments: {} },
])
: makeAssistantMessage([{ type: "text", text: "done" }]);
stream.push({
type: "done",
reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
message,
});
stream.end();
});
return stream;
};
let recordedSideEffect = false;
const stream = agentLoop(
[{ role: "user", content: "hello", timestamp: 1 }],
{
systemPrompt: "",
messages: [],
tools: [makeTool("message", executed), makeTool("exec", executed)],
},
{
...config,
afterToolCall: async ({ toolCall }) => {
if (toolCall.name === "message") {
recordedSideEffect = true;
}
return undefined;
},
},
undefined,
streamFn,
);
const events = await collectEvents(stream);
expect(recordedSideEffect).toBe(true);
expect(turn).toBe(3);
expect(executed).toEqual(["message", "exec"]);
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(2);
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
});
it("stops after a tool result only when the finalized result explicitly terminates", async () => {
const executed: string[] = [];
let turn = 0;
const streamFn: StreamFn = () => {
turn += 1;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message =
turn === 1
? makeAssistantMessage([
{ type: "toolCall", id: "call-message", name: "message", arguments: {} },
])
: makeAssistantMessage([
{ type: "toolCall", id: "call-exec", name: "exec", arguments: {} },
]);
stream.push({ type: "done", reason: "toolUse", message });
stream.end();
});
return stream;
};
const stream = agentLoop(
[{ role: "user", content: "hello", timestamp: 1 }],
{
systemPrompt: "",
messages: [],
tools: [makeTool("message", executed), makeTool("exec", executed)],
},
{
...config,
afterToolCall: async ({ toolCall }) =>
toolCall.name === "message" ? { terminate: true } : undefined,
},
undefined,
streamFn,
);
const events = await collectEvents(stream);
expect(turn).toBe(1);
expect(executed).toEqual(["message"]);
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1);
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
});
});
describe("agentLoop thinking state", () => {
function makeAssistantMessage(
activeModel: Model,

View File

@@ -296,9 +296,6 @@ export interface AssistantMessage {
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
errorCode?: string;
errorType?: string;
errorBody?: string;
timestamp: number; // Unix timestamp in milliseconds
}

View File

@@ -152,10 +152,9 @@ docker_build_run_logged() {
fi
done < <(pgrep -P "$process_id" 2>/dev/null || true)
fi
# A successful group signal does not prove this exact PID was in that group;
# send both so timeout/build wrappers cannot exit while their command stays alive.
kill -s "$signal" -- "-$process_id" 2>/dev/null || true
kill -s "$signal" "$process_id" 2>/dev/null || true
kill -s "$signal" -- "-$process_id" 2>/dev/null ||
kill -s "$signal" "$process_id" 2>/dev/null ||
true
}
docker_build_stop_tracked_build() {

View File

@@ -79,8 +79,6 @@ type CapturedMetric = {
type CapturedLogRecord = {
body: string | number | boolean | string[];
spanId: string;
traceId: string;
};
const DEFAULT_SCENARIO_ID = "otel-trace-smoke";
@@ -648,21 +646,15 @@ function decodeMetricRequest(body: Buffer): CapturedMetric[] {
function decodeLogRecord(message: Uint8Array): CapturedLogRecord {
const reader = new ProtoReader(message);
let body: string | number | boolean | string[] = "";
let traceId = "";
let spanId = "";
while (!reader.done()) {
const { field, wire } = reader.tag();
if (field === 5 && wire === 2) {
body = normalizeOtlpValue(decodeAnyValue(reader.bytes()));
} else if (field === 9 && wire === 2) {
traceId = Buffer.from(reader.bytes()).toString("hex");
} else if (field === 10 && wire === 2) {
spanId = Buffer.from(reader.bytes()).toString("hex");
} else {
reader.skip(wire);
}
}
return { body, spanId, traceId };
return { body };
}
function decodeScopeLogs(message: Uint8Array): CapturedLogRecord[] {
@@ -1447,12 +1439,6 @@ function assertSmoke(params: {
if (rawLogBodies.length > 0) {
failures.push(`OTLP log records exported ${rawLogBodies.length} non-placeholder bodies`);
}
const correlatedLogRecords = params.logRecords.filter(
(record) => record.traceId && record.spanId,
);
if (correlatedLogRecords.length === 0) {
failures.push("no OTLP log records included trace/span correlation ids");
}
const attributeKeys = collectAttributeKeys(params.spans);
const disallowed = [...DISALLOWED_ATTRIBUTE_KEYS].filter((key) => attributeKeys.has(key));
@@ -1582,9 +1568,6 @@ async function main() {
spanCount: receiver.capturedSpans.length,
metricCount: receiver.capturedMetrics.length,
logRecordCount: receiver.capturedLogRecords.length,
logRecordsWithTraceContext: receiver.capturedLogRecords.filter(
(record) => record.traceId && record.spanId,
).length,
spanNames: assertion.spanNames,
metricNames: assertion.metricNames,
signalRequestCounts: assertion.signalRequestCounts,

View File

@@ -29,7 +29,6 @@ export function createBaseToolHandlerState() {
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSourceReplyPayloads: [],
messageToolOnlySourceReplyDelivered: false,
messagingToolSentTargets: [] as unknown[],
deterministicApprovalPromptSent: false,
blockBuffer: "",

View File

@@ -77,8 +77,6 @@ vi.mock("./model-auth.js", () => ({
}));
vi.mock("./model-runtime-aliases.js", () => ({
isCliRuntimeAliasForProvider: ({ runtime, provider }: { runtime?: string; provider?: string }) =>
runtime === "claude-cli" && provider === "anthropic",
resolveCliRuntimeExecutionProvider: ({
provider,
cfg,
@@ -644,29 +642,6 @@ describe("runBtwSideQuestion", () => {
expect(streamSimpleMock).toHaveBeenCalledTimes(1);
});
it("fails closed instead of using direct provider auth for CLI-runtime alias models", async () => {
await expect(
runSideQuestion({
cfg: {
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
},
},
},
} as never,
model: "claude-opus-4-7",
sessionKey: DEFAULT_SESSION_KEY,
}),
).rejects.toThrow(
"/btw is not yet supported for claude-cli-backed models. The selected model is routed through claude-cli; OpenClaw will not fall back to direct anthropic auth because that would use a different backend than the active session.",
);
expect(getApiKeyForModelMock).not.toHaveBeenCalled();
expect(streamSimpleMock).not.toHaveBeenCalled();
expect(registerProviderStreamForModelMock).not.toHaveBeenCalled();
});
it("does not let an auto-selected stale Anthropic profile suppress Claude CLI auth for BTW", async () => {
const claudeAuthStore = {
version: 1 as const,

View File

@@ -27,6 +27,7 @@ import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent-
import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js";
import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js";
import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js";
import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js";
import { resolveAvailableAgentHarnessPolicy, selectAgentHarness } from "./harness/selection.js";
import {
resolveImageSanitizationLimits,
@@ -38,10 +39,8 @@ import {
getApiKeyForModel,
requireApiKey,
} from "./model-auth.js";
import { isCliRuntimeAliasForProvider } from "./model-runtime-aliases.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "./openai-routing.js";
import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js";
import { registerProviderStreamForModel } from "./provider-stream.js";
import { stripToolResultDetails } from "./session-transcript-repair.js";
import { sanitizeImageBlocks } from "./tool-images.js";
@@ -380,26 +379,6 @@ export async function runBtwSideQuestion(
if (harness.id === "codex") {
throw new Error(`Selected agent harness "${harness.id}" does not support /btw side questions.`);
}
const fallbackPolicy = resolveAvailableAgentHarnessPolicy({
provider: params.provider,
modelId: params.model,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
});
const fallbackRuntime = fallbackPolicy.runtime.trim();
if (
isCliRuntimeAliasForProvider({
runtime: fallbackRuntime,
provider: params.provider,
cfg: params.cfg,
})
) {
throw new Error(
`/btw is not yet supported for ${fallbackRuntime}-backed models. ` +
`The selected model is routed through ${fallbackRuntime}; OpenClaw will not fall back to direct ${params.provider} auth because that would use a different backend than the active session.`,
);
}
const activeRunSnapshot = getActiveEmbeddedRunSnapshot(sessionId);
const imageLimits = resolveImageSanitizationLimits(params.cfg);

View File

@@ -571,23 +571,6 @@ describe("formatAssistantErrorText", () => {
"LLM request failed: provider rejected the request schema or tool payload.",
);
});
it("uses structured error body detail for model-not-found copy", () => {
const msg = makeAssistantMessageFixture({
errorMessage: "400 Param Incorrect",
errorCode: "400",
errorBody:
'{"code":"400","message":"Param Incorrect","param":"Not supported model some-model-id"}',
content: [],
});
expect(formatAssistantErrorText(msg)).toBe(
"The selected model was not found by the provider. Check the model id or choose a different model.",
);
expect(formatUserFacingAssistantErrorText(msg)).toBe(
"The selected model was not found by the provider. Check the model id or choose a different model.",
);
});
});
describe("formatRawAssistantErrorForUi", () => {

View File

@@ -1,7 +1,6 @@
// Covers provider error classifiers and failover reason mapping.
import { describe, expect, it } from "vitest";
import {
classifyAssistantFailoverReason,
classifyProviderRuntimeFailureKind,
classifyFailoverReason,
classifyFailoverReasonFromHttpStatus,
@@ -1038,34 +1037,6 @@ describe("image dimension errors", () => {
});
});
describe("classifyAssistantFailoverReason", () => {
it("uses structured assistant error bodies for model-not-found 400s", () => {
expect(
classifyAssistantFailoverReason({
role: "assistant",
api: "openai-completions",
provider: "openai",
model: "some-model-id",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: "400 Param Incorrect",
errorCode: "400",
errorBody:
'{"code":"400","message":"Param Incorrect","param":"Not supported model some-model-id"}',
content: [],
timestamp: 0,
}),
).toBe("model_not_found");
});
});
describe("classifyFailoverReasonFromHttpStatus 402 temporary limits", () => {
it("reclassifies periodic usage limits as rate_limit", () => {
const samples = [

View File

@@ -12,7 +12,6 @@ export {
} from "./embedded-agent-helpers/bootstrap.js";
export {
BILLING_ERROR_USER_MESSAGE,
classifyAssistantFailoverReason,
classifyProviderRuntimeFailureKind,
formatBillingErrorMessage,
formatRateLimitOrOverloadedErrorCopy,

View File

@@ -77,10 +77,6 @@ const sandboxToolPolicyAuditMessages = new WeakSet<AssistantMessage>();
export const GENERIC_ASSISTANT_ERROR_TEXT = "LLM request failed.";
const PROVIDER_SCHEMA_REJECTION_USER_TEXT =
"LLM request failed: provider rejected the request schema or tool payload.";
const MODEL_NOT_FOUND_USER_TEXT =
"The selected model was not found by the provider. Check the model id or choose a different model.";
const MAX_FAILOVER_DETAIL_CANDIDATES = 12;
const MAX_FAILOVER_DETAIL_CHARS = 1_000;
/** Detect provider errors that require reasoning to stay enabled. */
export function isReasoningConstraintErrorMessage(raw: string): boolean {
@@ -280,7 +276,6 @@ export type FailoverSignal = {
errorType?: string;
message?: string;
provider?: string;
details?: readonly string[];
};
export type FailoverClassification =
@@ -292,87 +287,6 @@ export type FailoverClassification =
kind: "context_overflow";
};
// Provider SDKs often keep semantic error fields outside Error.message.
// These bounded candidates feed classification only; user-facing copy still
// comes from the normal sanitized formatter path.
function normalizeFailoverDetailString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.length > MAX_FAILOVER_DETAIL_CHARS
? trimmed.slice(0, MAX_FAILOVER_DETAIL_CHARS)
: trimmed;
}
function appendFailoverDetailCandidate(candidates: string[], value: unknown): void {
const normalized =
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? normalizeFailoverDetailString(String(value))
: undefined;
if (!normalized || candidates.includes(normalized)) {
return;
}
candidates.push(normalized);
}
function collectFailoverDetailCandidates(
value: unknown,
candidates: string[],
seen: Set<object>,
): void {
if (
candidates.length >= MAX_FAILOVER_DETAIL_CANDIDATES ||
value === undefined ||
value === null
) {
return;
}
if (typeof value === "string") {
appendFailoverDetailCandidate(candidates, value);
const trimmed = value.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return;
}
try {
collectFailoverDetailCandidates(JSON.parse(trimmed) as unknown, candidates, seen);
} catch {
// Non-JSON detail strings are still useful as direct classifier candidates.
}
return;
}
if (typeof value === "number" || typeof value === "boolean") {
appendFailoverDetailCandidate(candidates, value);
return;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return;
}
if (seen.has(value)) {
return;
}
seen.add(value);
const record = value as Record<string, unknown>;
for (const key of ["message", "param", "code", "type", "error", "detail", "body"]) {
collectFailoverDetailCandidates(record[key], candidates, seen);
if (candidates.length >= MAX_FAILOVER_DETAIL_CANDIDATES) {
return;
}
}
}
export function extractFailoverSignalDetails(...values: unknown[]): string[] | undefined {
const candidates: string[] = [];
const seen = new Set<object>();
for (const value of values) {
collectFailoverDetailCandidates(value, candidates, seen);
if (candidates.length >= MAX_FAILOVER_DETAIL_CANDIDATES) {
break;
}
}
return candidates.length > 0 ? candidates : undefined;
}
export type ProviderRuntimeFailureKind =
| "auth_scope"
| "auth_refresh"
@@ -388,7 +302,6 @@ export type ProviderRuntimeFailureKind =
| "rate_limit"
| "dns"
| "timeout"
| "model_not_found"
| "schema"
| "sandbox_blocked"
| "replay_invalid"
@@ -1096,49 +1009,6 @@ function classifyFailoverClassificationFromMessage(
return null;
}
function classificationReason(
classification: FailoverClassification | null,
): FailoverReason | undefined {
return classification?.kind === "reason" ? classification.reason : undefined;
}
function classifyFailoverDetailCandidates(
details: readonly string[] | undefined,
provider: string | undefined,
includeProviderPluginHooks: boolean,
): FailoverClassification | null {
for (const detail of details ?? []) {
const classification = classifyFailoverClassificationFromMessage(detail, provider, {
includeProviderPluginHooks,
});
if (classification) {
return classification;
}
}
return null;
}
function mergeMessageAndDetailClassification(
messageClassification: FailoverClassification | null,
detailClassification: FailoverClassification | null,
): FailoverClassification | null {
if (!messageClassification) {
return detailClassification;
}
if (!detailClassification) {
return messageClassification;
}
if (messageClassification.kind === "context_overflow") {
return messageClassification;
}
if (detailClassification.kind === "context_overflow") {
return detailClassification;
}
return classificationReason(messageClassification) === "format"
? detailClassification
: messageClassification;
}
export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassification | null {
const inferredStatus = inferSignalStatus(signal);
const explicitStatus =
@@ -1159,11 +1029,6 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi
includeProviderPluginHooks: !hasStructuredProviderSignal,
})
: null;
const detailClassification = classifyFailoverDetailCandidates(
signal.details,
signal.provider,
!hasStructuredProviderSignal,
);
const providerPluginReason =
hasStructuredProviderSignal &&
signal.provider &&
@@ -1178,7 +1043,7 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi
: null;
const effectiveMessageClassification = providerPluginReason
? toReasonClassification(providerPluginReason)
: mergeMessageAndDetailClassification(messageClassification, detailClassification);
: messageClassification;
const codeReason = classifyFailoverReasonFromCode(signal.code);
if (codeReason === "auth_permanent") {
return toReasonClassification(codeReason);
@@ -1245,12 +1110,6 @@ export function classifyProviderRuntimeFailureKind(
if (failoverClassification?.kind === "reason" && failoverClassification.reason === "rate_limit") {
return "rate_limit";
}
if (
failoverClassification?.kind === "reason" &&
failoverClassification.reason === "model_not_found"
) {
return "model_not_found";
}
if (message && isDnsTransportErrorMessage(message)) {
return "dns";
}
@@ -1296,32 +1155,6 @@ export function classifyProviderRuntimeFailureKind(
return "unclassified";
}
function buildAssistantFailoverSignal(
msg: AssistantMessage,
opts?: { provider?: string },
): FailoverSignal {
return {
status: extractLeadingHttpStatus(msg.errorMessage?.trim() ?? "")?.code,
code: msg.errorCode,
errorType: msg.errorType,
message: msg.errorMessage?.trim() || undefined,
provider: opts?.provider ?? msg.provider,
details: extractFailoverSignalDetails(msg.errorBody),
};
}
export function classifyAssistantFailoverReason(
msg: AssistantMessage | undefined,
opts?: { provider?: string },
): FailoverReason | null {
if (!msg || msg.stopReason !== "error") {
return null;
}
return failoverReasonFromClassification(
classifyFailoverSignal(buildAssistantFailoverSignal(msg, opts)),
);
}
export function formatAssistantErrorText(
msg: AssistantMessage,
opts?: { cfg?: OpenClawConfig; sessionKey?: string; provider?: string; model?: string },
@@ -1336,8 +1169,9 @@ export function formatAssistantErrorText(
}
const providerRuntimeFailureKind = classifyProviderRuntimeFailureKind({
...buildAssistantFailoverSignal(msg, { provider: opts?.provider }),
status: extractLeadingHttpStatus(raw)?.code,
message: raw,
provider: opts?.provider ?? msg.provider,
});
const unknownTool =
@@ -1430,10 +1264,6 @@ export function formatAssistantErrorText(
return "LLM request failed: proxy or tunnel configuration blocked the provider request.";
}
if (providerRuntimeFailureKind === "model_not_found") {
return MODEL_NOT_FOUND_USER_TEXT;
}
if (isContextOverflowError(raw)) {
return (
"Context overflow: prompt too large for the model. " +
@@ -1750,5 +1580,8 @@ export function isFailoverErrorMessage(raw: string, opts?: { provider?: string }
}
export function isFailoverAssistantError(msg: AssistantMessage | undefined): boolean {
return classifyAssistantFailoverReason(msg) !== null;
if (!msg || msg.stopReason !== "error") {
return false;
}
return isFailoverErrorMessage(msg.errorMessage ?? "", { provider: msg.provider });
}

View File

@@ -1,184 +0,0 @@
/**
* Detects message-tool sends that delivered a visible reply to the current source.
*/
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
import { isMessageToolSendActionName } from "./embedded-agent-messaging.js";
import { isToolResultError } from "./embedded-agent-subscribe.tools.js";
import { normalizeToolName } from "./tool-policy.js";
const MESSAGE_TOOL_NAME = "message";
const EXPLICIT_MESSAGE_ROUTE_KEYS = ["channel", "target", "to", "channelId", "provider"];
const DRY_RUN_DELIVERY_STATUS = "dry_run";
const SENT_DELIVERY_STATUS = "sent";
const RESULT_ENVELOPE_KEYS = [
"details",
"payload",
"result",
"results",
"sendResult",
"toolResult",
];
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function hasStringValue(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) {
return true;
}
return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value));
}
function normalizeStatus(value: unknown): string | undefined {
return typeof value === "string" ? value.trim().toLowerCase() : undefined;
}
function parseJsonRecord(value: string): Record<string, unknown> | undefined {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function recordHasDeliveredMessageId(record: Record<string, unknown>): boolean {
if (hasStringValue(record.messageId)) {
return true;
}
const receipt = record.receipt;
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
return false;
}
const receiptRecord = receipt as Record<string, unknown>;
return (
hasStringValue(receiptRecord.primaryPlatformMessageId) ||
(Array.isArray(receiptRecord.platformMessageIds) &&
receiptRecord.platformMessageIds.some((value) => hasStringValue(value)))
);
}
function deliveryEnvelopeIndicatesDryRun(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesDryRun(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (
record.dryRun === true ||
normalizeStatus(record.deliveryStatus) === DRY_RUN_DELIVERY_STATUS
) {
return true;
}
const content = record.content;
if (Array.isArray(content)) {
for (const item of content) {
if (deliveryEnvelopeIndicatesDryRun(item, depth + 1)) {
return true;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const text = (item as Record<string, unknown>).text;
if (typeof text === "string") {
const parsed = parseJsonRecord(text);
if (parsed && deliveryEnvelopeIndicatesDryRun(parsed, depth + 1)) {
return true;
}
}
}
}
}
return RESULT_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesDryRun(record[key], depth + 1),
);
}
function deliveryEnvelopeIndicatesDelivered(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesDelivered(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (
normalizeStatus(record.deliveryStatus) === SENT_DELIVERY_STATUS ||
recordHasDeliveredMessageId(record)
) {
return true;
}
const content = record.content;
if (Array.isArray(content)) {
for (const item of content) {
if (deliveryEnvelopeIndicatesDelivered(item, depth + 1)) {
return true;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const text = (item as Record<string, unknown>).text;
if (typeof text === "string") {
const parsed = parseJsonRecord(text);
if (parsed && deliveryEnvelopeIndicatesDelivered(parsed, depth + 1)) {
return true;
}
}
}
}
}
return RESULT_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesDelivered(record[key], depth + 1),
);
}
/**
* Only implicit-route, non-dry-run, delivered `message.send` calls qualify.
* Explicit routes and other messaging tools are outbound side effects, not source replies.
*/
export function isDeliveredMessageToolOnlySourceReplyResult(params: {
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
toolName: string;
args?: unknown;
result?: unknown;
hookResult?: unknown;
isError?: boolean;
}): boolean {
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
return false;
}
if (normalizeToolName(params.toolName) !== MESSAGE_TOOL_NAME) {
return false;
}
const args = asRecord(params.args);
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
return false;
}
if (params.isError || isToolResultError(params.result) || isToolResultError(params.hookResult)) {
return false;
}
if (
args.dryRun === true ||
deliveryEnvelopeIndicatesDryRun(params.result) ||
deliveryEnvelopeIndicatesDryRun(params.hookResult)
) {
return false;
}
return (
deliveryEnvelopeIndicatesDelivered(params.result) ||
deliveryEnvelopeIndicatesDelivered(params.hookResult)
);
}

View File

@@ -5,13 +5,6 @@ const manifestMocks = vi.hoisted(() => ({
listOpenClawPluginManifestMetadata: vi.fn(),
loadPluginManifest: vi.fn(),
}));
const providerMocks = vi.hoisted(() => ({
normalizePluginDiscoveryResult: vi.fn(),
resolveBundledProviderCompatPluginIds: vi.fn(),
resolveOwningPluginIdsForProviderRef: vi.fn(),
resolveRuntimePluginDiscoveryProviders: vi.fn(),
runProviderStaticCatalog: vi.fn(),
}));
vi.mock("../../plugins/manifest-metadata-scan.js", () => ({
listOpenClawPluginManifestMetadata: manifestMocks.listOpenClawPluginManifestMetadata,
@@ -22,24 +15,7 @@ vi.mock("../../plugins/manifest.js", async (importOriginal) => ({
loadPluginManifest: manifestMocks.loadPluginManifest,
}));
vi.mock("../../plugins/providers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/providers.js")>()),
resolveBundledProviderCompatPluginIds: providerMocks.resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProviderRef: providerMocks.resolveOwningPluginIdsForProviderRef,
}));
vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/provider-discovery.js")>()),
normalizePluginDiscoveryResult: providerMocks.normalizePluginDiscoveryResult,
resolveRuntimePluginDiscoveryProviders: providerMocks.resolveRuntimePluginDiscoveryProviders,
runProviderStaticCatalog: providerMocks.runProviderStaticCatalog,
}));
import { getModelProviderRequestTransport } from "../provider-request-config.js";
import {
resolveBundledProviderStaticCatalogModel,
resolveBundledStaticCatalogModel,
} from "./model.static-catalog.js";
import { resolveBundledStaticCatalogModel } from "./model.static-catalog.js";
function setManifestPlugins(plugins: unknown[]) {
// Static catalog resolution reads scan metadata first, then loads the manifest
@@ -105,17 +81,7 @@ function createMistralManifestPlugin(overrides?: {
beforeEach(() => {
manifestMocks.listOpenClawPluginManifestMetadata.mockReset();
manifestMocks.loadPluginManifest.mockReset();
providerMocks.normalizePluginDiscoveryResult.mockReset();
providerMocks.resolveBundledProviderCompatPluginIds.mockReset();
providerMocks.resolveOwningPluginIdsForProviderRef.mockReset();
providerMocks.resolveRuntimePluginDiscoveryProviders.mockReset();
providerMocks.runProviderStaticCatalog.mockReset();
setManifestPlugins([]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([]);
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(undefined);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([]);
providerMocks.runProviderStaticCatalog.mockResolvedValue(undefined);
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({});
});
describe("resolveBundledStaticCatalogModel", () => {
@@ -200,132 +166,3 @@ describe("resolveBundledStaticCatalogModel", () => {
).toBeUndefined();
});
});
describe("resolveBundledProviderStaticCatalogModel", () => {
it("resolves exact rows from bundled provider static catalogs", async () => {
const cfg = { plugins: { entries: { google: { enabled: true } } } };
const provider = {
id: "google",
pluginId: "google",
label: "Google",
auth: [],
staticCatalog: { run: vi.fn() },
};
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
providerMocks.runProviderStaticCatalog.mockResolvedValue({ marker: "static-result" });
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({
google: {
api: "google-generative-ai",
authHeader: true,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
request: { headers: { "X-Static-Catalog": "yes" } },
models: [
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
mediaInput: { image: { maxSidePx: 3072, tokenMode: "provider" } },
},
],
},
});
const model = await resolveBundledProviderStaticCatalogModel({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg,
});
expect(model).toMatchObject({
api: "google-generative-ai",
authHeader: true,
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
contextTokens: undefined,
contextWindow: 1_048_576,
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
headers: { "X-Static-Catalog": "yes" },
id: "gemini-3.1-pro-preview",
input: ["text", "image"],
maxTokens: 65_536,
mediaInput: { image: { maxSidePx: 3072, tokenMode: "provider" } },
name: "Gemini 3.1 Pro Preview",
provider: "google",
reasoning: true,
});
expect(getModelProviderRequestTransport(model!)).toEqual({
headers: { "X-Static-Catalog": "yes" },
});
expect(providerMocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith({
config: cfg,
workspaceDir: undefined,
env: process.env,
onlyPluginIds: ["google"],
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
discoveryEntriesOnly: true,
includeManifestModelCatalogProviders: false,
});
expect(providerMocks.runProviderStaticCatalog).toHaveBeenCalledWith({
provider,
config: cfg,
workspaceDir: undefined,
env: process.env,
});
});
it("does not load provider catalogs when the provider owner is not bundled and enabled", async () => {
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([]);
await expect(
resolveBundledProviderStaticCatalogModel({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg: {},
}),
).resolves.toBeUndefined();
expect(providerMocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
expect(providerMocks.runProviderStaticCatalog).not.toHaveBeenCalled();
});
it("requires an exact provider and model match", async () => {
const provider = { id: "google", pluginId: "google", label: "Google", auth: [] };
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(["google"]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({
google: {
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }],
},
"google-vertex": {
api: "google-vertex",
baseUrl: "https://aiplatform.googleapis.com/v1",
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }],
},
});
await expect(
resolveBundledProviderStaticCatalogModel({
provider: "google",
modelId: "gemini-2.5-pro",
cfg: {},
}),
).resolves.toBeUndefined();
await expect(
resolveBundledProviderStaticCatalogModel({
provider: "openrouter",
modelId: "gemini-3.1-pro-preview",
cfg: {},
}),
).resolves.toBeUndefined();
});
});

View File

@@ -3,26 +3,15 @@
*/
import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { ModelProviderConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { planManifestModelCatalogRows } from "../../model-catalog/manifest-planner.js";
import { listOpenClawPluginManifestMetadata } from "../../plugins/manifest-metadata-scan.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
import {
normalizePluginDiscoveryResult,
resolveRuntimePluginDiscoveryProviders,
runProviderStaticCatalog,
} from "../../plugins/provider-discovery.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import {
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProviderRef,
} from "../../plugins/providers.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeStaticProviderModelId } from "../model-ref-shared.js";
import { buildInlineProviderModels } from "./model.inline-provider.js";
/**
* Resolves bundled plugin static model-catalog rows into runtime model records.
@@ -31,35 +20,21 @@ function rowMatchesModel(params: {
row: NormalizedModelCatalogRow;
provider: string;
modelId: string;
}): boolean {
return staticModelIdMatches({
candidateId: params.row.id,
provider: params.provider,
modelId: params.modelId,
rowProvider: params.row.provider,
});
}
function staticModelIdMatches(params: {
candidateId: string;
provider: string;
modelId: string;
rowProvider?: string;
}): boolean {
const normalizedProvider = normalizeProviderId(params.provider);
if (params.rowProvider && normalizeProviderId(params.rowProvider) !== normalizedProvider) {
if (normalizeProviderId(params.row.provider) !== normalizedProvider) {
return false;
}
return (
normalizeStaticProviderModelId(normalizedProvider, params.candidateId).trim().toLowerCase() ===
normalizeStaticProviderModelId(normalizedProvider, params.row.id).trim().toLowerCase() ===
normalizeStaticProviderModelId(normalizedProvider, params.modelId).trim().toLowerCase()
);
}
function normalizeStaticCatalogInput(
input: readonly unknown[] | undefined,
input: NormalizedModelCatalogRow["input"],
): ProviderRuntimeModel["input"] {
const normalizedInput = (input ?? []).filter(
const normalizedInput = input.filter(
(item): item is "text" | "image" => item === "text" || item === "image",
);
return normalizedInput.length > 0 ? normalizedInput : ["text"];
@@ -96,42 +71,6 @@ function modelFromStaticCatalogRow(row: NormalizedModelCatalogRow): ProviderRunt
};
}
function modelFromProviderStaticCatalog(params: {
provider: string;
providerConfig: ModelProviderConfig;
model: ModelProviderConfig["models"][number];
}): ProviderRuntimeModel {
const [model] = buildInlineProviderModels({
[params.provider]: { ...params.providerConfig, models: [params.model] },
});
return {
...model,
id: model?.id ?? params.model.id,
name: model?.name || params.model.name || params.model.id,
provider: params.provider,
api: model?.api ?? params.model.api ?? params.providerConfig.api ?? "openai-responses",
baseUrl: model?.baseUrl ?? params.model.baseUrl ?? params.providerConfig.baseUrl ?? "",
reasoning: model?.reasoning ?? params.model.reasoning ?? false,
input: normalizeStaticCatalogInput(model?.input ?? params.model.input),
cost: model?.cost ?? normalizeStaticCatalogCost(params.model.cost),
contextWindow:
model?.contextWindow ??
params.model.contextWindow ??
params.providerConfig.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
contextTokens:
model?.contextTokens ?? params.model.contextTokens ?? params.providerConfig.contextTokens,
maxTokens:
model?.maxTokens ??
params.model.maxTokens ??
params.providerConfig.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
...(params.providerConfig.authHeader !== undefined
? { authHeader: params.providerConfig.authHeader }
: {}),
};
}
type StaticCatalogPlugin = Parameters<
typeof planManifestModelCatalogRows
>[0]["registry"]["plugins"][number];
@@ -271,86 +210,3 @@ export function resolveBundledStaticCatalogModel(params: {
}
return undefined;
}
/**
* Resolves one bundled provider static-catalog model row for provider/model lookup.
*
* Some bundled providers expose their canonical offline rows through
* `providerCatalogEntry` instead of manifest `modelCatalog`. This keeps the
* skip-discovery fallback aligned with model list/inspect without running live
* discovery or untrusted workspace plugins.
*/
export async function resolveBundledProviderStaticCatalogModel(params: {
provider: string;
modelId: string;
cfg?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderRuntimeModel | undefined> {
const env = params.env ?? process.env;
const provider = normalizeProviderId(params.provider);
if (!provider || !params.modelId.trim()) {
return undefined;
}
const pluginIds = resolveOwningPluginIdsForProviderRef({
provider,
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
});
if (!pluginIds || pluginIds.length === 0) {
return undefined;
}
const bundledPluginIds = new Set(
resolveBundledProviderCompatPluginIds({
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
}),
);
const scopedPluginIds = pluginIds.filter((pluginId) => bundledPluginIds.has(pluginId));
if (scopedPluginIds.length === 0) {
return undefined;
}
const providers = await resolveRuntimePluginDiscoveryProviders({
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: scopedPluginIds,
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
discoveryEntriesOnly: true,
includeManifestModelCatalogProviders: false,
});
for (const catalogProvider of providers) {
const result = await runProviderStaticCatalog({
provider: catalogProvider,
config: params.cfg ?? {},
workspaceDir: params.workspaceDir,
env,
});
const normalized = normalizePluginDiscoveryResult({
provider: catalogProvider,
result,
});
for (const [providerIdRaw, providerConfig] of Object.entries(normalized)) {
const providerId = normalizeProviderId(providerIdRaw);
if (providerId !== provider || !Array.isArray(providerConfig.models)) {
continue;
}
const model = providerConfig.models.find((candidate) =>
staticModelIdMatches({
candidateId: candidate.id,
provider,
modelId: params.modelId,
}),
);
if (model) {
return modelFromProviderStaticCatalog({ provider, providerConfig, model });
}
}
}
return undefined;
}

View File

@@ -19,7 +19,6 @@ import { resetModelDiscoveryCacheForTest } from "./model-discovery-cache.js";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
const resolveBundledStaticCatalogModelMock = vi.hoisted(() => vi.fn());
const resolveBundledProviderStaticCatalogModelMock = vi.hoisted(() => vi.fn());
const resolveRuntimeSyntheticAuthProviderRefsMock = vi.hoisted(() => vi.fn((): string[] => []));
const resolveRuntimeExternalAuthProviderRefsMock = vi.hoisted(() => vi.fn((): string[] => []));
@@ -135,7 +134,6 @@ vi.mock("./model.static-catalog.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./model.static-catalog.js")>();
return {
...actual,
resolveBundledProviderStaticCatalogModel: resolveBundledProviderStaticCatalogModelMock,
resolveBundledStaticCatalogModel: resolveBundledStaticCatalogModelMock,
};
});
@@ -189,7 +187,6 @@ beforeEach(() => {
mockLoadOpenRouterModelCapabilities.mockReset();
mockLoadOpenRouterModelCapabilities.mockResolvedValue();
resolveBundledStaticCatalogModelMock.mockReset();
resolveBundledProviderStaticCatalogModelMock.mockReset();
});
function createRuntimeHooks() {
@@ -571,58 +568,6 @@ describe("resolveModel", () => {
cfg: undefined,
workspaceDir: undefined,
});
expect(resolveBundledProviderStaticCatalogModelMock).not.toHaveBeenCalled();
expect(discoverAuthStorage).not.toHaveBeenCalled();
expect(discoverModels).not.toHaveBeenCalled();
});
it("resolves opt-in provider static catalog rows while skipping agent discovery", async () => {
resolveBundledProviderStaticCatalogModelMock.mockResolvedValueOnce({
provider: "google",
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 12, cacheRead: 0.5, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
});
const result = await resolveModelAsync(
"google",
"gemini-3.1-pro-preview",
"/tmp/agent",
undefined,
{
allowBundledStaticCatalogFallback: true,
runtimeHooks: createRuntimeHooks(),
skipAgentDiscovery: true,
},
);
expectRecordFields(expectResolvedModel(result), {
provider: "google",
id: "gemini-3.1-pro-preview",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
contextWindow: 1_048_576,
maxTokens: 65_536,
});
expect(resolveBundledStaticCatalogModelMock).toHaveBeenCalledWith({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg: undefined,
workspaceDir: undefined,
});
expect(resolveBundledProviderStaticCatalogModelMock).toHaveBeenCalledWith({
provider: "google",
modelId: "gemini-3.1-pro-preview",
cfg: undefined,
workspaceDir: undefined,
});
expect(discoverAuthStorage).not.toHaveBeenCalled();
expect(discoverModels).not.toHaveBeenCalled();
});
@@ -1143,374 +1088,6 @@ describe("resolveModel", () => {
});
});
it("inherits bundled static transport for configured provider fallback models", () => {
resolveBundledStaticCatalogModelMock.mockReturnValueOnce({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: true,
input: ["text"],
cost: { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 384_000,
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
},
});
const cfg = {
models: {
providers: {
deepseek: {
baseUrl: "",
models: [
{
id: "deepseek-v4-pro",
name: "Custom DeepSeek V4 Pro",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
compat: {
supportsReasoningEffort: false,
},
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("deepseek", "deepseek-v4-pro", "/tmp/agent", cfg);
const model = expectResolvedModel(result);
expectRecordFields(model, {
name: "Custom DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: false,
contextWindow: 32_768,
maxTokens: 4_096,
});
expect(model.compat).toEqual(
expect.objectContaining({
supportsUsageInStreaming: true,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
}),
);
expect(resolveBundledStaticCatalogModelMock).toHaveBeenCalledWith({
provider: "deepseek",
modelId: "deepseek-v4-pro",
cfg,
workspaceDir: expect.any(String),
includeRuntimeDiscovery: true,
});
});
it("fills missing configured provider runtime transport from bundled static metadata", async () => {
resolveBundledStaticCatalogModelMock.mockReturnValueOnce({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: true,
input: ["text"],
cost: { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 384_000,
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
},
});
const cfg = {
models: {
providers: {
deepseek: {
models: [
{
id: "deepseek-v4-pro",
name: "Custom DeepSeek V4 Pro",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
thinkingLevelMap: { off: null },
},
],
},
},
},
} as unknown as OpenClawConfig;
const baseRuntimeHooks = createRuntimeHooks();
const runProviderDynamicModel = vi.fn(() => ({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "Custom DeepSeek V4 Pro",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
}));
const result = await resolveModelAsync("deepseek", "deepseek-v4-pro", "/tmp/agent", cfg, {
runtimeHooks: {
...baseRuntimeHooks,
runProviderDynamicModel,
},
skipAgentDiscovery: true,
});
const model = expectResolvedModel(result);
expectRecordFields(model, {
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: false,
contextWindow: 32_768,
maxTokens: 4_096,
});
expect(model.compat).toEqual(
expect.objectContaining({
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
}),
);
expect(runProviderDynamicModel).toHaveBeenCalled();
});
it("resolves configured DeepSeek probe models through bundled static transport without agent discovery", async () => {
resolveBundledStaticCatalogModelMock.mockReturnValueOnce({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: true,
input: ["text"],
cost: { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 384_000,
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
},
});
const cfg = {
models: {
providers: {
deepseek: {
models: [
{
id: "deepseek-v4-pro",
name: "Custom DeepSeek V4 Pro",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
thinkingLevelMap: { off: null },
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = await resolveModelAsync("deepseek", "deepseek-v4-pro", "/tmp/agent", cfg, {
runtimeHooks: createRuntimeHooks(),
skipAgentDiscovery: true,
});
const model = expectResolvedModel(result);
expectRecordFields(model, {
name: "Custom DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: false,
contextWindow: 32_768,
maxTokens: 4_096,
});
expect(model.compat).toEqual(
expect.objectContaining({
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
}),
);
expect((model as { thinkingLevelMap?: unknown }).thinkingLevelMap).toEqual({ off: null });
});
it("keeps provider runtime transport ahead of bundled static fallback metadata", async () => {
resolveBundledStaticCatalogModelMock.mockReturnValueOnce({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: true,
input: ["text"],
cost: { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 384_000,
});
const cfg = {
models: {
providers: {
deepseek: {
models: [
{
id: "deepseek-v4-pro",
name: "Custom DeepSeek V4 Pro",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
},
],
},
},
},
} as unknown as OpenClawConfig;
const baseRuntimeHooks = createRuntimeHooks();
const runProviderDynamicModel = vi.fn(() => ({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "Runtime DeepSeek V4 Pro",
api: "openai-responses" as const,
baseUrl: "https://runtime.deepseek.example/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
}));
const result = await resolveModelAsync("deepseek", "deepseek-v4-pro", "/tmp/agent", cfg, {
runtimeHooks: {
...baseRuntimeHooks,
runProviderDynamicModel,
},
skipAgentDiscovery: true,
});
const model = expectResolvedModel(result);
expectRecordFields(model, {
api: "openai-responses",
baseUrl: "https://runtime.deepseek.example/v1",
reasoning: false,
contextWindow: 32_768,
maxTokens: 4_096,
});
expect(runProviderDynamicModel).toHaveBeenCalled();
});
it("keeps configured transport overrides ahead of bundled static fallback metadata", () => {
resolveBundledStaticCatalogModelMock.mockReturnValueOnce({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: true,
input: ["text"],
cost: { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 384_000,
});
const cfg = {
models: {
providers: {
deepseek: {
baseUrl: "https://deepseek-proxy.example.com/v1",
api: "openai-completions",
models: [
{
id: "deepseek-v4-pro",
name: "Custom DeepSeek V4 Pro",
baseUrl: "https://deepseek-model-proxy.example.com/v1",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("deepseek", "deepseek-v4-pro", "/tmp/agent", cfg);
const model = expectResolvedModel(result);
expectRecordFields(model, {
api: "openai-responses",
baseUrl: "https://deepseek-model-proxy.example.com/v1",
contextWindow: 32_768,
maxTokens: 4_096,
});
});
it("keeps bundled static baseUrl when provider api is configured without a baseUrl", () => {
resolveBundledStaticCatalogModelMock.mockReturnValueOnce({
provider: "deepseek",
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-responses",
baseUrl: "https://api.deepseek.com",
reasoning: true,
input: ["text"],
cost: { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 384_000,
});
const cfg = {
models: {
providers: {
deepseek: {
api: "openai-completions",
models: [
{
id: "deepseek-v4-pro",
name: "Custom DeepSeek V4 Pro",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 4_096,
thinkingLevelMap: { off: null },
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("deepseek", "deepseek-v4-pro", "/tmp/agent", cfg);
const model = expectResolvedModel(result);
expectRecordFields(model, {
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
contextWindow: 32_768,
maxTokens: 4_096,
});
expect(model.thinkingLevelMap).toEqual({ off: null });
});
it("keeps provider token overrides ahead of bundled static fallback metadata", () => {
resolveBundledStaticCatalogModelMock.mockReturnValueOnce({
provider: "xiaomi-token-plan",
@@ -1646,7 +1223,6 @@ describe("resolveModel", () => {
const claudeModel = expectResolvedModel(claude);
expect(claudeModel.api).toBe("anthropic-messages");
expect(claudeModel.baseUrl).toBe("http://localhost:8080");
expect(claudeModel.maxTokens).toBeUndefined();
const gpt = resolveModelForTest("my-router", "my-router/gpt", "/tmp/agent", cfg);
const gptModel = expectResolvedModel(gpt);
@@ -1654,33 +1230,6 @@ describe("resolveModel", () => {
expect(gptModel.baseUrl).toBe("http://localhost:8080/v1");
});
it("preserves normalized inline provider transport when static metadata is merged", () => {
const cfg = {
models: {
providers: {
"my-gemini": {
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com",
models: [
{
id: "gemini-pro",
name: "Gemini Pro",
input: ["text"],
contextWindow: 32_768,
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("my-gemini", "gemini-pro", "/tmp/agent", cfg);
const model = expectResolvedModel(result);
expect(model.api).toBe("google-generative-ai");
expect(model.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
});
it("defaults baseUrl-only local custom fallback models to chat completions", () => {
const cfg = {
agents: {

View File

@@ -54,7 +54,6 @@ import {
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
import {
canonicalizeManifestModelCatalogProviderAlias,
resolveBundledProviderStaticCatalogModel,
resolveBundledStaticCatalogModel,
} from "./model.static-catalog.js";
@@ -359,14 +358,13 @@ function resolveConfiguredProviderDefaultApi(params: {
if (explicit) {
return explicit;
}
const providerConfiguredBaseUrl = normalizeTransportBaseUrl(providerConfig?.baseUrl);
if (!providerConfiguredBaseUrl) {
if (!providerConfig?.baseUrl) {
return undefined;
}
const normalized = resolveProviderTransport({
provider: params.provider,
api: undefined,
baseUrl: providerConfiguredBaseUrl,
baseUrl: providerConfig.baseUrl,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
runtimeHooks: params.runtimeHooks,
@@ -374,14 +372,6 @@ function resolveConfiguredProviderDefaultApi(params: {
return normalized.api ?? "openai-completions";
}
function normalizeTransportBaseUrl(baseUrl: unknown): string | undefined {
if (typeof baseUrl !== "string") {
return undefined;
}
const trimmed = baseUrl.trim();
return trimmed ? trimmed : undefined;
}
function resolveProviderRequestTimeoutMs(timeoutSeconds: unknown): number | undefined {
return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds, { floorSeconds: true });
}
@@ -493,33 +483,6 @@ function findConfiguredProviderModel(
);
}
function mergeStaticCatalogInlineModel(
staticCatalogModel: StaticCatalogFallbackModel | undefined,
inlineModel: Model,
): Model {
if (!staticCatalogModel) {
return inlineModel;
}
const compat = mergeModelCompat(staticCatalogModel.compat, inlineModel.compat);
const mediaInput = mergeModelMediaInput(staticCatalogModel.mediaInput, inlineModel.mediaInput);
const params = mergeModelParams(
readModelParams(staticCatalogModel.params),
readModelParams(inlineModel.params),
);
return {
...staticCatalogModel,
...inlineModel,
api: inlineModel.api ?? staticCatalogModel.api,
baseUrl:
normalizeTransportBaseUrl(inlineModel.baseUrl) ??
normalizeTransportBaseUrl(staticCatalogModel.baseUrl),
headers: inlineModel.headers ?? staticCatalogModel.headers,
...(compat ? { compat } : {}),
...(mediaInput ? { mediaInput } : {}),
...(params ? { params } : {}),
} as Model;
}
function hasConfiguredFallbackSurface(params: {
providerConfig: InlineProviderConfig | undefined;
configuredModel: ReturnType<typeof findConfiguredProviderModel>;
@@ -657,15 +620,6 @@ function applyConfiguredProviderOverrides(params: {
(discoveredModel.id !== modelId
? findConfiguredProviderModel(providerConfig, params.provider, discoveredModel.id)
: undefined);
const configuredStaticCatalogModel = configuredModel
? (resolveBundledStaticCatalogModel({
provider: params.provider,
modelId,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
includeRuntimeDiscovery: true,
}) as StaticCatalogFallbackModel | undefined)
: undefined;
const metadataOverrideModel =
params.preferDiscoveredModelMetadata && isModelsAddMetadataModel({ model: configuredModel })
? undefined
@@ -719,7 +673,6 @@ function applyConfiguredProviderOverrides(params: {
};
}
const resolvedParams = mergeModelParams(
readModelParams(configuredStaticCatalogModel?.params),
readModelParams(discoveredModel.params),
providerParams,
defaultModelParams,
@@ -739,32 +692,16 @@ function applyConfiguredProviderOverrides(params: {
workspaceDir: params.workspaceDir,
runtimeHooks: params.runtimeHooks,
});
const metadataOverrideBaseUrl = normalizeTransportBaseUrl(metadataOverrideModel?.baseUrl);
const providerConfiguredBaseUrl = normalizeTransportBaseUrl(providerConfig.baseUrl);
const discoveredBaseUrl = normalizeTransportBaseUrl(discoveredModel.baseUrl);
const configuredStaticCatalogBaseUrl = normalizeTransportBaseUrl(
configuredStaticCatalogModel?.baseUrl,
);
const resolvedTransportApi = params.preferDiscoveredTransport
? (discoveredModel.api ??
metadataOverrideModel?.api ??
providerConfig.api ??
configuredStaticCatalogModel?.api ??
providerDefaultApi)
: (metadataOverrideModel?.api ??
providerConfig.api ??
discoveredModel.api ??
configuredStaticCatalogModel?.api ??
providerDefaultApi);
const resolvedTransportBaseUrl = params.preferDiscoveredTransport
? (discoveredBaseUrl ??
metadataOverrideBaseUrl ??
providerConfiguredBaseUrl ??
configuredStaticCatalogBaseUrl)
: (metadataOverrideBaseUrl ??
providerConfiguredBaseUrl ??
discoveredBaseUrl ??
configuredStaticCatalogBaseUrl);
const resolvedTransportApi =
metadataOverrideModel?.api ??
(params.preferDiscoveredTransport
? (discoveredModel.api ?? providerConfig.api ?? providerDefaultApi)
: (providerConfig.api ?? discoveredModel.api ?? providerDefaultApi));
const resolvedTransportBaseUrl =
metadataOverrideModel?.baseUrl ??
(params.preferDiscoveredTransport
? (discoveredModel.baseUrl ?? providerConfig.baseUrl)
: (providerConfig.baseUrl ?? discoveredModel.baseUrl));
const resolvedTransport = resolveProviderTransport({
provider: params.provider,
@@ -779,16 +716,7 @@ function applyConfiguredProviderOverrides(params: {
metadataOverrideModel?.contextWindow ?? providerConfig.contextWindow;
const resolvedMaxTokens =
metadataOverrideModel?.maxTokens ?? providerConfig.maxTokens ?? discoveredModel.maxTokens;
const normalizedResolvedMaxTokens =
typeof resolvedMaxTokens === "number" && Number.isFinite(resolvedMaxTokens)
? typeof resolvedContextWindow === "number" && Number.isFinite(resolvedContextWindow)
? Math.min(resolvedMaxTokens, resolvedContextWindow)
: resolvedMaxTokens
: undefined;
const resolvedCompat = mergeModelCompat(
mergeModelCompat(configuredStaticCatalogModel?.compat, discoveredModel.compat),
metadataOverrideModel?.compat,
);
const resolvedCompat = mergeModelCompat(discoveredModel.compat, metadataOverrideModel?.compat);
const resolvedReasoning = resolveMergedConfiguredModelReasoning({
provider: params.provider,
configuredCompat: metadataOverrideModel?.compat,
@@ -800,12 +728,10 @@ function applyConfiguredProviderOverrides(params: {
provider: params.provider,
api:
resolvedTransport.api ??
normalizeResolvedTransportApi(configuredStaticCatalogModel?.api) ??
normalizeResolvedTransportApi(discoveredModel.api) ??
providerDefaultApi ??
"openai-responses",
baseUrl:
resolvedTransport.baseUrl ?? configuredStaticCatalogModel?.baseUrl ?? discoveredModel.baseUrl,
baseUrl: resolvedTransport.baseUrl ?? discoveredModel.baseUrl,
discoveredHeaders,
providerHeaders,
modelHeaders: configuredHeaders,
@@ -828,9 +754,10 @@ function applyConfiguredProviderOverrides(params: {
metadataOverrideModel?.contextTokens ??
providerConfig.contextTokens ??
discoveredModel.contextTokens,
...(normalizedResolvedMaxTokens !== undefined
? { maxTokens: normalizedResolvedMaxTokens }
: {}),
maxTokens:
typeof resolvedContextWindow === "number"
? Math.min(resolvedMaxTokens, resolvedContextWindow)
: resolvedMaxTokens,
...(resolvedParams ? { params: resolvedParams } : {}),
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
headers: requestConfig.headers,
@@ -839,10 +766,7 @@ function applyConfiguredProviderOverrides(params: {
: {}),
compat: resolvedCompat,
mediaInput: mergeModelMediaInput(
mergeModelMediaInput(
configuredStaticCatalogModel?.mediaInput,
discoveredModel.mediaInput,
),
discoveredModel.mediaInput,
metadataOverrideModel?.mediaInput,
),
},
@@ -884,13 +808,13 @@ function resolveExplicitModelWithRegistry(params: {
) {
return { kind: "suppressed" };
}
const staticCatalogModel = resolveBundledStaticCatalogModel({
const resolvedParams = mergeConfiguredRuntimeModelParams({
cfg,
provider,
modelId,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
}) as StaticCatalogFallbackModel | undefined;
providerParams: providerConfig?.params,
configuredParams: inlineMatch.params,
});
return {
kind: "resolved",
model: normalizeResolvedModel({
@@ -898,16 +822,16 @@ function resolveExplicitModelWithRegistry(params: {
cfg,
agentDir,
workspaceDir,
model: applyConfiguredProviderOverrides({
provider,
discoveredModel: mergeStaticCatalogInlineModel(staticCatalogModel, inlineMatch as Model),
providerConfig,
modelId,
cfg,
runtimeHooks,
workspaceDir,
preferDiscoveredTransport: true,
}),
model: {
...inlineMatch,
reasoning: resolveConfiguredModelReasoning({
provider,
compat: inlineMatch.compat,
reasoning: inlineMatch.reasoning,
}),
...(resolvedParams ? { params: resolvedParams } : {}),
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
} as Model,
runtimeHooks,
}),
};
@@ -1121,48 +1045,37 @@ function resolveConfiguredFallbackModel(params: {
if (!hasConfiguredFallbackSurface({ providerConfig, configuredModel, modelId })) {
return undefined;
}
const staticCatalogModel = resolveBundledStaticCatalogModel({
provider,
modelId,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
}) as StaticCatalogFallbackModel | undefined;
const staticCatalogModel = configuredModel
? undefined
: (resolveBundledStaticCatalogModel({
provider,
modelId,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
}) as StaticCatalogFallbackModel | undefined);
const metadataModel = configuredModel ?? staticCatalogModel;
const fallbackCompat = mergeModelCompat(staticCatalogModel?.compat, configuredModel?.compat);
const fallbackMediaInput = mergeModelMediaInput(
staticCatalogModel?.mediaInput,
configuredModel?.mediaInput,
);
const fallbackCompat = configuredModel?.compat ?? staticCatalogModel?.compat;
const fallbackMediaInput = configuredModel?.mediaInput ?? staticCatalogModel?.mediaInput;
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, {
stripSecretRefMarkers: true,
});
const providerRequest = sanitizeConfiguredModelProviderRequest(providerConfig?.request);
const staticCatalogHeaders = sanitizeModelHeaders(staticCatalogModel?.headers, {
stripSecretRefMarkers: true,
});
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers, {
const modelHeaders = sanitizeModelHeaders(metadataModel?.headers, {
stripSecretRefMarkers: true,
});
const resolvedParams = mergeConfiguredRuntimeModelParams({
cfg,
provider,
modelId,
discoveredParams: staticCatalogModel?.params,
providerParams: providerConfig?.params,
configuredParams: configuredModel?.params,
configuredParams: metadataModel?.params,
});
const providerConfiguredApi = normalizeResolvedTransportApi(providerConfig?.api);
const configuredModelBaseUrl = normalizeTransportBaseUrl(configuredModel?.baseUrl);
const providerConfiguredBaseUrl = normalizeTransportBaseUrl(providerConfig?.baseUrl);
const staticCatalogBaseUrl = normalizeTransportBaseUrl(staticCatalogModel?.baseUrl);
const fallbackTransport = resolveProviderTransport({
provider,
modelId,
api:
normalizeResolvedTransportApi(configuredModel?.api) ??
providerConfiguredApi ??
normalizeResolvedTransportApi(staticCatalogModel?.api) ??
resolveConfiguredProviderDefaultApi({
provider,
providerConfig,
@@ -1170,8 +1083,9 @@ function resolveConfiguredFallbackModel(params: {
workspaceDir,
runtimeHooks,
}) ??
normalizeResolvedTransportApi(staticCatalogModel?.api) ??
"openai-responses",
baseUrl: configuredModelBaseUrl ?? providerConfiguredBaseUrl ?? staticCatalogBaseUrl,
baseUrl: configuredModel?.baseUrl ?? providerConfig?.baseUrl ?? staticCatalogModel?.baseUrl,
cfg,
workspaceDir,
runtimeHooks,
@@ -1180,7 +1094,6 @@ function resolveConfiguredFallbackModel(params: {
provider,
api: fallbackTransport.api ?? "openai-responses",
baseUrl: fallbackTransport.baseUrl,
discoveredHeaders: staticCatalogHeaders,
providerHeaders,
modelHeaders,
authHeader: providerConfig?.authHeader,
@@ -1213,9 +1126,6 @@ function resolveConfiguredFallbackModel(params: {
modelName: metadataModel?.name ?? modelId,
input: metadataModel?.input,
}),
...(configuredModel?.thinkingLevelMap !== undefined
? { thinkingLevelMap: configuredModel.thinkingLevelMap }
: {}),
cost: metadataModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
configuredModel?.contextWindow ??
@@ -1567,32 +1477,25 @@ export async function resolveModelAsync(
authProfileId: options?.authProfileId,
preferredProfile: options?.preferredProfile,
});
let staticCatalogLookup: Promise<ProviderRuntimeModel | undefined> | undefined;
const resolveStaticCatalogModel = async () => {
let staticCatalogLookupComplete = false;
let staticCatalogModel: ReturnType<typeof resolveBundledStaticCatalogModel> | undefined;
const resolveStaticCatalogModel = () => {
if (!options?.allowBundledStaticCatalogFallback) {
return undefined;
}
staticCatalogLookup ??= (async () => {
const manifestModel = resolveBundledStaticCatalogModel({
if (!staticCatalogLookupComplete) {
staticCatalogLookupComplete = true;
staticCatalogModel = resolveBundledStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
});
if (manifestModel) {
return manifestModel;
}
return await resolveBundledProviderStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
});
})();
return await staticCatalogLookup;
}
return staticCatalogModel;
};
const resolveStaticCatalogFallbackModel = async () => {
const catalogModel = await resolveStaticCatalogModel();
const resolveStaticCatalogFallbackModel = () => {
const catalogModel = resolveStaticCatalogModel();
if (!catalogModel) {
return undefined;
}
@@ -1665,7 +1568,7 @@ export async function resolveModelAsync(
model = await resolveDynamicAttempt();
}
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = await resolveStaticCatalogFallbackModel();
model = resolveStaticCatalogFallbackModel();
}
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = resolveConfiguredFallbackModel({
@@ -1678,7 +1581,7 @@ export async function resolveModelAsync(
});
}
if (model && options?.allowBundledStaticCatalogFallback) {
const staticMediaInput = (await resolveStaticCatalogModel())?.mediaInput;
const staticMediaInput = resolveStaticCatalogModel()?.mediaInput;
const resolvedMediaInput = (model as ProviderRuntimeModel).mediaInput;
const mediaInput = mergeModelMediaInput(staticMediaInput, resolvedMediaInput);
if (mediaInput) {

View File

@@ -211,10 +211,6 @@ export const mockedFormatBillingErrorMessage = vi.fn(() => "");
export const mockedClassifyFailoverReason = vi.fn<(raw: string) => FailoverReason | null>(
() => null,
);
export const mockedClassifyAssistantFailoverReason = vi.fn(
(assistant?: { errorMessage?: string | null }): FailoverReason | null =>
mockedClassifyFailoverReason(assistant?.errorMessage ?? ""),
);
export const mockedExtractObservedOverflowTokenCount = vi.fn((msg?: string) => {
const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i);
return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined;
@@ -388,11 +384,6 @@ export function resetRunOverflowCompactionHarnessMocks(): void {
mockedClassifyFailoverReason.mockReset();
mockedClassifyFailoverReason.mockReturnValue(null);
mockedClassifyAssistantFailoverReason.mockReset();
mockedClassifyAssistantFailoverReason.mockImplementation(
(assistant?: { errorMessage?: string | null }): FailoverReason | null =>
mockedClassifyFailoverReason(assistant?.errorMessage ?? ""),
);
mockedFormatBillingErrorMessage.mockReset();
mockedFormatBillingErrorMessage.mockReturnValue("");
mockedFormatAssistantErrorText.mockReset();
@@ -633,7 +624,6 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
vi.doMock("../embedded-agent-helpers.js", () => ({
formatBillingErrorMessage: mockedFormatBillingErrorMessage,
classifyFailoverReason: mockedClassifyFailoverReason,
classifyAssistantFailoverReason: mockedClassifyAssistantFailoverReason,
extractObservedOverflowTokenCount: mockedExtractObservedOverflowTokenCount,
formatAssistantErrorText: mockedFormatAssistantErrorText,
isAuthAssistantError: mockedIsAuthAssistantError,

View File

@@ -53,7 +53,6 @@ import {
} from "../command/session.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import {
classifyAssistantFailoverReason,
classifyFailoverReason,
extractObservedOverflowTokenCount,
type FailoverReason,
@@ -2798,7 +2797,12 @@ export async function runEmbeddedAgent(
const rateLimitFailure = isRateLimitAssistantError(assistantForFailover);
const billingFailure = isBillingAssistantError(assistantForFailover);
const failoverFailure = isFailoverAssistantError(assistantForFailover);
const assistantFailoverReason = classifyAssistantFailoverReason(assistantForFailover);
const assistantFailoverReason = classifyFailoverReason(
assistantForFailover?.errorMessage ?? "",
{
provider: assistantForFailover?.provider,
},
);
const assistantProviderStarted =
Boolean(currentAttemptAssistant?.provider) ||
idleTimedOut ||
@@ -3030,8 +3034,6 @@ export async function runEmbeddedAgent(
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
inlineToolResultsAllowed: false,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
attempt.didDeliverSourceReplyViaMessageTool === true,
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
agentId: params.agentId,

View File

@@ -538,193 +538,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
]);
});
it("keeps newly generated thinking after repairing rejected Anthropic replay", async () => {
const { SessionManager: ActualSessionManager } =
await vi.importActual<typeof import("../../sessions/index.js")>("../../sessions/index.js");
const staleAssistant = {
role: "assistant",
content: [
{
type: "thinking",
thinking: "historical stale thinking",
thinkingSignature: "stale-signature",
},
{ type: "text", text: "historical answer" },
],
stopReason: "stop",
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-6",
timestamp: 2,
} as AgentMessage;
const sessionMessages = [
{ role: "user", content: "historical question", timestamp: 1 } as AgentMessage,
staleAssistant,
];
const sessionManager = ActualSessionManager.inMemory();
const appendSessionMessage = (message: AgentMessage) =>
sessionManager.appendMessage(message as Parameters<typeof sessionManager.appendMessage>[0]);
for (const message of sessionMessages) {
appendSessionMessage(message);
}
const retryAssistant = {
role: "assistant",
content: [
{
type: "thinking",
thinking: "fresh valid retry thinking",
thinkingSignature: "fresh-valid-signature",
},
{ type: "text", text: "retry answer" },
],
stopReason: "stop",
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-6",
timestamp: 4,
} as AgentMessage;
const providerContexts: AgentMessage[][] = [];
const afterTurn = vi.fn(async (_params: { messages: AgentMessage[] }) => {});
hoisted.sessionManagerOpenMock.mockReturnValue(sessionManager);
await createContextEngineAttemptRunner({
contextEngine: {
...createContextEngineBootstrapAndAssemble(),
afterTurn,
},
sessionKey,
tempPaths,
sessionMessages,
attemptOverrides: {
provider: "anthropic",
modelId: "claude-sonnet-4-6",
model: {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-sonnet-4-6",
contextWindow: 128_000,
input: ["text"],
} as never,
runtimePlan: {
prompt: {
resolveSystemPromptContribution: () => undefined,
},
transcript: {
resolvePolicy: () => ({
sanitizeMode: "full",
sanitizeToolCallIds: true,
preserveNativeAnthropicToolUseIds: false,
repairToolUseResultPairing: true,
preserveSignatures: true,
sanitizeThinkingSignatures: false,
dropThinkingBlocks: false,
dropReasoningFromHistory: false,
applyGoogleTurnOrdering: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
allowSyntheticToolResults: false,
}),
},
transport: {
extraParams: {},
resolveExtraParams: () => ({}),
},
tools: {
normalize: (tools: unknown[]) => tools,
logDiagnostics: () => {},
},
auth: {
providerForAuth: "anthropic",
authProfileProviderForAuth: "",
forwardedAuthProfileId: undefined,
},
delivery: {
isSilentPayload: () => false,
resolveFollowupRoute: () => undefined,
},
outcome: {
classifyRunResult: () => undefined,
},
observability: {
resolvedRef: "anthropic/claude-sonnet-4-6",
provider: "anthropic",
modelId: "claude-sonnet-4-6",
modelApi: "anthropic-messages",
},
} as never,
},
createSession: () => {
const session = createDefaultEmbeddedSession({ initialMessages: sessionMessages });
let streamCalls = 0;
session.agent.streamFn = async (_model, context) => {
streamCalls += 1;
providerContexts.push([
...((context as { messages?: AgentMessage[] } | undefined)?.messages ?? []),
]);
if (streamCalls === 1) {
throw new Error("invalid signature in thinking block");
}
return {
async result() {
return retryAssistant;
},
[Symbol.asyncIterator]() {
return (async function* () {})();
},
};
};
session.prompt = async (prompt, options) => {
options?.preflightResult?.(true);
const userMessage = {
role: "user",
content: [{ type: "text", text: prompt }],
timestamp: 3,
} as AgentMessage;
session.messages = [...session.messages, userMessage];
appendSessionMessage(userMessage);
const stream = await session.agent.streamFn?.(
{} as never,
{ messages: session.messages } as never,
{},
);
const assistantMessage = await (
stream as { result: () => Promise<AgentMessage> }
).result();
session.messages = [...session.messages, assistantMessage];
appendSessionMessage(assistantMessage);
};
return session;
},
});
const firstProviderContext = providerContexts[0] ?? [];
const retryProviderContext = providerContexts[1] ?? [];
expect(JSON.stringify(firstProviderContext)).toContain("stale-signature");
expect(JSON.stringify(retryProviderContext)).not.toContain("stale-signature");
const finalMessages = sessionManager.buildSessionContext().messages;
expect(JSON.stringify(finalMessages[1])).not.toContain("historical stale thinking");
expect(JSON.stringify(finalMessages.at(-1))).toContain("fresh valid retry thinking");
expect(JSON.stringify(finalMessages.at(-1))).toContain("fresh-valid-signature");
expect(afterTurn).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([
expect.objectContaining({
type: "thinking",
thinking: "fresh valid retry thinking",
thinkingSignature: "fresh-valid-signature",
}),
]),
}),
]),
}),
);
});
it("sends transcriptPrompt visibly and keeps runtime context out of transcript messages", async () => {
const seen: { prompt?: string; messages?: unknown[]; systemPrompt?: string } = {};

View File

@@ -793,14 +793,10 @@ vi.mock("../sandbox-info.js", () => ({
resolveEmbeddedSandboxInfoExecPolicy: () => ({}),
}));
vi.mock("../thinking.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../thinking.js")>();
return {
...actual,
dropReasoningFromHistory: <T>(messages: T) => messages,
dropThinkingBlocks: <T>(messages: T) => messages,
};
});
vi.mock("../thinking.js", () => ({
dropReasoningFromHistory: <T>(messages: T) => messages,
dropThinkingBlocks: <T>(messages: T) => messages,
}));
vi.mock("../tool-name-allowlist.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../tool-name-allowlist.js")>();

View File

@@ -277,7 +277,6 @@ import {
resolveEmbeddedAgentStreamFn,
} from "../stream-resolution.js";
import { applySystemPromptToSession } from "../system-prompt.js";
import { repairRejectedThinkingReplayInSessionManager } from "../thinking-replay-repair.js";
import {
dropReasoningFromHistory,
dropThinkingBlocks,
@@ -1991,7 +1990,6 @@ export async function runEmbeddedAttempt(
let trajectoryEndRecorded = false;
let buildAbortSettlePromise: () => Promise<void> | null = () => null;
let cleanupYieldAborted = false;
let repairedRejectedThinkingReplay = false;
try {
await repairSessionFileIfNeeded({
sessionFile: params.sessionFile,
@@ -2746,30 +2744,6 @@ export async function runEmbeddedAttempt(
activeSession.agent.streamFn,
{
id: activeSession.sessionId,
onRecoveredAnthropicThinking: () => {
if (!sessionManager) {
log.warn(
`[session-recovery] unable to repair rejected thinking replay: session manager unavailable sessionId=${activeSession.sessionId}`,
);
return;
}
const repair = repairRejectedThinkingReplayInSessionManager({
sessionManager,
sessionFile: params.sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: sessionAgentId,
});
if (repair.repaired) {
repairedRejectedThinkingReplay = true;
sessionLockController.refreshAfterOwnedSessionWrite();
return;
}
log.warn(
`[session-recovery] rejected thinking replay retry succeeded but transcript repair made no changes: ` +
`sessionId=${activeSession.sessionId} reason=${repair.reason ?? "unknown"}`,
);
},
},
);
}
@@ -3309,7 +3283,6 @@ export async function runEmbeddedAttempt(
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
hasDeliveredMessageToolOnlySourceReply: () => didDeliverSourceReplyViaMessageTool,
onToolResult: params.onToolResult,
onReasoningStream: params.onReasoningStream,
onReasoningEnd: params.onReasoningEnd,
@@ -4564,9 +4537,6 @@ export async function runEmbeddedAttempt(
await sessionLockController.waitForSessionEvents(activeSession);
await waitForPendingEvents();
if (repairedRejectedThinkingReplay) {
activeSession.agent.state.messages = activeSessionManager.buildSessionContext().messages;
}
await sessionLockController.releaseForPrompt();
if (

View File

@@ -1,19 +1,18 @@
// Message-tool delivery tests cover message_tool_only delivery, where a
// successful source message send records source reply evidence without ending
// the run before the model can observe the tool result.
// Message-tool terminal tests cover message_tool_only delivery, where a
// successful source message send should end the run without duplicate replies.
import type { Agent, AfterToolCallContext } from "openclaw/plugin-sdk/agent-core";
import { describe, expect, it, vi } from "vitest";
import {
installMessageToolOnlyTerminalHook,
isDeliveredMessageToolOnlySourceReply,
shouldTerminateAfterMessageToolOnlySend,
} from "./message-tool-terminal.js";
describe("message-tool-only source replies", () => {
it("marks successful message-tool-only sends as delivered source replies", () => {
describe("message-tool-only terminal sends", () => {
it("marks successful message-tool-only sends as terminal", () => {
// Direct send evidence can come from the tool result or hook result; either
// path means the source reply was delivered and no automatic reply is needed.
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -22,7 +21,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(true);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -32,7 +31,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(true);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -44,9 +43,9 @@ describe("message-tool-only source replies", () => {
).toBe(true);
});
it("ignores automatic delivery, non-send actions, explicit routes, or failed sends", () => {
it("does not terminate automatic delivery, non-send actions, explicit routes, or failed sends", () => {
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "automatic",
context: createAfterToolCallContext({
toolName: "message",
@@ -55,7 +54,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -64,7 +63,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -73,7 +72,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "sessions_send",
@@ -82,7 +81,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -93,11 +92,11 @@ describe("message-tool-only source replies", () => {
).toBe(false);
});
it("ignores dry-run or non-delivered sends", () => {
it("does not terminate dry-run or non-delivered sends", () => {
// Dry runs and suppressed sends are observable tool activity, not delivered
// replies, so they cannot close the turn.
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -106,7 +105,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -124,7 +123,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -134,7 +133,7 @@ describe("message-tool-only source replies", () => {
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -148,9 +147,9 @@ describe("message-tool-only source replies", () => {
).toBe(false);
});
it("ignores suppressed sends without delivery evidence", () => {
it("does not terminate suppressed sends without delivery evidence", () => {
expect(
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: "message_tool_only",
context: createAfterToolCallContext({
toolName: "message",
@@ -161,7 +160,7 @@ describe("message-tool-only source replies", () => {
).toBe(false);
});
it("preserves existing after-tool-call output while recording delivered source replies", async () => {
it("preserves existing after-tool-call output while adding the terminal hint", async () => {
const previousAfterToolCall = vi.fn(async () => ({
content: [{ type: "text" as const, text: "rewritten" }],
details: { rewritten: true },
@@ -184,31 +183,12 @@ describe("message-tool-only source replies", () => {
).resolves.toEqual({
content: [{ type: "text", text: "rewritten" }],
details: { rewritten: true },
terminate: true,
});
expect(previousAfterToolCall).toHaveBeenCalledTimes(1);
expect(onDeliveredSourceReply).toHaveBeenCalledTimes(1);
});
it("records delivery evidence without rewriting the default result", async () => {
const agent = {} as unknown as Agent;
const onDeliveredSourceReply = vi.fn();
installMessageToolOnlyTerminalHook({
agent,
sourceReplyDeliveryMode: "message_tool_only",
onDeliveredSourceReply,
});
await expect(
agent.afterToolCall?.(
createAfterToolCallContext({
toolName: "message",
args: { action: "send", message: "visible reply" },
}),
),
).resolves.toBeUndefined();
expect(onDeliveredSourceReply).toHaveBeenCalledTimes(1);
});
it("leaves existing after-tool-call output alone when the send failed", async () => {
const previousAfterToolCall = vi.fn(async () => ({
content: [{ type: "text" as const, text: "failed" }],

View File

@@ -1,9 +1,24 @@
import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js";
/**
* Detects message-tool-only sends that delivered a visible source reply.
* Detects message-tool-only sends that should terminate an agent turn.
*/
import { isDeliveredMessageToolOnlySourceReplyResult } from "../../embedded-agent-message-tool-source-reply.js";
import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js";
import { isMessageToolSendActionName } from "../../embedded-agent-messaging.js";
import { isToolResultError } from "../../embedded-agent-subscribe.tools.js";
import type { AfterToolCallContext, AfterToolCallResult, Agent } from "../../runtime/index.js";
import { normalizeToolName } from "../../tool-policy.js";
const MESSAGE_TOOL_NAME = "message";
const EXPLICIT_MESSAGE_ROUTE_KEYS = ["channel", "target", "to", "channelId", "provider"];
const DRY_RUN_DELIVERY_STATUS = "dry_run";
const SENT_DELIVERY_STATUS = "sent";
const RESULT_ENVELOPE_KEYS = [
"details",
"payload",
"result",
"results",
"sendResult",
"toolResult",
];
function argsRecordForToolCall(context: AfterToolCallContext): Record<string, unknown> {
if (context.args && typeof context.args === "object" && !Array.isArray(context.args)) {
@@ -15,27 +30,168 @@ function argsRecordForToolCall(context: AfterToolCallContext): Record<string, un
: {};
}
function hasStringValue(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) {
return true;
}
return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value));
}
function normalizeStatus(value: unknown): string | undefined {
return typeof value === "string" ? value.trim().toLowerCase() : undefined;
}
function parseJsonRecord(value: string): Record<string, unknown> | undefined {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function recordHasDeliveredMessageId(record: Record<string, unknown>): boolean {
if (hasStringValue(record.messageId)) {
return true;
}
const receipt = record.receipt;
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
return false;
}
const receiptRecord = receipt as Record<string, unknown>;
return (
hasStringValue(receiptRecord.primaryPlatformMessageId) ||
(Array.isArray(receiptRecord.platformMessageIds) &&
receiptRecord.platformMessageIds.some((value) => hasStringValue(value)))
);
}
function deliveryEnvelopeIndicatesDryRun(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesDryRun(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (
record.dryRun === true ||
normalizeStatus(record.deliveryStatus) === DRY_RUN_DELIVERY_STATUS
) {
return true;
}
const content = record.content;
if (Array.isArray(content)) {
for (const item of content) {
if (deliveryEnvelopeIndicatesDryRun(item, depth + 1)) {
return true;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const text = (item as Record<string, unknown>).text;
if (typeof text === "string") {
const parsed = parseJsonRecord(text);
if (parsed && deliveryEnvelopeIndicatesDryRun(parsed, depth + 1)) {
return true;
}
}
}
}
}
return RESULT_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesDryRun(record[key], depth + 1),
);
}
function deliveryEnvelopeIndicatesDelivered(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesDelivered(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (
normalizeStatus(record.deliveryStatus) === SENT_DELIVERY_STATUS ||
recordHasDeliveredMessageId(record)
) {
return true;
}
const content = record.content;
if (Array.isArray(content)) {
for (const item of content) {
if (deliveryEnvelopeIndicatesDelivered(item, depth + 1)) {
return true;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const text = (item as Record<string, unknown>).text;
if (typeof text === "string") {
const parsed = parseJsonRecord(text);
if (parsed && deliveryEnvelopeIndicatesDelivered(parsed, depth + 1)) {
return true;
}
}
}
}
}
return RESULT_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesDelivered(record[key], depth + 1),
);
}
/**
* Determines whether a `message.send` tool call delivered a visible source reply
* in message-tool-only delivery mode. Only implicit-route, non-dry-run,
* delivered sends qualify; explicit routes and errors are not source replies.
* Determines whether a `message.send` tool call should end the turn in
* message-tool-only delivery mode. Only implicit-route, non-dry-run, delivered
* sends qualify; explicit routes and errors keep the model loop alive.
*/
export function isDeliveredMessageToolOnlySourceReply(params: {
export function shouldTerminateAfterMessageToolOnlySend(params: {
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
context: AfterToolCallContext;
hookResult?: AfterToolCallResult;
}): boolean {
return isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
toolName: params.context.toolCall.name,
args: argsRecordForToolCall(params.context),
result: params.context.result,
hookResult: params.hookResult,
isError: params.hookResult?.isError ?? params.context.isError,
});
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
return false;
}
const toolName = normalizeToolName(params.context.toolCall.name);
if (toolName !== MESSAGE_TOOL_NAME) {
return false;
}
const args = argsRecordForToolCall(params.context);
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
return false;
}
const isError = params.hookResult?.isError ?? params.context.isError;
if (isError || isToolResultError(params.context.result)) {
return false;
}
if (
args.dryRun === true ||
deliveryEnvelopeIndicatesDryRun(params.context.result) ||
deliveryEnvelopeIndicatesDryRun(params.hookResult)
) {
return false;
}
if (
!deliveryEnvelopeIndicatesDelivered(params.context.result) &&
!deliveryEnvelopeIndicatesDelivered(params.hookResult)
) {
return false;
}
return true;
}
/** Installs an after-tool hook that records source reply delivery evidence. */
/** Installs an after-tool hook that terminates the turn after a qualifying message send. */
export function installMessageToolOnlyTerminalHook(params: {
agent: Agent;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
@@ -48,14 +204,14 @@ export function installMessageToolOnlyTerminalHook(params: {
params.agent.afterToolCall = async (context, signal) => {
const hookResult = await previousAfterToolCall?.(context, signal);
if (
isDeliveredMessageToolOnlySourceReply({
shouldTerminateAfterMessageToolOnlySend({
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
context,
hookResult,
})
) {
params.onDeliveredSourceReply?.();
return hookResult;
return { ...hookResult, terminate: true };
}
return hookResult;
};

View File

@@ -199,26 +199,6 @@ describe("buildEmbeddedRunPayloads", () => {
expectNoPayloadTextContaining(payloads, "LLM request rejected");
});
it("uses structured provider details for model-not-found reply payloads", () => {
const payloads = buildPayloads({
lastAssistant: makeAssistant({
stopReason: "error",
errorMessage: "400 Param Incorrect",
errorCode: "400",
errorBody:
'{"code":"400","message":"Param Incorrect","param":"Not supported model some-model-id"}',
content: [],
}),
});
expectSinglePayloadSummary(payloads, {
text: "The selected model was not found by the provider. Check the model id or choose a different model.",
isError: true,
});
expectNoPayloadTextContaining(payloads, "some-model-id");
expectNoPayloadTextContaining(payloads, "Param Incorrect");
});
it("suppresses escaped structured provider error messages in user-facing reply payloads", () => {
const rawError =
'{"type":"error","error":{"type":"invalid_request_error","message":"SECRET\\nCANARY_69737"}}';

View File

@@ -261,20 +261,6 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
});
});
it("suppresses terminal assistant text after direct message-tool source replies", () => {
const payloads = buildPayloads({
assistantTexts: ["ordinary final should stay private"],
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
sourceReplyDeliveryMode: "message_tool_only",
sessionKey: "agent:main",
agentId: "main",
runId: "run-1",
});
expect(payloads).toEqual([]);
});
it("preserves rich-only internal message-tool source replies", () => {
const presentation = {
blocks: [

View File

@@ -242,7 +242,6 @@ export function buildEmbeddedRunPayloads(params: {
suppressToolErrorWarnings?: boolean | (() => boolean | undefined);
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
agentId?: string;
@@ -311,15 +310,10 @@ export function buildEmbeddedRunPayloads(params: {
});
});
const hasSourceReplyPayload = replyItems.length > sourceReplyStartIndex;
const deliveredSourceReplyViaMessageTool =
params.sourceReplyDeliveryMode === "message_tool_only" &&
params.didDeliverSourceReplyViaMessageTool === true;
const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts =
params.didSendDeterministicApprovalPrompt === true ||
hasSourceReplyPayload ||
deliveredSourceReplyViaMessageTool;
params.didSendDeterministicApprovalPrompt === true || hasSourceReplyPayload;
const nonEmptyAssistantTexts = params.assistantTexts.filter((text) => text.trim().length > 0);
const currentAssistant = params.currentAssistant ?? undefined;
const assistantForPayload =

View File

@@ -1,139 +0,0 @@
// Provider thinking replay repair tests cover durable transcript cleanup after
// Anthropic/Bedrock proves a signed thinking block invalid.
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import { describe, expect, it } from "vitest";
import { repairRejectedThinkingReplayInSessionManager } from "./thinking-replay-repair.js";
type AppendMessage = Parameters<SessionManager["appendMessage"]>[0];
function asAppendMessage(message: unknown): AppendMessage {
return message as AppendMessage;
}
function branchMessages(sessionManager: SessionManager): AgentMessage[] {
return sessionManager
.getBranch()
.filter((entry) => entry.type === "message")
.map((entry) => entry.message);
}
function branchAssistantContents(sessionManager: SessionManager): unknown[] {
return branchMessages(sessionManager)
.filter((message): message is Extract<AgentMessage, { role: "assistant" }> => {
return message.role === "assistant";
})
.map((message) => message.content);
}
describe("repairRejectedThinkingReplayInSessionManager", () => {
it("strips thinking blocks from active-branch assistant messages and preserves visible content", () => {
const sessionManager = SessionManager.inMemory();
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
sessionManager.appendMessage(
asAppendMessage({
role: "assistant",
content: [
{ type: "thinking", thinking: "private", thinkingSignature: "sig_bad" },
{ type: "text", text: "visible answer" },
],
timestamp: 2,
}),
);
sessionManager.appendMessage(
asAppendMessage({ role: "user", content: "second", timestamp: 3 }),
);
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
expect(result).toMatchObject({ repaired: true, repairedCount: 1 });
expect(branchMessages(sessionManager).map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
]);
expect(branchAssistantContents(sessionManager)).toEqual([
[{ type: "text", text: "visible answer" }],
]);
});
it("keeps thinking-only assistant turns as omitted-reasoning placeholders", () => {
const sessionManager = SessionManager.inMemory();
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
sessionManager.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "thinking", thinking: "private", thinkingSignature: "sig_bad" }],
timestamp: 2,
}),
);
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
expect(result).toMatchObject({ repaired: true, repairedCount: 1 });
expect(branchAssistantContents(sessionManager)).toEqual([
[{ type: "text", text: "[assistant reasoning omitted]" }],
]);
});
it("preserves downstream branch suffix entries after rewriting the first repaired assistant", () => {
const sessionManager = SessionManager.inMemory();
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
sessionManager.appendMessage(
asAppendMessage({
role: "assistant",
content: [
{ type: "thinking", thinking: "private", thinkingSignature: "sig_bad" },
{ type: "text", text: "first answer" },
],
timestamp: 2,
}),
);
sessionManager.appendMessage(
asAppendMessage({ role: "user", content: "follow-up", timestamp: 3 }),
);
sessionManager.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "text", text: "follow-up answer" }],
timestamp: 4,
}),
);
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
expect(result).toMatchObject({ repaired: true, repairedCount: 1 });
expect(branchMessages(sessionManager).map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
"assistant",
]);
expect(branchAssistantContents(sessionManager)).toEqual([
[{ type: "text", text: "first answer" }],
[{ type: "text", text: "follow-up answer" }],
]);
});
it("does not rewrite sessions without active-branch thinking blocks", () => {
const sessionManager = SessionManager.inMemory();
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
sessionManager.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "text", text: "visible answer" }],
timestamp: 2,
}),
);
const beforeLeafId = sessionManager.getLeafId();
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
expect(result).toMatchObject({
repaired: false,
repairedCount: 0,
reason: "no thinking blocks on active branch",
});
expect(sessionManager.getLeafId()).toBe(beforeLeafId);
});
});

View File

@@ -1,70 +0,0 @@
/**
* Repairs persisted signed-thinking replay state after provider-confirmed rejection.
*/
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { AgentMessage } from "../runtime/index.js";
import { log } from "./logger.js";
import { stripThinkingBlocksFromMessage } from "./thinking.js";
import { rewriteTranscriptEntriesInSessionManager } from "./transcript-rewrite.js";
type RewritableSessionManager = Parameters<
typeof rewriteTranscriptEntriesInSessionManager
>[0]["sessionManager"];
export function repairRejectedThinkingReplayInSessionManager(params: {
sessionManager: RewritableSessionManager;
sessionFile?: string;
sessionId?: string;
sessionKey?: string;
agentId?: string;
}): { repaired: boolean; repairedCount: number; reason?: string } {
const replacements: Array<{ entryId: string; message: AgentMessage }> = [];
for (const entry of params.sessionManager.getBranch()) {
if (entry.type !== "message") {
continue;
}
const replacement = stripThinkingBlocksFromMessage(entry.message);
if (replacement === entry.message) {
continue;
}
replacements.push({ entryId: entry.id, message: replacement });
}
if (replacements.length === 0) {
return {
repaired: false,
repairedCount: 0,
reason: "no thinking blocks on active branch",
};
}
const rewriteResult = rewriteTranscriptEntriesInSessionManager({
sessionManager: params.sessionManager,
replacements,
});
if (!rewriteResult.changed) {
return {
repaired: false,
repairedCount: 0,
reason: rewriteResult.reason,
};
}
if (params.sessionFile) {
emitSessionTranscriptUpdate({
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
});
}
log.warn(
`[session-recovery] stripped thinking blocks after provider rejected replay: ` +
`repaired=${rewriteResult.rewrittenEntries} sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
);
return {
repaired: true,
repairedCount: rewriteResult.rewrittenEntries,
reason: rewriteResult.reason,
};
}

View File

@@ -2,7 +2,7 @@
// recovery behavior for provider transcripts and active assistant turns.
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { castAgentMessage, castAgentMessages } from "../test-helpers/agent-message-fixtures.js";
import {
OMITTED_ASSISTANT_REASONING_TEXT,
@@ -594,139 +594,6 @@ describe("wrapAnthropicStreamWithRecovery", () => {
expect(retryMessage.content).toEqual([{ type: "text", text: "visible answer" }]);
});
it("notifies recovery only after a rejected request retry succeeds", async () => {
let callCount = 0;
const recovered = vi.fn();
const finalMessage = createTestAssistantMessage({
content: [{ type: "text", text: "recovered" }],
stopReason: "stop",
});
const originalMessages = castAgentMessages([
{
role: "assistant",
content: [
{ type: "thinking", thinking: "secret", thinkingSignature: "sig" },
{ type: "text", text: "visible answer" },
],
},
]);
const wrapped = wrapAnthropicStreamWithRecovery(
(() => {
callCount += 1;
if (callCount === 1) {
return Promise.reject(anthropicThinkingError);
}
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: finalMessage });
stream.end();
});
return stream;
}) as Parameters<typeof wrapAnthropicStreamWithRecovery>[0],
{ id: "test-session", onRecoveredAnthropicThinking: recovered },
);
const response = (await wrapped(
{} as never,
{
messages: originalMessages,
} as never,
{} as never,
)) as { result: () => Promise<unknown> } & AsyncIterable<unknown>;
for await (const event of response) {
void event;
// Drain the retry stream before reading result().
}
await expect(response.result()).resolves.toEqual(finalMessage);
expect(callCount).toBe(2);
expect(recovered).toHaveBeenCalledTimes(1);
expect(recovered).toHaveBeenCalledWith({
originalMessages,
cleanedMessages: [
{
...originalMessages[0],
content: [{ type: "text", text: "visible answer" }],
},
],
});
});
it("does not notify recovery when the stripped-thinking retry also fails", async () => {
const recovered = vi.fn();
let callCount = 0;
const retryError = new Error("retry failed");
const wrapped = wrapAnthropicStreamWithRecovery(
(() => {
callCount += 1;
return Promise.reject(callCount === 1 ? anthropicThinkingError : retryError);
}) as Parameters<typeof wrapAnthropicStreamWithRecovery>[0],
{ id: "test-session", onRecoveredAnthropicThinking: recovered },
);
await expect(
wrapped(
{} as never,
{
messages: castAgentMessages([
{
role: "assistant",
content: [{ type: "thinking", thinking: "secret", thinkingSignature: "sig" }],
},
]),
} as never,
{} as never,
),
).rejects.toBe(retryError);
expect(recovered).not.toHaveBeenCalled();
});
it("does not notify recovery when the stripped-thinking retry resolves to a stream error", async () => {
const recovered = vi.fn();
let callCount = 0;
const errorMessage = createTestStreamErrorMessage("retry stream failed");
const wrapped = wrapAnthropicStreamWithRecovery(
(() => {
callCount += 1;
if (callCount === 1) {
return Promise.reject(anthropicThinkingError);
}
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "error",
reason: "error",
error: errorMessage,
});
stream.end();
});
return stream;
}) as Parameters<typeof wrapAnthropicStreamWithRecovery>[0],
{ id: "test-session", onRecoveredAnthropicThinking: recovered },
);
const response = (await wrapped(
{} as never,
{
messages: castAgentMessages([
{
role: "assistant",
content: [{ type: "thinking", thinking: "secret", thinkingSignature: "sig" }],
},
]),
} as never,
{} as never,
)) as { result: () => Promise<unknown> } & AsyncIterable<unknown>;
for await (const event of response) {
void event;
// Drain the retry stream before reading result().
}
await expect(response.result()).resolves.toEqual(errorMessage);
expect(callCount).toBe(2);
expect(recovered).not.toHaveBeenCalled();
});
it("retries Bedrock-style invalid thinking signature errors", async () => {
let callCount = 0;
const bedrockThinkingError = new Error(

View File

@@ -10,15 +10,7 @@ import { log } from "./logger.js";
type AssistantContentBlock = Extract<AgentMessage, { role: "assistant" }>["content"][number];
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
type RecoveryAssessment = "valid" | "incomplete-thinking" | "incomplete-text";
export type AnthropicThinkingRecovery = {
originalMessages: AgentMessage[];
cleanedMessages: AgentMessage[];
};
type RecoverySessionMeta = {
id: string;
recoveredAnthropicThinking?: boolean;
onRecoveredAnthropicThinking?: (recovery: AnthropicThinkingRecovery) => void | Promise<void>;
};
type RecoverySessionMeta = { id: string; recoveredAnthropicThinking?: boolean };
const THINKING_BLOCK_ERROR_PATTERN =
/(?:thinking|redacted_thinking).*?(?:cannot be modified|signature|invalid|missing|empty|blank)|(?:signature|invalid|missing|empty|blank).*?(?:thinking|redacted_thinking)/i;
@@ -425,31 +417,26 @@ export function shouldPreserveLatestAssistantThinking(messages: AgentMessage[]):
return shouldPreserveCurrentToolTurnReasoning(messages, latestAssistantIndex, latestUserIndex);
}
export function stripThinkingBlocksFromMessage(message: AgentMessage): AgentMessage {
if (!isAssistantMessageWithContent(message)) {
return message;
}
const nextContent = message.content.filter((block) => !isThinkingBlock(block));
if (nextContent.length === message.content.length) {
return message;
}
return {
...message,
content: nextContent.length > 0 ? nextContent : buildOmittedAssistantReasoningContent(),
};
}
function stripAllThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
let touched = false;
const out: AgentMessage[] = [];
for (const message of messages) {
const stripped = stripThinkingBlocksFromMessage(message);
if (stripped === message) {
out.push(stripped);
if (!isAssistantMessageWithContent(message)) {
out.push(message);
continue;
}
const nextContent = message.content.filter((block) => !isThinkingBlock(block));
if (nextContent.length === message.content.length) {
out.push(message);
continue;
}
touched = true;
out.push(stripped);
out.push({
...message,
content: nextContent.length > 0 ? nextContent : buildOmittedAssistantReasoningContent(),
});
}
return touched ? out : messages;
}
@@ -605,58 +592,9 @@ function getAssistantMessageErrorText(
return typeof errorMessage === "string" ? errorMessage : "";
}
async function notifyRecoveredAnthropicThinking(
sessionMeta: RecoverySessionMeta,
recovery: AnthropicThinkingRecovery,
): Promise<void> {
try {
await sessionMeta.onRecoveredAnthropicThinking?.(recovery);
} catch (error: unknown) {
log.warn(
`[session-recovery] Anthropic thinking transcript repair hook failed: sessionId=${sessionMeta.id} error=${formatErrorMessage(error)}`,
);
}
}
function isSuccessfulRecoveryRetryResult(message: AssistantMessage | undefined): boolean {
if (!message) {
return false;
}
return message.stopReason !== "error" && message.stopReason !== "aborted";
}
function wrapRetryStreamWithRecoveryNotification(
retryStream: ReturnType<StreamFn>,
notify: () => Promise<void>,
): ReturnType<StreamFn> {
if (retryStream instanceof Promise) {
return retryStream.then((resolved) =>
wrapRetryStreamWithRecoveryNotification(resolved as ReturnType<StreamFn>, notify),
) as ReturnType<StreamFn>;
}
const streamWithResult = retryStream as unknown as {
result?: () => Promise<AssistantMessage>;
};
if (typeof streamWithResult.result !== "function") {
return retryStream;
}
const result = streamWithResult.result.bind(streamWithResult);
let notified = false;
streamWithResult.result = async () => {
const message = await result();
if (!notified && isSuccessfulRecoveryRetryResult(message)) {
notified = true;
await notify();
}
return message;
};
return retryStream;
}
async function retryStreamWithoutThinking(
outer: ReturnType<typeof createAssistantMessageEventStream>,
retry: () => ReturnType<StreamFn>,
notify: () => Promise<void>,
): Promise<AssistantMessage> {
const retryStream = retry();
const resolvedRetry = retryStream instanceof Promise ? await retryStream : retryStream;
@@ -664,9 +602,6 @@ async function retryStreamWithoutThinking(
outer.push(chunk as Parameters<typeof outer.push>[0]);
}
const result = await (resolvedRetry as { result?: () => Promise<AssistantMessage> }).result?.();
if (isSuccessfulRecoveryRetryResult(result)) {
await notify();
}
return result as AssistantMessage;
}
@@ -675,7 +610,6 @@ async function pumpStreamWithRecovery(
stream: ReturnType<StreamFn>,
sessionMeta: RecoverySessionMeta,
retry: () => ReturnType<StreamFn>,
notify: () => Promise<void>,
): Promise<AssistantMessage> {
let yieldedOutput = false;
try {
@@ -697,7 +631,7 @@ async function pumpStreamWithRecovery(
log.warn(
`[session-recovery] Anthropic thinking stream error; retrying once without thinking blocks: sessionId=${sessionMeta.id}`,
);
return retryStreamWithoutThinking(outer, retry, notify);
return retryStreamWithoutThinking(outer, retry);
}
}
} else {
@@ -721,7 +655,7 @@ async function pumpStreamWithRecovery(
log.warn(
`[session-recovery] Anthropic thinking error during stream; retrying once without thinking blocks: sessionId=${sessionMeta.id}`,
);
return retryStreamWithoutThinking(outer, retry, notify);
return retryStreamWithoutThinking(outer, retry);
}
}
@@ -730,10 +664,7 @@ export function wrapAnthropicStreamWithRecovery(
sessionMeta: RecoverySessionMeta,
): StreamFn {
return (model, context, options) => {
const requestMeta: RecoverySessionMeta = {
id: sessionMeta.id,
onRecoveredAnthropicThinking: sessionMeta.onRecoveredAnthropicThinking,
};
const requestMeta: RecoverySessionMeta = { id: sessionMeta.id };
const contextRecord = context as unknown as { messages?: unknown };
const originalMessages = Array.isArray(contextRecord.messages)
? (contextRecord.messages as AgentMessage[])
@@ -746,11 +677,6 @@ export function wrapAnthropicStreamWithRecovery(
} as typeof context;
return innerStreamFn(model, nextContext, options);
};
const notify = () =>
notifyRecoveredAnthropicThinking(requestMeta, {
originalMessages,
cleanedMessages: stripAllThinkingBlocks(originalMessages),
});
const stream = innerStreamFn(model, context, options);
if (stream instanceof Promise) {
@@ -762,19 +688,15 @@ export function wrapAnthropicStreamWithRecovery(
log.warn(
`[session-recovery] Anthropic thinking request rejected; retrying once without thinking blocks: sessionId=${requestMeta.id}`,
);
return wrapRetryStreamWithRecoveryNotification(retry(), notify);
return retry();
}) as ReturnType<StreamFn>;
}
const outer = createAssistantMessageEventStream();
const finalResultPromise = pumpStreamWithRecovery(
outer,
stream,
requestMeta,
retry,
notify,
).finally(() => {
outer.end();
});
const finalResultPromise = pumpStreamWithRecovery(outer, stream, requestMeta, retry).finally(
() => {
outer.end();
},
);
outer.result = () => finalResultPromise;
return outer as unknown as ReturnType<StreamFn>;
};

View File

@@ -157,15 +157,6 @@ function shouldSuppressDeterministicApprovalOutput(
return state.deterministicApprovalPromptPending || state.deterministicApprovalPromptSent;
}
function hasMessageToolOnlySourceDelivery(ctx: EmbeddedAgentSubscribeContext): boolean {
return (
ctx.params.sourceReplyDeliveryMode === "message_tool_only" &&
(ctx.state.messageToolOnlySourceReplyDelivered ||
ctx.params.hasDeliveredMessageToolOnlySourceReply?.() === true ||
(ctx.state.messagingToolSourceReplyPayloads?.length ?? 0) > 0)
);
}
function appendBlockReplyChunk(ctx: EmbeddedAgentSubscribeContext, chunk: string) {
if (ctx.blockChunker) {
ctx.blockChunker.append(chunk);
@@ -589,7 +580,6 @@ export function handleMessageUpdate(
return;
}
const suppressDeterministicApprovalOutput = shouldSuppressDeterministicApprovalOutput(ctx.state);
const suppressMessageToolOnlySourceReplyOutput = hasMessageToolOnlySourceDelivery(ctx);
const assistantEvent = evt.assistantMessageEvent;
const assistantPhase = resolveAssistantMessagePhase(msg);
@@ -607,10 +597,7 @@ export function handleMessageUpdate(
}
if (evtType === "thinking_start" || evtType === "thinking_delta" || evtType === "thinking_end") {
if (
!suppressMessageToolOnlySourceReplyOutput &&
(evtType === "thinking_start" || evtType === "thinking_delta")
) {
if (evtType === "thinking_start" || evtType === "thinking_delta") {
openReasoningStream(ctx);
}
const thinkingDelta = typeof assistantRecord?.delta === "string" ? assistantRecord.delta : "";
@@ -625,12 +612,12 @@ export function handleMessageUpdate(
delta: thinkingDelta,
content: thinkingContent,
});
if (!suppressMessageToolOnlySourceReplyOutput && ctx.state.streamReasoning) {
if (ctx.state.streamReasoning) {
// Prefer full partial-message thinking when available; fall back to event payloads.
const partialThinking = extractAssistantThinking(msg);
ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta);
}
if (!suppressMessageToolOnlySourceReplyOutput && evtType === "thinking_end") {
if (evtType === "thinking_end") {
if (!ctx.state.reasoningStreamOpen) {
openReasoningStream(ctx);
}
@@ -703,7 +690,7 @@ export function handleMessageUpdate(
}
}
if (!suppressMessageToolOnlySourceReplyOutput && ctx.state.streamReasoning) {
if (ctx.state.streamReasoning) {
// Handle partial <think> tags: stream whatever reasoning is visible so far.
ctx.emitReasoningStream(extractThinkingFromTaggedStream(ctx.state.deltaBuffer));
}
@@ -777,19 +764,11 @@ export function handleMessageUpdate(
});
}
if (next) {
if (
!suppressMessageToolOnlySourceReplyOutput &&
!wasThinking &&
ctx.state.partialBlockState.thinking
) {
if (!wasThinking && ctx.state.partialBlockState.thinking) {
openReasoningStream(ctx);
}
// Detect when thinking block ends (</think> tag processed)
if (
!suppressMessageToolOnlySourceReplyOutput &&
wasThinking &&
!ctx.state.partialBlockState.thinking
) {
if (wasThinking && !ctx.state.partialBlockState.thinking) {
emitReasoningEnd(ctx);
}
const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null;
@@ -843,11 +822,7 @@ export function handleMessageUpdate(
ctx.state.lastStreamedAssistant = nextRawStreamText;
ctx.state.lastStreamedAssistantCleaned = cleanedText;
if (
ctx.params.silentExpected ||
suppressDeterministicApprovalOutput ||
suppressMessageToolOnlySourceReplyOutput
) {
if (ctx.params.silentExpected || suppressDeterministicApprovalOutput) {
shouldEmit = false;
}
@@ -869,7 +844,6 @@ export function handleMessageUpdate(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
!suppressMessageToolOnlySourceReplyOutput &&
ctx.params.onBlockReply &&
ctx.blockChunking &&
ctx.state.blockReplyBreak === "text_end"
@@ -880,7 +854,6 @@ export function handleMessageUpdate(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
!suppressMessageToolOnlySourceReplyOutput &&
evtType === "text_end" &&
ctx.state.blockReplyBreak === "text_end"
) {
@@ -907,7 +880,6 @@ export function handleMessageEnd(
const assistantPhase = resolveAssistantMessagePhase(assistantMessage);
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(assistantMessage);
const suppressDeterministicApprovalOutput = shouldSuppressDeterministicApprovalOutput(ctx.state);
const suppressMessageToolOnlySourceReplyOutput = hasMessageToolOnlySourceDelivery(ctx);
ctx.noteLastAssistant(assistantMessage);
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
ctx.commitAssistantUsage();
@@ -993,7 +965,6 @@ export function handleMessageEnd(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
!suppressMessageToolOnlySourceReplyOutput &&
(cleanedText || hasMedia) &&
(!ctx.state.emittedAssistantUpdate ||
shouldReplaceFinalStream ||
@@ -1027,7 +998,6 @@ export function handleMessageEnd(
const shouldEmitReasoning = Boolean(
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
!suppressMessageToolOnlySourceReplyOutput &&
ctx.state.includeReasoning &&
trimmedReasoning &&
onBlockReply &&
@@ -1083,7 +1053,6 @@ export function handleMessageEnd(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
!suppressMessageToolOnlySourceReplyOutput &&
text &&
onBlockReply &&
(ctx.state.blockReplyBreak === "message_end" ||
@@ -1163,21 +1132,11 @@ export function handleMessageEnd(
if (!shouldEmitReasoningBeforeAnswer) {
maybeEmitReasoning();
}
if (
!ctx.params.silentExpected &&
!suppressMessageToolOnlySourceReplyOutput &&
ctx.state.streamReasoning &&
rawThinking
) {
if (!ctx.params.silentExpected && ctx.state.streamReasoning && rawThinking) {
ctx.emitReasoningStream(rawThinking);
}
if (
!ctx.params.silentExpected &&
!suppressMessageToolOnlySourceReplyOutput &&
ctx.state.blockReplyBreak === "text_end" &&
onBlockReply
) {
if (!ctx.params.silentExpected && ctx.state.blockReplyBreak === "text_end" && onBlockReply) {
emitSplitResultAsBlockReply(ctx.consumeReplyDirectives("", { final: true }));
}

View File

@@ -40,8 +40,6 @@ function createMockContext(overrides?: {
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],
messagingToolSourceReplyPayloads: [],
messageToolOnlySourceReplyDelivered: false,
messagingToolSentTargets: [],
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,

View File

@@ -77,7 +77,6 @@ function createTestContext(): {
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],
messagingToolSourceReplyPayloads: [],
messageToolOnlySourceReplyDelivered: false,
messagingToolSentTargets: [],
successfulCronAdds: 0,
deterministicApprovalPromptSent: false,

View File

@@ -39,7 +39,6 @@ import type { ApplyPatchSummary } from "./apply-patch.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { sanitizeForConsole } from "./console-sanitize.js";
import { normalizeTextForComparison } from "./embedded-agent-helpers.js";
import { isDeliveredMessageToolOnlySourceReplyResult } from "./embedded-agent-message-tool-source-reply.js";
import { isMessagingTool, isMessagingToolSendAction } from "./embedded-agent-messaging.js";
import type { MessagingToolSourceReplyPayload } from "./embedded-agent-messaging.types.js";
import { mergeEmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
@@ -1276,17 +1275,6 @@ export async function handleToolExecutionEnd(
ctx.state.messagingToolSentMediaUrls.push(...committedMediaUrls);
ctx.trimMessagingToolSent();
}
if (
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: ctx.params.sourceReplyDeliveryMode,
toolName,
args: startArgs,
result,
isError: isToolError,
})
) {
ctx.state.messageToolOnlySourceReplyDelivered = true;
}
const sourceReplyPayload = extractMessagingToolSourceReplyPayload(result);
if (sourceReplyPayload) {
ctx.state.messagingToolSourceReplyPayloads.push(sourceReplyPayload);

View File

@@ -161,7 +161,6 @@ export type EmbeddedAgentSubscribeState = {
heartbeatToolResponse?: HeartbeatToolResponse;
messagingToolSentMediaUrls: string[];
messagingToolSourceReplyPayloads: MessagingToolSourceReplyPayload[];
messageToolOnlySourceReplyDelivered: boolean;
pendingMessagingTexts: Map<string, string>;
pendingMessagingTargets: Map<string, MessagingToolSend>;
successfulCronAdds: number;
@@ -277,7 +276,6 @@ type ToolHandlerParams = Pick<
| "agentId"
| "toolResultFormat"
| "toolProgressDetail"
| "sourceReplyDeliveryMode"
>;
type ToolHandlerState = Pick<
@@ -304,7 +302,6 @@ type ToolHandlerState = Pick<
| "messagingToolSentTextsNormalized"
| "messagingToolSentMediaUrls"
| "messagingToolSourceReplyPayloads"
| "messageToolOnlySourceReplyDelivered"
| "messagingToolSentTargets"
| "heartbeatToolResponse"
| "successfulCronAdds"

View File

@@ -10,36 +10,18 @@ import {
} from "./embedded-agent-subscribe.e2e-harness.js";
import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js";
function createBlockReplyHarness(
blockReplyBreak: "message_end" | "text_end",
options: {
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
hasDeliveredMessageToolOnlySourceReply?: () => boolean;
reasoningMode?: "off" | "on" | "stream";
onReasoningEnd?: () => void;
onReasoningStream?: (payload: { text?: string }) => void;
} = {},
) {
function createBlockReplyHarness(blockReplyBreak: "message_end" | "text_end") {
// Harness exposes both emitted block replies and subscription state so tests
// can distinguish suppression from missing delivery tracking.
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
const onPartialReply = vi.fn();
const onAgentEvent = vi.fn();
const subscription = subscribeEmbeddedAgentSession({
session,
runId: "run",
onBlockReply,
onPartialReply,
onAgentEvent,
onReasoningEnd: options.onReasoningEnd,
onReasoningStream: options.onReasoningStream,
blockReplyBreak,
reasoningMode: options.reasoningMode,
sourceReplyDeliveryMode: options.sourceReplyDeliveryMode,
hasDeliveredMessageToolOnlySourceReply: options.hasDeliveredMessageToolOnlySourceReply,
});
return { emit, onAgentEvent, onBlockReply, onPartialReply, subscription };
return { emit, onBlockReply, subscription };
}
async function emitMessageToolLifecycle(params: {
@@ -47,7 +29,6 @@ async function emitMessageToolLifecycle(params: {
toolCallId: string;
message: string;
media?: string;
to?: string | null;
result: unknown;
}) {
// Message tool sends are modeled as normal tool start/end events because the
@@ -56,12 +37,7 @@ async function emitMessageToolLifecycle(params: {
type: "tool_execution_start",
toolName: "message",
toolCallId: params.toolCallId,
args: {
action: "send",
...(params.to === null ? {} : { to: params.to ?? "+1555" }),
message: params.message,
media: params.media,
},
args: { action: "send", to: "+1555", message: params.message, media: params.media },
});
// Wait for async handler to complete.
await Promise.resolve();
@@ -110,230 +86,6 @@ describe("subscribeEmbeddedAgentSession", () => {
expect(onBlockReply).not.toHaveBeenCalled();
});
it("suppresses later message_end block replies after message-tool-only delivery", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-continue",
message: "Starting the requested work.",
to: null,
result: { details: { deliveryStatus: "sent" } },
});
emitAssistantMessageEnd(emit, "Done.");
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("suppresses later text_end block replies after message-tool-only delivery", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("text_end", {
sourceReplyDeliveryMode: "message_tool_only",
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-text-end-continue",
message: "Starting the requested work.",
to: null,
result: { details: { deliveryStatus: "sent" } },
});
emitAssistantTextEndBlock(emit, "Done.");
await Promise.resolve();
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("does not suppress source replies after explicit routed message-tool-only sends", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-routed",
message: "Sent somewhere else.",
to: "+1555",
result: { details: { deliveryStatus: "sent" } },
});
emitAssistantMessageEnd(emit, "Reply to the current source.");
await vi.waitFor(() => {
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
});
it("does not suppress source replies after non-message messaging tools send", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
});
emit({
type: "tool_execution_start",
toolName: "sessions_send",
toolCallId: "tool-sessions-send",
args: { message: "Sent to a spawned session." },
});
await Promise.resolve();
emit({
type: "tool_execution_end",
toolName: "sessions_send",
toolCallId: "tool-sessions-send",
isError: false,
result: { details: { deliveryStatus: "sent" } },
});
emitAssistantMessageEnd(emit, "Reply to the current source.");
await vi.waitFor(() => {
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
});
it("preserves source-reply suppression across compaction retries", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-before-compaction",
message: "Starting the requested work.",
to: null,
result: { details: { deliveryStatus: "sent" } },
});
emit({ type: "compaction_end", willRetry: true, result: { summary: "compacted" } });
await Promise.resolve();
emitAssistantMessageEnd(emit, "Done after compaction.");
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("preserves internal source-reply payloads across compaction retries", async () => {
const { emit, subscription } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-internal-before-compaction",
message: "Visible terminal answer.",
result: {
details: {
status: "ok",
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: { text: "Visible terminal answer." },
},
},
});
emit({ type: "compaction_end", willRetry: true, result: { summary: "compacted" } });
await Promise.resolve();
expect(subscription.getMessagingToolSourceReplyPayloads()).toEqual([
{ text: "Visible terminal answer." },
]);
});
it("suppresses later assistant stream and partial replies after message-tool-only delivery", async () => {
const { emit, onAgentEvent, onPartialReply } = createBlockReplyHarness("text_end", {
sourceReplyDeliveryMode: "message_tool_only",
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-before-partial",
message: "Starting the requested work.",
to: null,
result: { details: { deliveryStatus: "sent" } },
});
emit({ type: "message_start", message: { role: "assistant" } });
emitAssistantTextDelta({ emit, delta: "Done." });
await Promise.resolve();
expect(onPartialReply).not.toHaveBeenCalled();
expect(onAgentEvent.mock.calls.some((call) => call[0]?.stream === "assistant")).toBe(false);
});
it("suppresses later reasoning streams after message-tool-only delivery", async () => {
const onReasoningStream = vi.fn();
const onReasoningEnd = vi.fn();
const { emit } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
reasoningMode: "stream",
onReasoningEnd,
onReasoningStream,
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-before-reasoning",
message: "Starting the requested work.",
to: null,
result: { details: { deliveryStatus: "sent" } },
});
emit({
type: "message_update",
message: { role: "assistant", content: [{ type: "thinking", thinking: "private" }] },
assistantMessageEvent: { type: "thinking_delta", delta: "private" },
});
emit({
type: "message_update",
message: { role: "assistant", content: [{ type: "thinking", thinking: "private" }] },
assistantMessageEvent: { type: "thinking_end" },
});
await Promise.resolve();
expect(onReasoningStream).not.toHaveBeenCalled();
expect(onReasoningEnd).not.toHaveBeenCalled();
});
it("suppresses later tagged reasoning streams after message-tool-only delivery", async () => {
const onReasoningStream = vi.fn();
const onReasoningEnd = vi.fn();
const { emit } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
reasoningMode: "stream",
onReasoningEnd,
onReasoningStream,
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-before-tagged-reasoning",
message: "Starting the requested work.",
to: null,
result: { details: { deliveryStatus: "sent" } },
});
emit({ type: "message_start", message: { role: "assistant" } });
emitAssistantTextDelta({ emit, delta: "<think>private reasoning" });
emitAssistantTextDelta({ emit, delta: "</think>Done." });
await Promise.resolve();
expect(onReasoningStream).not.toHaveBeenCalled();
expect(onReasoningEnd).not.toHaveBeenCalled();
});
it("uses runner-level delivery evidence when tool result details were rewritten", async () => {
const { emit, onBlockReply } = createBlockReplyHarness("message_end", {
sourceReplyDeliveryMode: "message_tool_only",
hasDeliveredMessageToolOnlySourceReply: () => true,
});
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-rewritten-result",
message: "Starting the requested work.",
to: null,
result: { details: { rewritten: true } },
});
emitAssistantMessageEnd(emit, "Done after rewritten tool result.");
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("tracks media-only message tool sends as messaging delivery", async () => {
const { emit, subscription } = createBlockReplyHarness("message_end");

View File

@@ -223,7 +223,6 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
heartbeatToolResponse: undefined,
messagingToolSentMediaUrls: [],
messagingToolSourceReplyPayloads: [],
messageToolOnlySourceReplyDelivered: false,
pendingMessagingTexts: new Map(),
pendingMessagingTargets: new Map(),
successfulCronAdds: 0,
@@ -960,11 +959,6 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
output += text.slice(lastIndex);
return output;
};
const hasMessageToolOnlySourceDelivery = () =>
params.sourceReplyDeliveryMode === "message_tool_only" &&
(state.messageToolOnlySourceReplyDelivered ||
params.hasDeliveredMessageToolOnlySourceReply?.() === true ||
messagingToolSourceReplyPayloads.length > 0);
const emitBlockChunk = (
text: string,
@@ -992,10 +986,6 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
state.lastDeliveredBlockReplyText = blockReplyText;
state.toolExecutionSinceLastBlockReply = false;
};
if (hasMessageToolOnlySourceDelivery()) {
markBlockReplyTextHandled();
return;
}
let chunk = blockReplyText;
let slicedPrefixReplay = false;
const lastDeliveredBlockReplyText = state.lastDeliveredBlockReplyText;
@@ -1204,6 +1194,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
messagingToolSentTextsNormalized.length = 0;
messagingToolSentTargets.length = 0;
messagingToolSentMediaUrls.length = 0;
messagingToolSourceReplyPayloads.length = 0;
pendingMessagingTexts.clear();
pendingMessagingTargets.clear();
state.successfulCronAdds = 0;

View File

@@ -41,8 +41,6 @@ export type SubscribeEmbeddedAgentSessionParams = {
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
/** Attempt-owned delivery proof for message-tool-only source replies. */
hasDeliveredMessageToolOnlySourceReply?: () => boolean;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onReasoningStream?: (payload: {
text?: string;

View File

@@ -649,40 +649,6 @@ describe("failover-error", () => {
).toBe("model_not_found");
});
it("uses structured OpenAI-compatible param detail for model-not-found 400s", () => {
const err = Object.assign(new Error("400 Param Incorrect"), {
status: 400,
code: "400",
param: "Not supported model some-model-id",
error: {
code: "400",
message: "Param Incorrect",
param: "Not supported model some-model-id",
},
});
expect(resolveFailoverReasonFromError(err)).toBe("model_not_found");
expect(describeFailoverError(err)).toMatchObject({
message: "400 Param Incorrect",
reason: "model_not_found",
status: 400,
code: "400",
});
});
it("keeps unsupported capability details classified as format", () => {
expect(
resolveFailoverReasonFromError({
status: 400,
message: "400 Param Incorrect",
error: {
message: "Param Incorrect",
param: "This model is not supported for tool calling.",
},
}),
).toBe("format");
});
it("treats HTTP 422 as format error", () => {
expect(
resolveFailoverReasonFromError({

View File

@@ -8,7 +8,6 @@ import { formatCliCommand } from "../cli/command-format.js";
import { readErrorName } from "../infra/errors.js";
import {
classifyFailoverSignal,
extractFailoverSignalDetails,
inferSignalStatus,
isUnclassifiedNoBodyHttpSignal,
type FailoverClassification,
@@ -238,26 +237,6 @@ function getProvider(err: unknown): string | undefined {
return findErrorProperty(err, readDirectProvider);
}
function readDirectErrorDetails(err: unknown): string[] | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const candidate = err as {
body?: unknown;
detail?: unknown;
error?: unknown;
errorBody?: unknown;
param?: unknown;
};
return extractFailoverSignalDetails(
candidate.param,
candidate.errorBody,
candidate.body,
candidate.detail,
candidate.error,
);
}
function readDirectErrorMessage(err: unknown): string | undefined {
if (err instanceof Error) {
return err.message || undefined;
@@ -292,7 +271,6 @@ function normalizeDirectErrorSignal(err: unknown): FailoverSignal {
errorType: readDirectErrorType(err),
message: message || undefined,
provider: readDirectProvider(err),
details: readDirectErrorDetails(err),
};
}
@@ -412,7 +390,6 @@ function normalizeErrorSignal(err: unknown, providerHint?: string): FailoverSign
errorType: getErrorType(err),
message: message || undefined,
provider: getProvider(err) ?? providerHint,
details: readDirectErrorDetails(err),
};
}

View File

@@ -35,10 +35,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
({ onlyPluginIds }: { onlyPluginIds?: readonly string[] }) =>
(onlyPluginIds ?? []).filter((pluginId) => pluginId === "openai"),
);
mocks.resolveActivatableProviderOwnerPluginIds.mockImplementation(
({ pluginIds }: { pluginIds: readonly string[] }) =>
pluginIds.filter((pluginId) => pluginId === "memory-core"),
);
mocks.resolveActivatableProviderOwnerPluginIds.mockReturnValue([]);
vi.resetModules();
({ ensureSelectedAgentHarnessPlugin } = await import("./runtime-plugin.js"));
});
@@ -65,7 +62,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["codex", "openai", "memory-core"],
onlyPluginIds: ["codex", "openai"],
}),
);
});
@@ -91,7 +88,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["codex", "openai", "memory-core"],
onlyPluginIds: ["codex", "openai"],
}),
);
});
@@ -119,10 +116,10 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["copilot", "memory-core"],
onlyPluginIds: ["copilot"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["copilot", "memory-core"],
allow: ["copilot"],
entries: expect.objectContaining({
copilot: expect.objectContaining({ enabled: true }),
}),
@@ -208,77 +205,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
);
});
it("keeps an allowed memory slot plugin in Codex harness scoped loads", async () => {
await ensureSelectedAgentHarnessPlugin({
provider: "openai",
modelId: "gpt-5.5-pro",
config: {
plugins: {
allow: ["codex", "openai", "memory-core"],
entries: {
codex: { enabled: true },
openai: { enabled: true },
},
},
} as OpenClawConfig,
workspaceDir: "/tmp/workspace",
});
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["codex", "openai", "memory-core"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["codex", "openai", "memory-core"],
entries: expect.objectContaining({
codex: expect.objectContaining({ enabled: true }),
openai: expect.objectContaining({ enabled: true }),
"memory-core": expect.objectContaining({ enabled: true }),
}),
}),
}),
}),
);
});
it("does not auto-activate an untrusted workspace memory slot plugin", async () => {
await ensureSelectedAgentHarnessPlugin({
provider: "openai",
modelId: "gpt-5.5-pro",
config: {
plugins: {
slots: { memory: "workspace-memory" },
},
} as OpenClawConfig,
workspaceDir: "/tmp/workspace",
});
expect(mocks.resolveActivatableProviderOwnerPluginIds).toHaveBeenCalledWith({
pluginIds: ["openai"],
config: expect.any(Object),
workspaceDir: "/tmp/workspace",
});
expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalledWith(
expect.objectContaining({ pluginIds: ["workspace-memory"] }),
);
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["codex", "openai"],
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.not.objectContaining({
"workspace-memory": expect.anything(),
}),
}),
}),
}),
);
});
it("does not auto-activate untrusted provider owners for Codex harness loads", async () => {
// Provider owner activation is limited to bundled-compatible/activatable
// owners so workspace plugins are not enabled just because Codex was chosen.
@@ -355,7 +281,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
);
});
it("keeps real bundled memory-core in a Codex scoped load when the provider has no owner plugin", async () => {
it("keeps a Codex scoped load narrow when the provider has no owner plugin", async () => {
mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(undefined);
await ensureSelectedAgentHarnessPlugin({
@@ -371,7 +297,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["codex", "memory-core"],
onlyPluginIds: ["codex"],
}),
);
});

View File

@@ -3,12 +3,6 @@
*/
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withActivatedPluginIds } from "../../plugins/activation-context.js";
import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js";
import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js";
import {
loadPluginRegistrySnapshot,
normalizePluginsConfigWithRegistry,
} from "../../plugins/plugin-registry.js";
import {
resolveActivatableProviderOwnerPluginIds,
resolveBundledProviderCompatPluginIds,
@@ -45,37 +39,6 @@ function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, plu
return allow.length > 0 && !allow.includes(pluginId);
}
function resolveSelectedMemoryPluginIds(params: {
config: OpenClawConfig | undefined;
workspaceDir: string;
}): string[] {
const registry = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
});
const plugins = normalizePluginsConfigWithRegistry(params.config?.plugins, registry);
const memorySlot = plugins.slots.memory;
if (
typeof memorySlot !== "string" ||
memorySlot.trim().length === 0 ||
restrictiveAllowlistOmitsPlugin(params.config, memorySlot)
) {
return [];
}
const plugin = registry.plugins.find((entry) => entry.pluginId === memorySlot);
if (!plugin?.startup.memory) {
return [];
}
const activationState = resolveEffectivePluginActivationState({
id: plugin.pluginId,
origin: plugin.origin,
config: plugins,
rootConfig: params.config,
enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin),
});
return activationState.activated ? [plugin.pluginId] : [];
}
function resolveHarnessPluginIds(params: {
runtime: string;
provider: string;
@@ -177,20 +140,15 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
config: params.config,
workspaceDir: params.workspaceDir,
});
const memoryPluginIds = resolveSelectedMemoryPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
});
const scopedPluginIds = dedupePluginIds([...pluginIds, ...memoryPluginIds]);
const configWithAllowedRuntimePlugins = withRuntimePluginIdsAllowed({
config: params.config,
requiredPluginId: runtime,
pluginIds: scopedPluginIds,
pluginIds,
});
const activatedConfig =
withActivatedPluginIds({
config: configWithAllowedRuntimePlugins,
pluginIds: scopedPluginIds,
pluginIds,
}) ?? configWithAllowedRuntimePlugins;
ensurePluginRegistryLoaded({
scope: "all",
@@ -201,6 +159,6 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
}
: {}),
workspaceDir: params.workspaceDir,
onlyPluginIds: scopedPluginIds,
onlyPluginIds: pluginIds,
});
}

View File

@@ -31,12 +31,6 @@ describe("live model error helpers", () => {
"HTTP 400 not_found_error: model: claude-3-5-haiku-20241022 (request_id: req_123)",
),
).toBe(true);
expect(
isModelNotFoundErrorMessage(
'{"error":{"code":"400","message":"Param Incorrect","param":"Not supported model some-model-id"}}',
),
).toBe(true);
expect(isModelNotFoundErrorMessage("Not supported model some-model-id")).toBe(true);
expect(
isModelNotFoundErrorMessage(
"404 The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
@@ -53,13 +47,6 @@ describe("live model error helpers", () => {
expect(isModelNotFoundErrorMessage('{"error":{"message":"Resource missing","code":404}}')).toBe(
false,
);
expect(isModelNotFoundErrorMessage("This model is not supported for tool calling.")).toBe(
false,
);
expect(isModelNotFoundErrorMessage("This model does not support image inputs.")).toBe(false);
expect(isModelNotFoundErrorMessage("Reasoning effort is not supported for this model.")).toBe(
false,
);
expect(isModelNotFoundErrorMessage("request ended without sending any chunks")).toBe(false);
});

View File

@@ -28,9 +28,6 @@ export function isModelNotFoundErrorMessage(raw: string): boolean {
if (/not_found_error/i.test(msg)) {
return true;
}
if (/\bnot supported model\b/i.test(msg)) {
return true;
}
if (/model:\s*[a-z0-9._/-]+/i.test(msg) && /not(?:[_\-\s])?found/i.test(msg)) {
return true;
}

View File

@@ -134,8 +134,6 @@ let applyAuthHeaderOverride: typeof import("./model-auth.js").applyAuthHeaderOve
let applyLocalNoAuthHeaderOverride: typeof import("./model-auth.js").applyLocalNoAuthHeaderOverride;
let createRuntimeProviderAuthLookup: typeof import("./model-auth.js").createRuntimeProviderAuthLookup;
let formatMissingAuthError: typeof import("./model-auth.js").formatMissingAuthError;
let hasAvailableAuthForProvider: typeof import("./model-auth.js").hasAvailableAuthForProvider;
let hasRuntimeAvailableProviderAuth: typeof import("./model-auth.js").hasRuntimeAvailableProviderAuth;
let hasUsableCustomProviderApiKey: typeof import("./model-auth.js").hasUsableCustomProviderApiKey;
let hasSyntheticLocalProviderAuthConfig: typeof import("./model-auth.js").hasSyntheticLocalProviderAuthConfig;
let requireApiKey: typeof import("./model-auth.js").requireApiKey;
@@ -157,8 +155,6 @@ beforeAll(async () => {
applyLocalNoAuthHeaderOverride,
createRuntimeProviderAuthLookup,
formatMissingAuthError,
hasAvailableAuthForProvider,
hasRuntimeAvailableProviderAuth,
hasSyntheticLocalProviderAuthConfig,
getApiKeyForModel,
hasUsableCustomProviderApiKey,
@@ -878,142 +874,6 @@ describe("resolveApiKeyForProvider", () => {
});
});
it.each([
{
name: "generated marker",
apiKey: NON_ENV_SECRETREF_MARKER,
},
{
name: "file SecretRef",
apiKey: { source: "file", provider: "vault", id: "/cliproxy/api-key" } as const,
},
])("resolves custom provider $name auth from the active runtime snapshot", async ({ apiKey }) => {
const sourceConfig = {
models: {
providers: {
cliproxyapi: {
api: "openai-responses" as const,
apiKey,
baseUrl: "https://cliproxy.example/v1",
models: [],
},
},
},
};
const runtimeConfig = {
models: {
providers: {
cliproxyapi: {
...sourceConfig.models.providers.cliproxyapi,
apiKey: "sk-runtime-cliproxy", // pragma: allowlist secret
},
},
},
};
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
const resolved = await resolveApiKeyForProvider({
provider: "cliproxyapi",
cfg: sourceConfig,
store: { version: 1, profiles: {} },
});
expectAuthFields(resolved, {
apiKey: "sk-runtime-cliproxy",
source: "models.providers.cliproxyapi",
mode: "api-key",
});
await expect(
hasAvailableAuthForProvider({
provider: "cliproxyapi",
cfg: sourceConfig,
store: { version: 1, profiles: {} },
}),
).resolves.toBe(true);
expect(
hasRuntimeAvailableProviderAuth({
provider: "cliproxyapi",
cfg: sourceConfig,
allowPluginSyntheticAuth: false,
}),
).toBe(true);
});
it("does not treat a custom provider managed SecretRef marker as auth without a runtime snapshot", async () => {
const sourceConfig = {
models: {
providers: {
cliproxyapi: {
api: "openai-responses" as const,
apiKey: NON_ENV_SECRETREF_MARKER,
baseUrl: "https://cliproxy.example/v1",
models: [],
},
},
},
};
await expect(
resolveApiKeyForProvider({
provider: "cliproxyapi",
cfg: sourceConfig,
store: { version: 1, profiles: {} },
}),
).rejects.toThrow('No API key found for provider "cliproxyapi"');
await expect(
hasAvailableAuthForProvider({
provider: "cliproxyapi",
cfg: sourceConfig,
store: { version: 1, profiles: {} },
}),
).resolves.toBe(false);
});
it("does not resolve custom provider managed SecretRef auth from an unrelated runtime snapshot", async () => {
const sourceConfig = {
models: {
providers: {
cliproxyapi: {
api: "openai-responses" as const,
apiKey: NON_ENV_SECRETREF_MARKER,
baseUrl: "https://cliproxy.example/v1",
models: [],
},
},
},
};
setRuntimeConfigSnapshot(
{
models: {
providers: {
cliproxyapi: {
...sourceConfig.models.providers.cliproxyapi,
apiKey: "sk-runtime-wrong-source", // pragma: allowlist secret
},
},
},
},
{
models: {
providers: {
cliproxyapi: {
...sourceConfig.models.providers.cliproxyapi,
baseUrl: "https://other.example/v1",
},
},
},
},
);
await expect(
resolveApiKeyForProvider({
provider: "cliproxyapi",
cfg: sourceConfig,
store: { version: 1, profiles: {} },
}),
).rejects.toThrow('No API key found for provider "cliproxyapi"');
});
it("does not reuse plugin fallback auth when the plugin is disabled", async () => {
await expect(
withoutEnv("PLUGIN_WEB_API_KEY", () =>
@@ -1124,56 +984,6 @@ describe("resolveApiKeyForProvider", () => {
});
});
it("prefers explicit api-key provider SecretRef config over ambient auth profiles", async () => {
const sourceConfig = {
models: {
providers: {
cliproxyapi: {
api: "openai-responses" as const,
auth: "api-key" as const,
apiKey: { source: "file", provider: "vault", id: "/cliproxy/api-key" } as const,
baseUrl: "https://cliproxy.example/v1",
models: [],
},
},
},
};
setRuntimeConfigSnapshot(
{
models: {
providers: {
cliproxyapi: {
...sourceConfig.models.providers.cliproxyapi,
apiKey: "sk-runtime-cliproxy", // pragma: allowlist secret
},
},
},
},
sourceConfig,
);
const resolved = await resolveApiKeyForProvider({
provider: "cliproxyapi",
cfg: sourceConfig,
store: {
version: 1,
profiles: {
"cliproxyapi:default": {
type: "api_key",
provider: "cliproxyapi",
key: "sk-profile-stale", // pragma: allowlist secret
},
},
},
});
expectAuthFields(resolved, {
apiKey: "sk-runtime-cliproxy",
source: "models.providers.cliproxyapi",
mode: "api-key",
});
});
it("prefers non-secret local env markers over ambient profiles", async () => {
const resolved = await withEnv("OLLAMA_API_KEY", "ollama-local", () =>
resolveApiKeyForProvider({
@@ -1349,46 +1159,6 @@ describe("resolveApiKeyForProvider synthetic local auth for custom providers
});
});
it("prefers a custom Ollama provider SecretRef runtime key over plugin synthetic auth", async () => {
const providerConfig = {
...createCustomProviderConfig("http://192.168.178.122:11435", "qwen3:14b", "Qwen 3 14B"),
api: "ollama" as const,
apiKey: { source: "file", provider: "vault", id: "/ollama/api-key" } as const,
};
const sourceConfig = {
models: {
providers: {
"ollama-gpu1": providerConfig,
},
},
};
setRuntimeConfigSnapshot(
{
models: {
providers: {
"ollama-gpu1": {
...providerConfig,
apiKey: "sk-runtime-ollama", // pragma: allowlist secret
},
},
},
},
sourceConfig,
);
const auth = await resolveApiKeyForProvider({
provider: "ollama-gpu1",
cfg: sourceConfig,
store: { version: 1, profiles: {} },
});
expectAuthFields(auth, {
apiKey: "sk-runtime-ollama",
source: "models.providers.ollama-gpu1",
mode: "api-key",
});
});
it("resolves synthetic auth when model overrides api to ollama within a non-ollama provider", async () => {
const auth = await getApiKeyForModel({
model: {

View File

@@ -10,11 +10,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
import { formatCliCommand } from "../cli/command-format.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "../config/config.js";
import { getRuntimeConfigSnapshot } from "../config/config.js";
import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { coerceSecretRef } from "../config/types.secrets.js";
@@ -658,61 +654,6 @@ function isManagedSecretRefApiKeyMarker(apiKey: string | undefined): boolean {
return apiKey?.trim() === NON_ENV_SECRETREF_MARKER;
}
function hasManagedSecretRefProviderApiKey(
cfg: OpenClawConfig | undefined,
provider: string,
): boolean {
const apiKey = resolveProviderConfig(cfg, provider)?.apiKey;
const ref = coerceSecretRef(apiKey);
if (ref) {
return ref.source !== "env";
}
return typeof apiKey === "string" && isManagedSecretRefApiKeyMarker(apiKey);
}
function resolveLiteralProviderConfigApiKeyAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
}): ResolvedProviderAuth | undefined {
const apiKey = normalizeOptionalSecretInput(
resolveProviderConfig(params.cfg, params.provider)?.apiKey,
);
if (!apiKey || isNonSecretApiKeyMarker(apiKey)) {
return undefined;
}
return {
apiKey,
source: `models.providers.${params.provider}`,
mode: "api-key",
};
}
function resolveManagedSecretRefRuntimeProviderAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
}): ResolvedProviderAuth | undefined {
if (!hasManagedSecretRefProviderApiKey(params.cfg, params.provider)) {
return undefined;
}
const runtimeConfig = getRuntimeConfigSnapshot();
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
if (params.cfg && params.cfg !== runtimeConfig && !runtimeSourceConfig) {
return undefined;
}
const applicableConfig = selectApplicableRuntimeConfig({
inputConfig: params.cfg,
runtimeConfig,
runtimeSourceConfig,
});
if (!runtimeConfig || applicableConfig !== runtimeConfig) {
return undefined;
}
return resolveLiteralProviderConfigApiKeyAuth({
cfg: runtimeConfig,
provider: params.provider,
});
}
/** True when a custom local provider can use a synthetic no-auth placeholder. */
export function hasSyntheticLocalProviderAuthConfig(params: {
cfg: OpenClawConfig | undefined;
@@ -815,9 +756,6 @@ export function hasRuntimeAvailableProviderAuth(params: {
if (resolveUsableCustomProviderApiKey({ cfg: params.cfg, provider, env: params.env })) {
return true;
}
if (resolveManagedSecretRefRuntimeProviderAuth({ cfg: params.cfg, provider })) {
return true;
}
if (hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider })) {
return true;
}
@@ -845,14 +783,6 @@ function resolveProviderSyntheticRuntimeAuth(params: {
provider: string;
modelApi?: string;
}): SyntheticProviderAuthResolution {
const runtimeAuth = resolveManagedSecretRefRuntimeProviderAuth(params);
if (runtimeAuth) {
return { auth: runtimeAuth };
}
if (hasManagedSecretRefProviderApiKey(params.cfg, params.provider)) {
return { blockedOnManagedSecretRef: true };
}
const resolveFromConfig = (
config: OpenClawConfig | undefined,
): ResolvedProviderAuth | undefined => {
@@ -884,13 +814,13 @@ function resolveProviderSyntheticRuntimeAuth(params: {
return { blockedOnManagedSecretRef: true };
}
const runtimePluginAuth = resolveFromConfig(runtimeConfig);
const runtimeApiKey = runtimePluginAuth?.apiKey;
if (!runtimePluginAuth || !runtimeApiKey || isNonSecretApiKeyMarker(runtimeApiKey)) {
const runtimeAuth = resolveFromConfig(runtimeConfig);
const runtimeApiKey = runtimeAuth?.apiKey;
if (!runtimeAuth || !runtimeApiKey || isNonSecretApiKeyMarker(runtimeApiKey)) {
return { blockedOnManagedSecretRef: true };
}
return {
auth: runtimePluginAuth,
auth: runtimeAuth,
};
}
@@ -1191,10 +1121,6 @@ export async function resolveApiKeyForProvider(params: {
}
if (shouldPreferExplicitConfigApiKeyAuth(cfg, provider)) {
const runtimeCustomKey = resolveManagedSecretRefRuntimeProviderAuth({ cfg, provider });
if (runtimeCustomKey) {
return runtimeCustomKey;
}
const customKey = resolveUsableCustomProviderApiKey({ cfg, provider });
if (customKey) {
return {

View File

@@ -2,10 +2,6 @@
import { createServer } from "node:http";
import type { Api, Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import {
classifyAssistantFailoverReason,
formatUserFacingAssistantErrorText,
} from "./embedded-agent-helpers.js";
import {
buildOpenAIResponsesParams,
buildOpenAICompletionsParams,
@@ -1583,83 +1579,6 @@ describe("openai transport stream", () => {
}
});
it("classifies OpenAI-compatible unsupported-model detail from failed chat requests", async () => {
const server = createServer((req, res) => {
req.resume();
req.on("end", () => {
res.writeHead(400, {
"content-type": "application/json; charset=utf-8",
"x-request-id": "req_not_supported_model",
});
res.end(
JSON.stringify({
error: {
code: "400",
message: "Param Incorrect",
param: "Not supported model some-model-id",
},
}),
);
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Missing loopback server address");
}
const model = {
id: "some-model-id",
name: "Some Model",
api: "openai-completions",
provider: "openai",
baseUrl: `http://127.0.0.1:${address.port}/v1`,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
} satisfies Model<"openai-completions">;
const stream = createOpenAICompletionsTransportStreamFn()(
model,
{
systemPrompt: "system",
messages: [{ role: "user", content: "Reply OK", timestamp: Date.now() }],
tools: [],
} as never,
{ apiKey: "test-key" } as never,
);
let errorPayload: Record<string, unknown> | undefined;
for await (const event of stream as AsyncIterable<{
type: string;
error?: Record<string, unknown>;
}>) {
if (event.type === "error") {
errorPayload = event.error;
}
}
expect(errorPayload).toMatchObject({
stopReason: "error",
errorMessage: "400 Param Incorrect",
errorCode: "400",
});
expect(String(errorPayload?.errorBody)).toContain("Not supported model some-model-id");
expect(classifyAssistantFailoverReason(errorPayload as never)).toBe("model_not_found");
expect(formatUserFacingAssistantErrorText(errorPayload as never)).toBe(
"The selected model was not found by the provider. Check the model id or choose a different model.",
);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("preserves reasoning tokens without double-counting them", () => {
const model = {
id: "gpt-5",
@@ -10264,7 +10183,9 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
});
it("preserves reasoning_content replay for Gemma 4 openai-completions models", () => {
const assistant = getAssistantMessage(buildReplayParams(gemma4Model, "reasoning_content"));
const assistant = getAssistantMessage(
buildReplayParams(gemma4Model, "reasoning_content"),
);
expect(assistant.reasoning_content).toBe("Need to answer politely.");
expect(assistant).not.toHaveProperty("reasoning_details");

Some files were not shown because too many files have changed in this diff Show More