fix(ci): unblock agent typing and cache startup metadata

This commit is contained in:
Peter Steinberger
2026-04-04 07:04:06 +01:00
parent 3a3f88a80a
commit c91b6bf322
8 changed files with 123 additions and 55 deletions

View File

@@ -149,8 +149,8 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC
config: {
...cfg,
channels: {
...cfg.channels,
matrix: updatedMatrix as OpenClawConfig["channels"]["matrix"],
...(cfg.channels ?? {}),
matrix: updatedMatrix as NonNullable<OpenClawConfig["channels"]>["matrix"],
},
},
changes,

View File

@@ -973,8 +973,8 @@
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 node scripts/run-vitest.mjs run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"android:test:third-party": "cd apps/android && ./gradlew :app:testThirdPartyDebugUnitTest",
"audit:seams": "node scripts/audit-seams.mjs",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"canon:check": "node scripts/canon.mjs check",

View File

@@ -1,8 +1,8 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { renderRootHelpText as renderSourceRootHelpText } from "../src/cli/program/root-help.ts";
function dedupe(values: string[]): string[] {
const seen = new Set<string>();
@@ -40,10 +40,33 @@ type ExtensionChannelEntry = {
label: string;
};
export function readBundledChannelCatalogIds(
type BundledChannelCatalog = {
ids: string[];
signature: string;
};
function resolveRootHelpBundleIdentity(
distDirOverride: string = distDir,
): { bundleName: string; signature: string } | null {
const bundleName = readdirSync(distDirOverride).find(
(entry) => entry.startsWith("root-help-") && entry.endsWith(".js"),
);
if (!bundleName) {
return null;
}
const bundlePath = path.join(distDirOverride, bundleName);
const raw = readFileSync(bundlePath, "utf8");
return {
bundleName,
signature: createHash("sha1").update(raw).digest("hex"),
};
}
export function readBundledChannelCatalog(
extensionsDirOverride: string = extensionsDir,
): string[] {
): BundledChannelCatalog {
const entries: ExtensionChannelEntry[] = [];
const signature = createHash("sha1");
for (const dirEntry of readdirSync(extensionsDirOverride, { withFileTypes: true })) {
if (!dirEntry.isDirectory()) {
continue;
@@ -51,6 +74,7 @@ export function readBundledChannelCatalogIds(
const packageJsonPath = path.join(extensionsDirOverride, dirEntry.name, "package.json");
try {
const raw = readFileSync(packageJsonPath, "utf8");
signature.update(`${dirEntry.name}\0${raw}\0`);
const parsed = JSON.parse(raw) as {
openclaw?: {
channel?: {
@@ -75,31 +99,40 @@ export function readBundledChannelCatalogIds(
// Ignore malformed or missing extension package manifests.
}
}
return entries
.toSorted((a, b) => (a.order === b.order ? a.label.localeCompare(b.label) : a.order - b.order))
.map((entry) => entry.id);
return {
ids: entries
.toSorted((a, b) =>
a.order === b.order ? a.label.localeCompare(b.label) : a.order - b.order,
)
.map((entry) => entry.id),
signature: signature.digest("hex"),
};
}
export function readBundledChannelCatalogIds(
extensionsDirOverride: string = extensionsDir,
): string[] {
return readBundledChannelCatalog(extensionsDirOverride).ids;
}
export async function renderBundledRootHelpText(
_distDirOverride: string = distDir,
): Promise<string> {
const bundleName = readdirSync(distDirOverride).find(
(entry) => entry.startsWith("root-help-") && entry.endsWith(".js"),
);
if (!bundleName) {
const bundleIdentity = resolveRootHelpBundleIdentity(_distDirOverride);
if (!bundleIdentity) {
throw new Error("No root-help bundle found in dist; cannot write CLI startup metadata.");
}
const moduleUrl = pathToFileURL(path.join(distDirOverride, bundleName)).href;
const moduleUrl = pathToFileURL(path.join(_distDirOverride, bundleIdentity.bundleName)).href;
const inlineModule = [
`const mod = await import(${JSON.stringify(moduleUrl)});`,
"if (typeof mod.outputRootHelp !== 'function') {",
` throw new Error(${JSON.stringify(`Bundle ${bundleName} does not export outputRootHelp.`)});`,
` throw new Error(${JSON.stringify(`Bundle ${bundleIdentity.bundleName} does not export outputRootHelp.`)});`,
"}",
"await mod.outputRootHelp();",
"process.exit(0);",
].join("\n");
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", inlineModule], {
cwd: distDirOverride,
cwd: _distDirOverride,
encoding: "utf8",
timeout: 30_000,
});
@@ -109,13 +142,18 @@ export async function renderBundledRootHelpText(
if (result.status !== 0) {
const stderr = result.stderr?.trim();
throw new Error(
`Failed to render bundled root help from ${bundleName}` +
`Failed to render bundled root help from ${bundleIdentity.bundleName}` +
(stderr ? `: ${stderr}` : result.signal ? `: terminated by ${result.signal}` : ""),
);
}
return result.stdout ?? "";
}
async function renderSourceRootHelpText(): Promise<string> {
const module = await import("../src/cli/program/root-help.ts");
return module.renderRootHelpText({ pluginSdkResolution: "src" });
}
export async function writeCliStartupMetadata(options?: {
distDir?: string;
outputPath?: string;
@@ -124,15 +162,32 @@ export async function writeCliStartupMetadata(options?: {
const resolvedDistDir = options?.distDir ?? distDir;
const resolvedOutputPath = options?.outputPath ?? outputPath;
const resolvedExtensionsDir = options?.extensionsDir ?? extensionsDir;
const catalog = readBundledChannelCatalogIds(resolvedExtensionsDir);
const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...catalog]);
const useSourceRootHelp =
resolvedDistDir === distDir &&
resolvedOutputPath === outputPath &&
resolvedExtensionsDir === extensionsDir;
const rootHelpText = useSourceRootHelp
? await renderSourceRootHelpText({ pluginSdkResolution: "src" })
: await renderBundledRootHelpText(resolvedDistDir);
const channelCatalog = readBundledChannelCatalog(resolvedExtensionsDir);
const bundleIdentity = resolveRootHelpBundleIdentity(resolvedDistDir);
const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...channelCatalog.ids]);
try {
const existing = JSON.parse(readFileSync(resolvedOutputPath, "utf8")) as {
rootHelpBundleSignature?: unknown;
channelCatalogSignature?: unknown;
};
if (
bundleIdentity &&
existing.rootHelpBundleSignature === bundleIdentity.signature &&
existing.channelCatalogSignature === channelCatalog.signature
) {
return;
}
} catch {
// Missing or malformed existing metadata means we should regenerate it.
}
let rootHelpText: string;
try {
rootHelpText = await renderBundledRootHelpText(resolvedDistDir);
} catch {
rootHelpText = await renderSourceRootHelpText();
}
mkdirSync(resolvedDistDir, { recursive: true });
writeFileSync(
@@ -141,6 +196,8 @@ export async function writeCliStartupMetadata(options?: {
{
generatedBy: "scripts/write-cli-startup-metadata.ts",
channelOptions,
channelCatalogSignature: channelCatalog.signature,
rootHelpBundleSignature: bundleIdentity?.signature ?? null,
rootHelpText,
},
null,

View File

@@ -8,6 +8,7 @@ import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
import { buildAssistantMessageWithZeroUsage } from "./stream-message-shared.js";
export const LIVE_CACHE_TEST_ENABLED =
isLiveTestEnabled() && isTruthyEnvValue(process.env.OPENCLAW_LIVE_CACHE_TEST);
@@ -123,6 +124,22 @@ export function extractAssistantText(message: AssistantMessage): string {
.join(" ");
}
export function buildAssistantHistoryTurn(
text: string,
model?: Pick<Model<Api>, "api" | "provider" | "id">,
): AssistantMessage {
return buildAssistantMessageWithZeroUsage({
model: {
api: model?.api ?? "openai-responses",
provider: model?.provider ?? "openai",
id: model?.id ?? "test-model",
},
content: [{ type: "text", text }],
stopReason: "stop",
timestamp: Date.now(),
});
}
export function computeCacheHitRate(usage: {
input?: number;
cacheRead?: number;

View File

@@ -48,14 +48,14 @@ import {
planTurnInput,
} from "./openai-ws-message-conversion.js";
import { buildOpenAIWebSocketResponseCreatePayload } from "./openai-ws-request.js";
import { createBoundaryAwareStreamFnForModel } from "./provider-transport-stream.js";
import { log } from "./pi-embedded-runner/logger.js";
import { createBoundaryAwareStreamFnForModel } from "./provider-transport-stream.js";
import {
buildAssistantMessageWithZeroUsage,
buildStreamErrorAssistantMessage,
} from "./stream-message-shared.js";
import { mergeTransportMetadata } from "./transport-stream-shared.js";
import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js";
import { mergeTransportMetadata } from "./transport-stream-shared.js";
// ─────────────────────────────────────────────────────────────────────────────
// Per-session state
@@ -963,7 +963,7 @@ async function fallbackToHttp(
const httpStreamFn =
openAIWsStreamDeps.createHttpFallbackStreamFn(model as ProviderRuntimeModel) ??
openAIWsStreamDeps.streamSimple;
const httpStream = httpStreamFn(model, context, mergedOptions);
const httpStream = await httpStreamFn(model, context, mergedOptions);
for await (const event of httpStream) {
if (fallbackOptions?.suppressStart && event.type === "start") {
continue;

View File

@@ -6,6 +6,7 @@ import { Type } from "@sinclair/typebox";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
buildAssistantHistoryTurn as buildTypedAssistantHistoryTurn,
buildStableCachePrefix,
completeSimpleWithLiveTimeout,
computeCacheHitRate,
@@ -49,12 +50,11 @@ let liveRunnerRootDir: string | undefined;
type UserContent = Extract<Message, { role: "user" }>["content"];
function makeAssistantHistoryTurn(text: string): Message {
return {
role: "assistant",
content: [{ type: "text", text }],
timestamp: Date.now(),
};
function makeAssistantHistoryTurn(
text: string,
model?: Awaited<ReturnType<typeof resolveLiveDirectModel>>["model"],
): Message {
return buildTypedAssistantHistoryTurn(text, model);
}
function makeUserHistoryTurn(content: UserContent): Message {
@@ -348,9 +348,9 @@ async function runOpenAiToolCacheProbe(params: {
},
toolTurn.response,
buildToolResultMessage(toolTurn.toolCall.id, NOOP_TOOL.name, "ok"),
makeAssistantHistoryTurn("TOOL HISTORY ACKNOWLEDGED"),
makeAssistantHistoryTurn("TOOL HISTORY ACKNOWLEDGED", params.model),
makeUserHistoryTurn("Keep the tool output stable in history."),
makeAssistantHistoryTurn("TOOL HISTORY PRESERVED"),
makeAssistantHistoryTurn("TOOL HISTORY PRESERVED", params.model),
{
role: "user",
content: `Reply with exactly CACHE-OK ${params.suffix}.`,
@@ -432,9 +432,9 @@ async function runOpenAiImageCacheProbe(params: {
makeImageUserTurn(
"An image is attached. Ignore image semantics but keep the bytes in history.",
),
makeAssistantHistoryTurn("IMAGE HISTORY ACKNOWLEDGED"),
makeAssistantHistoryTurn("IMAGE HISTORY ACKNOWLEDGED", params.model),
makeUserHistoryTurn("Keep the earlier image turn stable in context."),
makeAssistantHistoryTurn("IMAGE HISTORY PRESERVED"),
makeAssistantHistoryTurn("IMAGE HISTORY PRESERVED", params.model),
makeUserHistoryTurn(`Reply with exactly CACHE-OK ${params.suffix}.`),
],
},
@@ -526,9 +526,9 @@ async function runAnthropicToolCacheProbe(params: {
},
toolTurn.response,
buildToolResultMessage(toolTurn.toolCall.id, NOOP_TOOL.name, "ok"),
makeAssistantHistoryTurn("TOOL HISTORY ACKNOWLEDGED"),
makeAssistantHistoryTurn("TOOL HISTORY ACKNOWLEDGED", params.model),
makeUserHistoryTurn("Keep the tool output stable in history."),
makeAssistantHistoryTurn("TOOL HISTORY PRESERVED"),
makeAssistantHistoryTurn("TOOL HISTORY PRESERVED", params.model),
{
role: "user",
content: `Reply with exactly CACHE-OK ${params.suffix}.`,
@@ -572,9 +572,9 @@ async function runAnthropicImageCacheProbe(params: {
makeImageUserTurn(
"An image is attached. Ignore image semantics but keep the bytes in history.",
),
makeAssistantHistoryTurn("IMAGE HISTORY ACKNOWLEDGED"),
makeAssistantHistoryTurn("IMAGE HISTORY ACKNOWLEDGED", params.model),
makeUserHistoryTurn("Keep the earlier image turn stable in context."),
makeAssistantHistoryTurn("IMAGE HISTORY PRESERVED"),
makeAssistantHistoryTurn("IMAGE HISTORY PRESERVED", params.model),
makeUserHistoryTurn(`Reply with exactly CACHE-OK ${params.suffix}.`),
],
},

View File

@@ -83,7 +83,8 @@ export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.f
export const createOpenClawCodingToolsMock = vi.fn(() => []);
export const resolveEmbeddedAgentStreamFnMock = vi.fn((_params?: unknown) => vi.fn());
export const applyExtraParamsToAgentMock = vi.fn(() => ({ effectiveExtraParams: {} }));
export const resolveAgentTransportOverrideMock = vi.fn(() => undefined);
export const resolveAgentTransportOverrideMock: Mock<(params?: unknown) => string | undefined> =
vi.fn(() => undefined);
export function resetCompactSessionStateMocks(): void {
sanitizeSessionHistoryMock.mockReset();

View File

@@ -2,6 +2,7 @@ import type { AssistantMessage, Tool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import {
buildAssistantHistoryTurn,
buildStableCachePrefix,
completeSimpleWithLiveTimeout,
computeCacheHitRate,
@@ -122,21 +123,13 @@ async function runOpenAiMcpStyleCacheProbe(params: {
{ role: "user", content: toolTurn.prompt, timestamp: Date.now() },
toolTurn.response,
buildToolResultMessage(toolTurn.toolCall.id),
{
role: "assistant",
content: [{ type: "text", text: "MCP TOOL HISTORY ACKNOWLEDGED" }],
timestamp: Date.now(),
},
buildAssistantHistoryTurn("MCP TOOL HISTORY ACKNOWLEDGED", params.model),
{
role: "user",
content: "Keep the MCP tool output stable in history.",
timestamp: Date.now(),
},
{
role: "assistant",
content: [{ type: "text", text: "MCP TOOL HISTORY PRESERVED" }],
timestamp: Date.now(),
},
buildAssistantHistoryTurn("MCP TOOL HISTORY PRESERVED", params.model),
{
role: "user",
content: `Reply with exactly CACHE-OK ${params.suffix}.`,