mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 01:31:48 +08:00
Compare commits
1 Commits
main
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853ce969bf |
1
.github/workflows/plugin-npm-release.yml
vendored
1
.github/workflows/plugin-npm-release.yml
vendored
@@ -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
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1374,6 +1374,7 @@
|
||||
"pages": [
|
||||
"clawhub/cli",
|
||||
"clawhub/publishing",
|
||||
"clawhub/plugin-validation-fixes",
|
||||
"clawhub/skill-format",
|
||||
"clawhub/auth",
|
||||
"clawhub/telemetry",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -303,6 +303,7 @@ function identity(pluginName: string): ResolvedCodexPluginPolicy {
|
||||
pluginName,
|
||||
enabled: true,
|
||||
allowDestructiveActions: false,
|
||||
destructiveApprovalMode: "deny",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -508,6 +508,7 @@ function readCodexPluginPolicy(item: MigrationItem): ResolvedCodexPluginPolicy |
|
||||
pluginName,
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,7 +25,6 @@ export type TelegramMessageContextOptions = {
|
||||
receivedAtMs?: number;
|
||||
ingressBuffer?: "inbound-debounce" | "text-fragment";
|
||||
promptContextMinTimestampMs?: number;
|
||||
spooledReplay?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramPromptContextEntry = NonNullable<
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -19,7 +19,6 @@ export type TelegramGetChat = (chatId: number | string) => Promise<TelegramChatD
|
||||
*/
|
||||
export type TelegramContext = {
|
||||
message: Message;
|
||||
update?: unknown;
|
||||
me?: UserFromGetMe;
|
||||
getFile: TelegramGetFile;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}.`,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,7 +29,6 @@ export function createBaseToolHandlerState() {
|
||||
messagingToolSentTextsNormalized: [] as string[],
|
||||
messagingToolSentMediaUrls: [] as string[],
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
messageToolOnlySourceReplyDelivered: false,
|
||||
messagingToolSentTargets: [] as unknown[],
|
||||
deterministicApprovalPromptSent: false,
|
||||
blockBuffer: "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -12,7 +12,6 @@ export {
|
||||
} from "./embedded-agent-helpers/bootstrap.js";
|
||||
export {
|
||||
BILLING_ERROR_USER_MESSAGE,
|
||||
classifyAssistantFailoverReason,
|
||||
classifyProviderRuntimeFailureKind,
|
||||
formatBillingErrorMessage,
|
||||
formatRateLimitOrOverloadedErrorCopy,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } = {};
|
||||
|
||||
|
||||
@@ -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")>();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"}}';
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ function createMockContext(overrides?: {
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
messageToolOnlySourceReplyDelivered: false,
|
||||
messagingToolSentTargets: [],
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
|
||||
@@ -77,7 +77,6 @@ function createTestContext(): {
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
messageToolOnlySourceReplyDelivered: false,
|
||||
messagingToolSentTargets: [],
|
||||
successfulCronAdds: 0,
|
||||
deterministicApprovalPromptSent: false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user