Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
31
test/helpers/AGENTS.md
Normal file
31
test/helpers/AGENTS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Shared Test Helper Boundary
|
||||
|
||||
This directory holds shared test helpers reused by core and bundled plugin
|
||||
tests.
|
||||
|
||||
## Bundled Plugin Imports
|
||||
|
||||
- Shared helpers in this tree must not hardcode repo-relative imports into
|
||||
`extensions/**`.
|
||||
- When a helper needs a bundled plugin public surface, go through
|
||||
`src/test-utils/bundled-plugin-public-surface.ts`.
|
||||
- Prefer `loadBundledPluginApiSync(...)`,
|
||||
`loadBundledPluginRuntimeApiSync(...)`,
|
||||
`loadBundledPluginContractApiSync(...)`, and
|
||||
`loadBundledPluginTestApiSync(...)` for eager access to exported surfaces.
|
||||
- Prefer `resolveRelativeBundledPluginPublicModuleId(...)` or
|
||||
`resolveBundledPluginPublicModulePath(...)` when a helper needs a module id
|
||||
or filesystem path for dynamic import, mocking, or loading a plugin entrypoint
|
||||
such as `index.js`.
|
||||
- If `vi.hoisted(...)` is involved, do not call imported helper functions from
|
||||
inside the hoisted callback. Resolve the module id outside the callback or
|
||||
switch to `vi.doMock(...)`.
|
||||
- Do not keep plugin-local deep mocks or private `src/**` knowledge in shared
|
||||
helpers. Move those helpers into the owning bundled plugin package instead.
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep shared helpers aligned with the same public/plugin boundary that
|
||||
production code uses.
|
||||
- Avoid shared helper debt that makes core test lanes depend on bundled plugin
|
||||
private layout.
|
||||
1
test/helpers/CLAUDE.md
Symbolic link
1
test/helpers/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
55
test/helpers/acp-manager-task-state.ts
Normal file
55
test/helpers/acp-manager-task-state.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// ACP manager task state helper resets task flow state for ACP tests.
|
||||
import { resetTaskFlowRegistryForTests } from "../../src/tasks/task-flow-registry.js";
|
||||
import { configureTaskFlowRegistryRuntime } from "../../src/tasks/task-flow-registry.store.js";
|
||||
import { findTaskByRunId, resetTaskRegistryForTests } from "../../src/tasks/task-registry.js";
|
||||
import { withTempDir } from "../../src/test-helpers/temp-dir.js";
|
||||
import { captureEnv, setTestEnvValue } from "../../src/test-utils/env.js";
|
||||
import { installInMemoryTaskRegistryRuntime } from "../../src/test-utils/task-registry-runtime.js";
|
||||
|
||||
// Shared ACP manager task registry setup for tests.
|
||||
|
||||
export { findTaskByRunId };
|
||||
|
||||
/** Reset task and task-flow registries without persisting state. */
|
||||
export function resetAcpManagerTaskStateForTests(): void {
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
resetTaskFlowRegistryForTests({ persist: false });
|
||||
}
|
||||
|
||||
/** Run a test with isolated ACP manager task state rooted in a temp dir. */
|
||||
export async function withAcpManagerTaskStateDir(
|
||||
run: (root: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await withTempDir({ prefix: "openclaw-acp-manager-task-" }, async (root) => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", root);
|
||||
resetAcpManagerTaskStateForTests();
|
||||
installInMemoryTaskRegistryRuntime();
|
||||
configureTaskFlowRegistryRuntime({
|
||||
store: {
|
||||
loadSnapshot: () => ({
|
||||
flows: new Map(),
|
||||
}),
|
||||
saveSnapshot: () => {},
|
||||
upsertFlow: () => {},
|
||||
deleteFlow: () => {},
|
||||
close: () => {},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await run(root);
|
||||
} finally {
|
||||
resetAcpManagerTaskStateForTests();
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Return a task by run id or fail the test with a clear message. */
|
||||
export function requireTaskByRunId(runId: string) {
|
||||
const task = findTaskByRunId(runId);
|
||||
if (!task) {
|
||||
throw new Error(`Expected task for run ${runId}`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
958
test/helpers/agents/happy-path-prompt-snapshots.ts
Normal file
958
test/helpers/agents/happy-path-prompt-snapshots.ts
Normal file
@@ -0,0 +1,958 @@
|
||||
// Happy path prompt snapshot helper reads expected prompt snapshot files.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { resolveHeartbeatPromptForResponseTool } from "../../../src/auto-reply/heartbeat.js";
|
||||
import {
|
||||
buildDirectChatContext,
|
||||
buildGroupChatContext,
|
||||
buildGroupIntro,
|
||||
} from "../../../src/auto-reply/reply/groups.js";
|
||||
import {
|
||||
buildInboundMetaSystemPrompt,
|
||||
buildInboundUserContextPrefix,
|
||||
} from "../../../src/auto-reply/reply/inbound-meta.js";
|
||||
import { buildReplyPromptBodies } from "../../../src/auto-reply/reply/prompt-prelude.js";
|
||||
import type { TemplateContext } from "../../../src/auto-reply/templating.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
|
||||
import { normalizeChatType } from "../../../src/channels/chat-type.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
|
||||
import type {
|
||||
AnyAgentTool,
|
||||
EmbeddedRunAttemptParams,
|
||||
} from "../../../src/plugin-sdk/agent-harness-runtime.js";
|
||||
import { normalizeAgentRuntimeTools } from "../../../src/plugin-sdk/agent-harness-runtime.js";
|
||||
import { createOpenClawCodingTools } from "../../../src/plugin-sdk/agent-harness.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
import {
|
||||
CODEX_MODEL_PROMPT_FIXTURE_DIR,
|
||||
CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR,
|
||||
} from "./prompt-snapshot-paths.js";
|
||||
|
||||
// Builds Codex happy-path prompt snapshot fixtures for agent prompt regression tests.
|
||||
|
||||
export { CODEX_MODEL_PROMPT_FIXTURE_DIR, CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR };
|
||||
|
||||
const WORKSPACE_DIR = "/tmp/openclaw-happy-path/workspace";
|
||||
const AGENT_DIR = "/tmp/openclaw-happy-path/agent";
|
||||
const SESSION_FILE = "/tmp/openclaw-happy-path/session.jsonl";
|
||||
const MODEL_ID = "gpt-5.5";
|
||||
const CODEX_PROMPT_PERSONALITY = "pragmatic";
|
||||
const CODEX_MODEL_PROMPT_FIXTURE_PATH = path.join(
|
||||
CODEX_MODEL_PROMPT_FIXTURE_DIR,
|
||||
`${MODEL_ID}.${CODEX_PROMPT_PERSONALITY}.instructions.md`,
|
||||
);
|
||||
const CODEX_MODEL_PROMPT_SOURCE_PATH = path.join(
|
||||
CODEX_MODEL_PROMPT_FIXTURE_DIR,
|
||||
`${MODEL_ID}.${CODEX_PROMPT_PERSONALITY}.source.json`,
|
||||
);
|
||||
const CODEX_YOLO_PERMISSION_INSTRUCTIONS = [
|
||||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled.",
|
||||
"Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.",
|
||||
].join("\n");
|
||||
const HAPPY_PATH_TOOL_NAMES = new Set([
|
||||
"nodes",
|
||||
"cron",
|
||||
"message",
|
||||
"heartbeat_respond",
|
||||
"tts",
|
||||
"gateway",
|
||||
"agents_list",
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"sessions_yield",
|
||||
"subagents",
|
||||
"session_status",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
]);
|
||||
|
||||
type CodexDynamicToolFunctionSpec = {
|
||||
type?: "function";
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
};
|
||||
|
||||
type CodexDynamicToolNamespaceSpec = {
|
||||
type: "namespace";
|
||||
name: string;
|
||||
tools: CodexDynamicToolFunctionSpec[];
|
||||
};
|
||||
|
||||
type CodexDynamicToolSpec = CodexDynamicToolFunctionSpec | CodexDynamicToolNamespaceSpec;
|
||||
|
||||
function flattenCodexDynamicToolSpecs(
|
||||
specs: readonly CodexDynamicToolSpec[],
|
||||
): CodexDynamicToolFunctionSpec[] {
|
||||
return specs.flatMap((spec) => (spec.type === "namespace" ? spec.tools : [spec]));
|
||||
}
|
||||
|
||||
type CodexPromptSnapshotApi = {
|
||||
resolveCodexPromptSnapshotAppServerOptions: (pluginConfig?: unknown) => unknown;
|
||||
buildCodexHarnessPromptSnapshot: (params: {
|
||||
attempt: EmbeddedRunAttemptParams;
|
||||
cwd: string;
|
||||
threadId: string;
|
||||
dynamicTools: CodexDynamicToolSpec[];
|
||||
appServer: unknown;
|
||||
config?: Record<string, unknown>;
|
||||
promptText?: string;
|
||||
developerInstructionAdditions?: string;
|
||||
turnScopedDeveloperInstructions?: string;
|
||||
heartbeatCollaborationInstructions?: string;
|
||||
}) => {
|
||||
developerInstructions: string;
|
||||
threadStartParams: Record<string, unknown>;
|
||||
threadResumeParams: Record<string, unknown>;
|
||||
turnStartParams: Record<string, unknown>;
|
||||
};
|
||||
createCodexDynamicToolSpecsForPromptSnapshot: (params: {
|
||||
tools: AnyAgentTool[];
|
||||
pluginConfig?: {
|
||||
codexDynamicToolsLoading?: "searchable" | "direct";
|
||||
codexDynamicToolsExclude?: string[];
|
||||
};
|
||||
directToolNames?: string[];
|
||||
}) => CodexDynamicToolSpec[];
|
||||
};
|
||||
|
||||
type PromptSnapshotFile = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type PromptScenario = {
|
||||
id: string;
|
||||
title: string;
|
||||
notes: string[];
|
||||
trigger: "user" | "heartbeat";
|
||||
ctx: TemplateContext;
|
||||
prompt: string;
|
||||
extraSystemPrompt: string;
|
||||
dynamicTools: CodexDynamicToolSpec[];
|
||||
toolSnapshotFile: string;
|
||||
};
|
||||
|
||||
const CODEX_TEST_API_MODULE_ID = resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "codex",
|
||||
artifactBasename: "test-api.js",
|
||||
});
|
||||
|
||||
/** Load the Codex public test API without hardcoding plugin-private paths. */
|
||||
async function loadCodexPromptSnapshotApi(): Promise<CodexPromptSnapshotApi> {
|
||||
return (await import(CODEX_TEST_API_MODULE_ID)) as CodexPromptSnapshotApi;
|
||||
}
|
||||
|
||||
const CODEX_WORKSPACE_BOOTSTRAP_CONTEXT_FILES = [
|
||||
{
|
||||
path: path.join(WORKSPACE_DIR, "MEMORY.md"),
|
||||
content: "<MEMORY.md contents will be here>",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const CODEX_WORKSPACE_THREAD_DEVELOPER_CONTEXT_FILES = [
|
||||
{
|
||||
path: path.join(WORKSPACE_DIR, "TOOLS.md"),
|
||||
content: "<TOOLS.md contents will be here>",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const CODEX_WORKSPACE_TURN_SCOPED_DEVELOPER_CONTEXT_FILES = [
|
||||
{
|
||||
path: path.join(WORKSPACE_DIR, "IDENTITY.md"),
|
||||
content: "<IDENTITY.md contents will be here>",
|
||||
},
|
||||
{
|
||||
path: path.join(WORKSPACE_DIR, "SOUL.md"),
|
||||
content: "<SOUL.md contents will be here>",
|
||||
},
|
||||
{
|
||||
path: path.join(WORKSPACE_DIR, "USER.md"),
|
||||
content: "<USER.md contents will be here>",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const CODEX_HEARTBEAT_CONTEXT_FILE = {
|
||||
path: path.join(WORKSPACE_DIR, "HEARTBEAT.md"),
|
||||
content: "<HEARTBEAT.md contents will be here>",
|
||||
} as const;
|
||||
|
||||
const CODEX_WORKSPACE_BOOTSTRAP_PROMPT_CONTEXT = [
|
||||
"OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. TOOLS.md is provided as inherited Codex developer instructions. SOUL.md, IDENTITY.md, and USER.md are provided as turn-scoped collaboration instructions so native Codex subagents do not inherit them. HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.",
|
||||
"",
|
||||
"# Project Context",
|
||||
"",
|
||||
"The following project context files have been loaded:",
|
||||
"",
|
||||
...CODEX_WORKSPACE_BOOTSTRAP_CONTEXT_FILES.flatMap((file) => [
|
||||
`## ${file.path}`,
|
||||
"",
|
||||
file.content,
|
||||
"",
|
||||
]),
|
||||
]
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
const CODEX_WORKSPACE_THREAD_DEVELOPER_INSTRUCTIONS = [
|
||||
"## OpenClaw Workspace Instructions",
|
||||
"",
|
||||
"OpenClaw loaded these workspace instruction files from the active agent workspace. Internalize and follow them accordingly.",
|
||||
"",
|
||||
...CODEX_WORKSPACE_THREAD_DEVELOPER_CONTEXT_FILES.flatMap((file) => [
|
||||
`### ${file.path}`,
|
||||
"",
|
||||
file.content,
|
||||
"",
|
||||
]),
|
||||
]
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
const CODEX_WORKSPACE_TURN_SCOPED_DEVELOPER_INSTRUCTIONS = [
|
||||
"## OpenClaw Agent Soul",
|
||||
"",
|
||||
"OpenClaw loaded these workspace instruction files from the active agent workspace. They are the canonical definitions of who you are, how you think and work, and the human you work alongside. Internalize and follow them accordingly.",
|
||||
"",
|
||||
...CODEX_WORKSPACE_TURN_SCOPED_DEVELOPER_CONTEXT_FILES.flatMap((file) => [
|
||||
`### ${file.path}`,
|
||||
"",
|
||||
file.content,
|
||||
"",
|
||||
]),
|
||||
]
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
const CODEX_HEARTBEAT_COLLABORATION_INSTRUCTIONS = [
|
||||
"## OpenClaw Heartbeat Workspace",
|
||||
"",
|
||||
"HEARTBEAT.md exists in the active agent workspace. Read it before proceeding with this heartbeat, then decide what action is appropriate.",
|
||||
"",
|
||||
`- ${CODEX_HEARTBEAT_CONTEXT_FILE.path}`,
|
||||
].join("\n");
|
||||
|
||||
const CODEX_PROMPT_SNAPSHOT_THREAD_CONFIG = {
|
||||
"features.code_mode_only": false,
|
||||
};
|
||||
|
||||
const baseConfig: OpenClawConfig = {
|
||||
messages: {
|
||||
groupChat: {
|
||||
visibleReplies: "message_tool",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
every: "30m",
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
profiles: {
|
||||
coding: {
|
||||
allow: [
|
||||
"message",
|
||||
"heartbeat_respond",
|
||||
"sessions_spawn",
|
||||
"sessions_list",
|
||||
"sessions_yield",
|
||||
"cron",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"session_status",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dynamicToolsConfig: OpenClawConfig = {
|
||||
...baseConfig,
|
||||
plugins: {
|
||||
enabled: true,
|
||||
slots: {
|
||||
memory: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const happyPathModel = {
|
||||
id: MODEL_ID,
|
||||
provider: "openai",
|
||||
api: "responses",
|
||||
input: ["text"],
|
||||
contextWindow: 272_000,
|
||||
} as unknown as Model;
|
||||
|
||||
function stableJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableJsonValue);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, child]) => child !== undefined)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, child]) => [key, stableJsonValue(child)]),
|
||||
);
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
return `${JSON.stringify(stableJsonValue(value), null, 2)}\n`;
|
||||
}
|
||||
|
||||
function markdownFence(info: string, value: string): string {
|
||||
const body = value.trimEnd();
|
||||
const longestBacktickRun = Math.max(
|
||||
3,
|
||||
...(body.match(/`+/g) ?? []).map((match) => match.length + 1),
|
||||
);
|
||||
const fence = "`".repeat(longestBacktickRun);
|
||||
return [`${fence}${info}`, body, fence].join("\n");
|
||||
}
|
||||
|
||||
function readFixture(pathFromRepoRoot: string): string {
|
||||
return fs.readFileSync(path.resolve(pathFromRepoRoot), "utf8");
|
||||
}
|
||||
|
||||
function approximateTokens(value: string): number {
|
||||
return Math.ceil(value.length / 4);
|
||||
}
|
||||
|
||||
function textStats(value: string): { chars: number; roughTokens: number } {
|
||||
return {
|
||||
chars: value.length,
|
||||
roughTokens: approximateTokens(value),
|
||||
};
|
||||
}
|
||||
|
||||
function createPrompt(ctx: TemplateContext, body: string): string {
|
||||
const inboundUserContext = buildInboundUserContextPrefix(ctx);
|
||||
return buildReplyPromptBodies({
|
||||
ctx,
|
||||
sessionCtx: ctx,
|
||||
effectiveBaseBody: [inboundUserContext, body].filter(Boolean).join("\n\n"),
|
||||
prefixedBody: [inboundUserContext, body].filter(Boolean).join("\n\n"),
|
||||
}).prefixedCommandBody;
|
||||
}
|
||||
|
||||
function createExtraSystemPrompt(params: {
|
||||
ctx: TemplateContext;
|
||||
chatContext: string;
|
||||
intro?: string;
|
||||
}): string {
|
||||
return [
|
||||
buildInboundMetaSystemPrompt(params.ctx),
|
||||
params.chatContext,
|
||||
params.intro,
|
||||
params.ctx.GroupSystemPrompt,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function createAttempt(params: {
|
||||
scenario: PromptScenario;
|
||||
sessionKey: string;
|
||||
}): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
agentId: "main",
|
||||
agentDir: AGENT_DIR,
|
||||
workspaceDir: WORKSPACE_DIR,
|
||||
sessionFile: SESSION_FILE,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: `session-${params.scenario.id}`,
|
||||
runId: `run-${params.scenario.id}`,
|
||||
provider: "codex",
|
||||
modelId: MODEL_ID,
|
||||
model: happyPathModel,
|
||||
prompt: params.scenario.prompt,
|
||||
extraSystemPrompt: params.scenario.extraSystemPrompt,
|
||||
config: baseConfig,
|
||||
thinkLevel: "medium",
|
||||
timeoutMs: 600_000,
|
||||
trigger: params.scenario.trigger,
|
||||
messageProvider: params.scenario.ctx.Provider,
|
||||
messageChannel: params.scenario.ctx.OriginatingChannel,
|
||||
chatType: normalizeChatType(params.scenario.ctx.ChatType),
|
||||
agentAccountId: params.scenario.ctx.AccountId,
|
||||
messageTo: params.scenario.ctx.OriginatingTo,
|
||||
messageThreadId: params.scenario.ctx.MessageThreadId,
|
||||
groupId: params.scenario.ctx.From,
|
||||
groupChannel: params.scenario.ctx.GroupChannel,
|
||||
groupSpace: params.scenario.ctx.GroupSpace,
|
||||
senderId: params.scenario.ctx.SenderId,
|
||||
senderName: params.scenario.ctx.SenderName,
|
||||
senderUsername: params.scenario.ctx.SenderUsername,
|
||||
senderE164: params.scenario.ctx.SenderE164,
|
||||
senderIsOwner: true,
|
||||
currentMessageId: params.scenario.ctx.MessageSid,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
forceMessageTool: true,
|
||||
authStorage: {} as EmbeddedRunAttemptParams["authStorage"],
|
||||
modelRegistry: {} as EmbeddedRunAttemptParams["modelRegistry"],
|
||||
} as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
function createDynamicTools(params: {
|
||||
codexApi: CodexPromptSnapshotApi;
|
||||
ctx: TemplateContext;
|
||||
trigger: "user" | "heartbeat";
|
||||
}): CodexDynamicToolSpec[] {
|
||||
const tools = createOpenClawCodingTools({
|
||||
agentId: "main",
|
||||
workspaceDir: WORKSPACE_DIR,
|
||||
agentDir: AGENT_DIR,
|
||||
sessionKey: params.ctx.SessionKey,
|
||||
sessionId: `session-tools-${params.trigger}`,
|
||||
runId: `run-tools-${params.trigger}`,
|
||||
messageProvider: params.ctx.Provider,
|
||||
agentAccountId: params.ctx.AccountId,
|
||||
messageTo: params.ctx.OriginatingTo,
|
||||
messageThreadId: params.ctx.MessageThreadId,
|
||||
groupId: params.ctx.From,
|
||||
groupChannel: params.ctx.GroupChannel,
|
||||
groupSpace: params.ctx.GroupSpace,
|
||||
senderId: params.ctx.SenderId,
|
||||
senderName: params.ctx.SenderName,
|
||||
senderUsername: params.ctx.SenderUsername,
|
||||
senderE164: params.ctx.SenderE164,
|
||||
senderIsOwner: true,
|
||||
currentMessageId: params.ctx.MessageSid,
|
||||
modelProvider: "openai",
|
||||
modelId: MODEL_ID,
|
||||
modelApi: "responses",
|
||||
modelContextWindowTokens: 272_000,
|
||||
forceMessageTool: true,
|
||||
enableHeartbeatTool: params.trigger === "heartbeat",
|
||||
forceHeartbeatTool: params.trigger === "heartbeat",
|
||||
trigger: params.trigger,
|
||||
config: dynamicToolsConfig,
|
||||
toolConstructionPlan: {
|
||||
includeBaseCodingTools: false,
|
||||
includeShellTools: false,
|
||||
includeChannelTools: false,
|
||||
includeOpenClawTools: true,
|
||||
includePluginTools: false,
|
||||
},
|
||||
});
|
||||
const normalized = normalizeAgentRuntimeTools({
|
||||
tools,
|
||||
runtimePlan: undefined,
|
||||
provider: "codex",
|
||||
config: dynamicToolsConfig,
|
||||
workspaceDir: WORKSPACE_DIR,
|
||||
env: {},
|
||||
modelId: MODEL_ID,
|
||||
modelApi: "responses",
|
||||
model: happyPathModel,
|
||||
});
|
||||
return params.codexApi.createCodexDynamicToolSpecsForPromptSnapshot({
|
||||
tools: normalized.filter((tool) => HAPPY_PATH_TOOL_NAMES.has(tool.name)),
|
||||
directToolNames: ["message"],
|
||||
});
|
||||
}
|
||||
|
||||
function createScenarios(codexApi: CodexPromptSnapshotApi): PromptScenario[] {
|
||||
const telegramDirectCtx: TemplateContext = {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "user:1000001",
|
||||
AccountId: "primary",
|
||||
ChatType: "direct",
|
||||
SessionKey: "agent:main:telegram:direct:1000001",
|
||||
MessageSid: "tg-msg-0001",
|
||||
SenderId: "1000001",
|
||||
SenderName: "Pash",
|
||||
SenderUsername: "pash",
|
||||
Body: "Can you check whether the nightly build finished and tell me what happened?",
|
||||
BodyStripped: "Can you check whether the nightly build finished and tell me what happened?",
|
||||
};
|
||||
const discordGroupCtx: TemplateContext = {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:987654321",
|
||||
From: "guild:123456789/channel:987654321",
|
||||
AccountId: "primary",
|
||||
ChatType: "group",
|
||||
SessionKey: "agent:main:discord:guild:123456789:channel:987654321",
|
||||
MessageSid: "discord-msg-0001",
|
||||
SenderId: "424242",
|
||||
SenderName: "Pash",
|
||||
SenderUsername: "pash",
|
||||
GroupSubject: "OpenClaw maintainers",
|
||||
GroupChannel: "#agent-sandbox",
|
||||
GroupSpace: "OpenClaw",
|
||||
ConversationLabel: "OpenClaw/#agent-sandbox",
|
||||
WasMentioned: true,
|
||||
InboundHistory: [
|
||||
{
|
||||
sender: "Peter",
|
||||
body: "I pushed the Discord-side message-tool bridge.",
|
||||
},
|
||||
{
|
||||
sender: "Pash",
|
||||
body: "@OpenClaw please verify the Codex happy path too.",
|
||||
},
|
||||
],
|
||||
Body: "@OpenClaw can you audit whether this prompt path has conflicting silence instructions?",
|
||||
BodyStripped: "can you audit whether this prompt path has conflicting silence instructions?",
|
||||
};
|
||||
const heartbeatCtx: TemplateContext = {
|
||||
...telegramDirectCtx,
|
||||
MessageSid: "heartbeat-0001",
|
||||
Body: resolveHeartbeatPromptForResponseTool(),
|
||||
BodyStripped: resolveHeartbeatPromptForResponseTool(),
|
||||
};
|
||||
const telegramDirectTools = createDynamicTools({
|
||||
codexApi,
|
||||
ctx: telegramDirectCtx,
|
||||
trigger: "user",
|
||||
});
|
||||
const discordGroupTools = createDynamicTools({
|
||||
codexApi,
|
||||
ctx: discordGroupCtx,
|
||||
trigger: "user",
|
||||
});
|
||||
const heartbeatTools = createDynamicTools({ codexApi, ctx: heartbeatCtx, trigger: "heartbeat" });
|
||||
|
||||
return [
|
||||
{
|
||||
id: "telegram-direct-codex-message-tool",
|
||||
title: "Telegram Direct Codex Message Tool Turn",
|
||||
notes: [
|
||||
"Default happy path: OpenAI model through the Codex harness/runtime, Telegram direct conversation, and message-tool-only visible replies.",
|
||||
"A quiet turn is represented by not calling `message(action=send)`; the normal final assistant text is private to OpenClaw/Codex.",
|
||||
],
|
||||
trigger: "user",
|
||||
ctx: telegramDirectCtx,
|
||||
prompt: createPrompt(
|
||||
telegramDirectCtx,
|
||||
telegramDirectCtx.BodyStripped ?? telegramDirectCtx.Body ?? "",
|
||||
),
|
||||
extraSystemPrompt: createExtraSystemPrompt({
|
||||
ctx: telegramDirectCtx,
|
||||
chatContext: buildDirectChatContext({
|
||||
sessionCtx: telegramDirectCtx,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
}),
|
||||
}),
|
||||
dynamicTools: telegramDirectTools,
|
||||
toolSnapshotFile: "codex-dynamic-tools.telegram-direct.json",
|
||||
},
|
||||
{
|
||||
id: "discord-group-codex-message-tool",
|
||||
title: "Discord Group Codex Message Tool Turn",
|
||||
notes: [
|
||||
"Default happy path: the same Codex agent is mentioned in a Discord group/channel while Telegram can remain the user's primary direct interface.",
|
||||
"Group-visible output must be explicit through the message tool; the model is also told to mostly lurk unless directly addressed or clearly useful.",
|
||||
],
|
||||
trigger: "user",
|
||||
ctx: discordGroupCtx,
|
||||
prompt: createPrompt(
|
||||
discordGroupCtx,
|
||||
discordGroupCtx.BodyStripped ?? discordGroupCtx.Body ?? "",
|
||||
),
|
||||
extraSystemPrompt: createExtraSystemPrompt({
|
||||
ctx: discordGroupCtx,
|
||||
chatContext: buildGroupChatContext({
|
||||
sessionCtx: discordGroupCtx,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
silentReplyPolicy: "allow",
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
}),
|
||||
intro: buildGroupIntro({
|
||||
defaultActivation: "mention",
|
||||
}),
|
||||
}),
|
||||
dynamicTools: discordGroupTools,
|
||||
toolSnapshotFile: "codex-dynamic-tools.discord-group.json",
|
||||
},
|
||||
{
|
||||
id: "telegram-heartbeat-codex-tool",
|
||||
title: "Telegram Direct Codex Heartbeat Tool Turn",
|
||||
notes: [
|
||||
"Heartbeat happy path: Codex receives the structured `heartbeat_respond` dynamic tool in the searchable catalog instead of the initial tool context.",
|
||||
"The heartbeat tool still carries the notify/no-notify decision, outcome, summary, and optional notification text instead of relying only on final-text parsing.",
|
||||
],
|
||||
trigger: "heartbeat",
|
||||
ctx: heartbeatCtx,
|
||||
prompt: createPrompt(heartbeatCtx, heartbeatCtx.BodyStripped ?? heartbeatCtx.Body ?? ""),
|
||||
extraSystemPrompt: createExtraSystemPrompt({
|
||||
ctx: heartbeatCtx,
|
||||
chatContext: buildDirectChatContext({
|
||||
sessionCtx: heartbeatCtx,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
}),
|
||||
}),
|
||||
dynamicTools: heartbeatTools,
|
||||
toolSnapshotFile: "codex-dynamic-tools.heartbeat-turn.json",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function selectedThreadStartParams(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...value,
|
||||
developerInstructions: "<see Reconstructed Model-Bound Prompt Layers>",
|
||||
dynamicTools: Array.isArray(value.dynamicTools)
|
||||
? flattenCodexDynamicToolSpecs(value.dynamicTools as CodexDynamicToolSpec[]).map(
|
||||
(tool) => tool.name,
|
||||
)
|
||||
: value.dynamicTools,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedThreadResumeParams(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...value,
|
||||
developerInstructions: "<see Reconstructed Model-Bound Prompt Layers>",
|
||||
};
|
||||
}
|
||||
|
||||
function selectedTurnStartParams(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...value,
|
||||
input: Array.isArray(value.input)
|
||||
? value.input.map((item) =>
|
||||
item && typeof item === "object" && "type" in item
|
||||
? {
|
||||
...item,
|
||||
text:
|
||||
typeof (item as { text?: unknown }).text === "string"
|
||||
? "<see Reconstructed Model-Bound Prompt Layers>"
|
||||
: (item as { text?: unknown }).text,
|
||||
}
|
||||
: item,
|
||||
)
|
||||
: value.input,
|
||||
};
|
||||
}
|
||||
|
||||
function renderModelBoundPromptLayers(params: {
|
||||
scenario: PromptScenario;
|
||||
codexSnapshot: ReturnType<CodexPromptSnapshotApi["buildCodexHarnessPromptSnapshot"]>;
|
||||
dynamicToolsJson: string;
|
||||
}): string[] {
|
||||
const codexModelInstructions = readFixture(CODEX_MODEL_PROMPT_FIXTURE_PATH);
|
||||
const codexModelSource = JSON.parse(readFixture(CODEX_MODEL_PROMPT_SOURCE_PATH)) as unknown;
|
||||
const codexConfigInstructions =
|
||||
typeof params.codexSnapshot.threadStartParams.config === "object" &&
|
||||
params.codexSnapshot.threadStartParams.config &&
|
||||
"instructions" in params.codexSnapshot.threadStartParams.config &&
|
||||
typeof params.codexSnapshot.threadStartParams.config.instructions === "string"
|
||||
? params.codexSnapshot.threadStartParams.config.instructions
|
||||
: "";
|
||||
const openClawDeveloperInstructions = params.codexSnapshot.developerInstructions;
|
||||
const codexCollaborationModeInstructions =
|
||||
typeof params.codexSnapshot.turnStartParams.collaborationMode?.settings
|
||||
?.developer_instructions === "string"
|
||||
? params.codexSnapshot.turnStartParams.collaborationMode.settings.developer_instructions
|
||||
: "";
|
||||
const turnInputText = readCodexTurnInputText(params.codexSnapshot.turnStartParams);
|
||||
const textOnlyTotal = [
|
||||
codexModelInstructions,
|
||||
CODEX_YOLO_PERMISSION_INSTRUCTIONS,
|
||||
codexConfigInstructions,
|
||||
openClawDeveloperInstructions,
|
||||
codexCollaborationModeInstructions,
|
||||
turnInputText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const totalWithDynamicToolJson = [textOnlyTotal, params.dynamicToolsJson].join("\n\n");
|
||||
|
||||
return [
|
||||
"## Reconstructed Model-Bound Prompt Layers",
|
||||
"",
|
||||
"This is the deterministic model-bound layer stack OpenClaw can snapshot for the Codex happy path. It uses a pinned Codex `gpt-5.5` prompt fixture generated from Codex's model catalog/cache shape, then adds the Codex permission developer text, Codex thread config instructions when present, OpenClaw developer instructions, turn-scoped collaboration-mode instructions when OpenClaw provides them, turn input with OpenClaw runtime context, and the OpenClaw dynamic tool catalog. Codex can still add runtime-owned context such as native workspace `AGENTS.md`, environment context, memories, app/plugin instructions, and built-in collaboration-mode instructions inside the Codex runtime.",
|
||||
"",
|
||||
"### Layer Metadata",
|
||||
"",
|
||||
markdownFence(
|
||||
"json",
|
||||
stableJson({
|
||||
codexModelInstructions: {
|
||||
fixture: CODEX_MODEL_PROMPT_FIXTURE_PATH,
|
||||
source: codexModelSource,
|
||||
},
|
||||
codexPermissions: {
|
||||
sandbox: "danger-full-access",
|
||||
approvalPolicy: "never",
|
||||
networkAccess: "enabled",
|
||||
},
|
||||
openClawRuntime: {
|
||||
configInstructionsFrom: "extensions/codex app-server thread/start config.instructions",
|
||||
workspaceBootstrapContextFrom:
|
||||
"extensions/codex app-server turn/start input OpenClaw runtime context",
|
||||
developerInstructionsFrom:
|
||||
"extensions/codex app-server thread/start developerInstructions",
|
||||
collaborationModeDeveloperInstructionsFrom:
|
||||
"extensions/codex app-server turn/start collaborationMode.settings.developer_instructions",
|
||||
userInputFrom: "extensions/codex app-server turn/start input",
|
||||
dynamicToolsFrom: params.scenario.toolSnapshotFile,
|
||||
},
|
||||
limitations: [
|
||||
"This is a reconstructed prompt-layer snapshot, not a byte-for-byte raw OpenAI request captured from Codex core.",
|
||||
"Codex-owned workspace AGENTS.md, environment context, memories, app/plugin instructions, built-in Default collaboration-mode instructions, and provider tool serialization are still runtime-owned gaps until Codex exposes a rendered-prompt inspection API.",
|
||||
],
|
||||
}),
|
||||
),
|
||||
"",
|
||||
"### Rough Text Token Estimates",
|
||||
"",
|
||||
markdownFence(
|
||||
"json",
|
||||
stableJson({
|
||||
codexModelInstructions: textStats(codexModelInstructions),
|
||||
codexPermissionDeveloperInstructions: textStats(CODEX_YOLO_PERMISSION_INSTRUCTIONS),
|
||||
codexWorkspaceBootstrapConfigInstructions: textStats(codexConfigInstructions),
|
||||
openClawDeveloperInstructions: textStats(openClawDeveloperInstructions),
|
||||
codexCollaborationModeDeveloperInstructions: textStats(codexCollaborationModeInstructions),
|
||||
userInputText: textStats(turnInputText),
|
||||
dynamicToolsJson: textStats(params.dynamicToolsJson),
|
||||
totalTextOnly: textStats(textOnlyTotal),
|
||||
totalWithDynamicToolsJson: textStats(totalWithDynamicToolJson),
|
||||
}),
|
||||
),
|
||||
"",
|
||||
`### System: Codex Model Instructions (${MODEL_ID}, ${CODEX_PROMPT_PERSONALITY})`,
|
||||
"",
|
||||
markdownFence("text", codexModelInstructions),
|
||||
"",
|
||||
"### Developer: Codex Permission Instructions",
|
||||
"",
|
||||
markdownFence("text", CODEX_YOLO_PERMISSION_INSTRUCTIONS),
|
||||
"",
|
||||
"### User: Codex Config Instructions",
|
||||
"",
|
||||
markdownFence("text", codexConfigInstructions),
|
||||
"",
|
||||
"### Developer: OpenClaw Runtime Instructions",
|
||||
"",
|
||||
markdownFence("text", openClawDeveloperInstructions),
|
||||
"",
|
||||
"### Developer: Codex Collaboration Mode Instructions",
|
||||
"",
|
||||
codexCollaborationModeInstructions
|
||||
? markdownFence("text", codexCollaborationModeInstructions)
|
||||
: "This turn asks Codex app-server to resolve its built-in Default collaboration-mode instructions at runtime.",
|
||||
"",
|
||||
"### User: Turn Input Text",
|
||||
"",
|
||||
markdownFence("text", turnInputText),
|
||||
"",
|
||||
"### Tools: Dynamic Tool Catalog",
|
||||
"",
|
||||
`Full JSON: \`${params.scenario.toolSnapshotFile}\``,
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function readCodexTurnInputText(turnStartParams: { input?: unknown }): string {
|
||||
const input = turnStartParams.input;
|
||||
if (!Array.isArray(input)) {
|
||||
return "";
|
||||
}
|
||||
const firstText = input.find(
|
||||
(item): item is { text: string } =>
|
||||
item !== null &&
|
||||
typeof item === "object" &&
|
||||
typeof (item as { text?: unknown }).text === "string",
|
||||
);
|
||||
return firstText?.text ?? "";
|
||||
}
|
||||
|
||||
function buildCodexOpenClawRuntimeContext(): string {
|
||||
return [
|
||||
"OpenClaw runtime context for this turn:",
|
||||
"Treat this OpenClaw-provided context as supporting project/user reference for the current request.",
|
||||
"",
|
||||
"## OpenClaw Workspace Context",
|
||||
"",
|
||||
CODEX_WORKSPACE_BOOTSTRAP_PROMPT_CONTEXT,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function prependCodexOpenClawRuntimeContext(prompt: string): string {
|
||||
return [buildCodexOpenClawRuntimeContext(), "", "Current user request:", prompt].join("\n");
|
||||
}
|
||||
|
||||
function renderScenarioSnapshot(
|
||||
codexApi: CodexPromptSnapshotApi,
|
||||
scenario: PromptScenario,
|
||||
): string {
|
||||
const attempt = createAttempt({
|
||||
scenario,
|
||||
sessionKey: scenario.ctx.SessionKey ?? `agent:main:${scenario.id}`,
|
||||
});
|
||||
const appServer = codexApi.resolveCodexPromptSnapshotAppServerOptions();
|
||||
const codexTurnPromptText = prependCodexOpenClawRuntimeContext(scenario.prompt);
|
||||
const codexSnapshot = codexApi.buildCodexHarnessPromptSnapshot({
|
||||
attempt,
|
||||
cwd: WORKSPACE_DIR,
|
||||
threadId: `thread-${scenario.id}`,
|
||||
dynamicTools: scenario.dynamicTools,
|
||||
appServer,
|
||||
config: CODEX_PROMPT_SNAPSHOT_THREAD_CONFIG,
|
||||
promptText: codexTurnPromptText,
|
||||
developerInstructionAdditions: CODEX_WORKSPACE_THREAD_DEVELOPER_INSTRUCTIONS,
|
||||
turnScopedDeveloperInstructions: CODEX_WORKSPACE_TURN_SCOPED_DEVELOPER_INSTRUCTIONS,
|
||||
heartbeatCollaborationInstructions:
|
||||
scenario.trigger === "heartbeat" ? CODEX_HEARTBEAT_COLLABORATION_INSTRUCTIONS : undefined,
|
||||
});
|
||||
const dynamicToolFunctions = flattenCodexDynamicToolSpecs(scenario.dynamicTools);
|
||||
const criticalToolSpecs = dynamicToolFunctions.filter((tool) =>
|
||||
["message", "heartbeat_respond"].includes(tool.name),
|
||||
);
|
||||
const dynamicToolsJson = stableJson(scenario.dynamicTools);
|
||||
return [
|
||||
`# ${scenario.title}`,
|
||||
"",
|
||||
"<!-- Generated by `pnpm prompt:snapshots:gen`. Do not edit by hand. -->",
|
||||
"",
|
||||
"## Scope",
|
||||
"",
|
||||
...scenario.notes.map((note) => `- ${note}`),
|
||||
"- This captures the OpenClaw-owned Codex app-server inputs and reconstructs the stable Codex model/permission layers from committed Codex prompt fixtures.",
|
||||
"- This also simulates Codex workspace bootstrap routing: `TOOLS.md` as inherited developer instructions, `SOUL.md`, `IDENTITY.md`, and `USER.md` as turn-scoped collaboration instructions, `MEMORY.md` in turn input, and `HEARTBEAT.md` as a heartbeat-only file pointer.",
|
||||
"",
|
||||
"## Scenario Metadata",
|
||||
"",
|
||||
markdownFence(
|
||||
"json",
|
||||
stableJson({
|
||||
harness: "codex",
|
||||
runtime: "codex_app_server",
|
||||
modelProvider: "openai",
|
||||
model: MODEL_ID,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
trigger: scenario.trigger,
|
||||
channel: scenario.ctx.Provider,
|
||||
chatType: scenario.ctx.ChatType,
|
||||
toolSnapshot: scenario.toolSnapshotFile,
|
||||
codexModelInstructionsFixture: CODEX_MODEL_PROMPT_FIXTURE_PATH,
|
||||
simulatedWorkspaceBootstrapFiles: CODEX_WORKSPACE_BOOTSTRAP_CONTEXT_FILES.map(
|
||||
(file) => file.path,
|
||||
),
|
||||
simulatedWorkspaceDeveloperInstructionFiles:
|
||||
CODEX_WORKSPACE_THREAD_DEVELOPER_CONTEXT_FILES.map((file) => file.path),
|
||||
simulatedWorkspaceTurnScopedDeveloperInstructionFiles:
|
||||
CODEX_WORKSPACE_TURN_SCOPED_DEVELOPER_CONTEXT_FILES.map((file) => file.path),
|
||||
simulatedHeartbeatWorkspaceFile: CODEX_HEARTBEAT_CONTEXT_FILE.path,
|
||||
}),
|
||||
),
|
||||
"",
|
||||
"## Effective OpenClaw Config",
|
||||
"",
|
||||
markdownFence("json", stableJson(baseConfig)),
|
||||
"",
|
||||
"## Thread Start Params",
|
||||
"",
|
||||
markdownFence("json", stableJson(selectedThreadStartParams(codexSnapshot.threadStartParams))),
|
||||
"",
|
||||
"## Thread Resume Params",
|
||||
"",
|
||||
markdownFence("json", stableJson(selectedThreadResumeParams(codexSnapshot.threadResumeParams))),
|
||||
"",
|
||||
"## Turn Start Params",
|
||||
"",
|
||||
markdownFence("json", stableJson(selectedTurnStartParams(codexSnapshot.turnStartParams))),
|
||||
"",
|
||||
...renderModelBoundPromptLayers({ scenario, codexSnapshot, dynamicToolsJson }),
|
||||
"## Dynamic Tool Names",
|
||||
"",
|
||||
markdownFence("json", stableJson(dynamicToolFunctions.map((tool) => tool.name))),
|
||||
"",
|
||||
"## Critical Visible-Reply Tool Specs",
|
||||
"",
|
||||
markdownFence("json", stableJson(criticalToolSpecs)),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderReadme(scenarios: PromptScenario[]): string {
|
||||
return [
|
||||
"# Codex Happy Path Prompt Snapshots",
|
||||
"",
|
||||
"<!-- Generated by `pnpm prompt:snapshots:gen`. Do not edit by hand. -->",
|
||||
"",
|
||||
"These fixtures capture the default OpenAI/Codex happy path for prompt review:",
|
||||
"",
|
||||
"- OpenAI model through the Codex harness and Codex app-server runtime.",
|
||||
"- Codex harness default coverage for tool-only visible source replies.",
|
||||
"- Telegram direct chat, Discord group chat, and a heartbeat turn with `heartbeat_respond` available through searchable dynamic tools.",
|
||||
"",
|
||||
"The Markdown files show selected app-server thread/turn params plus a reconstructed model-bound prompt layer stack: Codex `gpt-5.5` model instructions from a pinned Codex model catalog fixture, Codex permission developer instructions for the happy-path yolo profile, OpenClaw developer instructions, turn input with simulated OpenClaw workspace bootstrap runtime context, heartbeat collaboration-mode guidance when applicable, and references to the complete dynamic tool catalog.",
|
||||
"",
|
||||
"The workspace bootstrap simulation includes dummy workspace contents so prompt reviewers can see how OpenClaw routes stable profile files into Codex developer instructions, keeps `MEMORY.md` in turn input, and points heartbeat turns at `HEARTBEAT.md` without inlining it. `AGENTS.md` is intentionally not repeated here because Codex loads it natively.",
|
||||
"",
|
||||
"The tool catalog is pinned to the canonical happy-path OpenClaw tools so optional locally installed plugin tools do not create fixture churn.",
|
||||
"",
|
||||
"The Codex model prompt fixture is generated from the same Codex model catalog/cache shape that the Codex runtime uses for remote model metadata. Regenerate it from Codex's runtime cache or, when present, a local Codex checkout with:",
|
||||
"",
|
||||
markdownFence("sh", "pnpm prompt:snapshots:sync-codex-model"),
|
||||
"",
|
||||
"These snapshots are still not a byte-for-byte raw OpenAI request capture. Codex-owned native `AGENTS.md`, environment context, memories, app/plugin instructions, and built-in collaboration-mode instructions can be added inside the Codex runtime after OpenClaw sends thread and turn params.",
|
||||
"",
|
||||
"Regenerate with:",
|
||||
"",
|
||||
markdownFence("sh", "pnpm prompt:snapshots:gen"),
|
||||
"",
|
||||
"Check for drift with:",
|
||||
"",
|
||||
markdownFence("sh", "pnpm prompt:snapshots:check"),
|
||||
"",
|
||||
"Snapshots:",
|
||||
"",
|
||||
...scenarios.map((scenario) => `- ${scenario.id}.md`),
|
||||
...scenarios.map((scenario) => `- ${scenario.toolSnapshotFile}`),
|
||||
"",
|
||||
"Codex model prompt fixtures:",
|
||||
"",
|
||||
`- ${path.relative(
|
||||
CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR,
|
||||
CODEX_MODEL_PROMPT_FIXTURE_PATH,
|
||||
)}`,
|
||||
`- ${path.relative(
|
||||
CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR,
|
||||
CODEX_MODEL_PROMPT_SOURCE_PATH,
|
||||
)}`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Build all Codex happy-path prompt snapshot files without writing them. */
|
||||
export async function createHappyPathPromptSnapshotFiles(): Promise<PromptSnapshotFile[]> {
|
||||
const codexApi = await loadCodexPromptSnapshotApi();
|
||||
const scenarios = createScenarios(codexApi);
|
||||
const files = [
|
||||
{
|
||||
path: path.join(CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR, "README.md"),
|
||||
content: renderReadme(scenarios),
|
||||
},
|
||||
...scenarios.map((scenario) => ({
|
||||
path: path.join(CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR, `${scenario.id}.md`),
|
||||
content: renderScenarioSnapshot(codexApi, scenario),
|
||||
})),
|
||||
...scenarios.map((scenario) => ({
|
||||
path: path.join(CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR, scenario.toolSnapshotFile),
|
||||
content: stableJson(scenario.dynamicTools),
|
||||
})),
|
||||
];
|
||||
return files.map((file) => ({
|
||||
path: file.path,
|
||||
content: file.content.endsWith("\n") ? file.content : `${file.content}\n`,
|
||||
}));
|
||||
}
|
||||
16
test/helpers/agents/llm-stream-simple-mock.ts
Normal file
16
test/helpers/agents/llm-stream-simple-mock.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Simple LLM stream mock helper builds deterministic streamed responses.
|
||||
import { vi } from "vitest";
|
||||
|
||||
type LlmMockModule = Record<string, unknown>;
|
||||
|
||||
export function createLlmStreamSimpleMock(): LlmMockModule {
|
||||
return {
|
||||
streamSimple: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
result: vi.fn(async () => undefined),
|
||||
[Symbol.asyncIterator]: vi.fn(async function* () {
|
||||
// Minimal async stream shape for wrappers that patch iteration/result.
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
795
test/helpers/agents/prompt-composition-scenarios.ts
Normal file
795
test/helpers/agents/prompt-composition-scenarios.ts
Normal file
@@ -0,0 +1,795 @@
|
||||
// Prompt composition scenarios build reusable agent prompt fixtures.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
appendBootstrapPromptWarning,
|
||||
analyzeBootstrapBudget,
|
||||
buildBootstrapInjectionStats,
|
||||
buildBootstrapPromptWarning,
|
||||
} from "../../../src/agents/bootstrap-budget.js";
|
||||
import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-files.js";
|
||||
import { buildCurrentInboundPrompt } from "../../../src/agents/embedded-agent-runner/run/runtime-context-prompt.js";
|
||||
import { buildEmbeddedSystemPrompt } from "../../../src/agents/embedded-agent-runner/system-prompt.js";
|
||||
import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js";
|
||||
import { createStubTool } from "../../../src/agents/test-helpers/agent-tool-stubs.js";
|
||||
import {
|
||||
buildDirectChatContext,
|
||||
buildGroupChatContext,
|
||||
buildGroupIntro,
|
||||
} from "../../../src/auto-reply/reply/groups.js";
|
||||
import {
|
||||
buildInboundMetaSystemPrompt,
|
||||
buildInboundUserContextPrefix,
|
||||
} from "../../../src/auto-reply/reply/inbound-meta.js";
|
||||
import { buildReplyPromptEnvelope } from "../../../src/auto-reply/reply/prompt-prelude.js";
|
||||
import type { TemplateContext } from "../../../src/auto-reply/templating.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../../../src/test-helpers/workspace.js";
|
||||
|
||||
// Prompt composition scenarios for system/body prompt stability tests.
|
||||
|
||||
/** One turn in a prompt composition scenario. */
|
||||
export type PromptScenarioTurn = {
|
||||
id: string;
|
||||
label: string;
|
||||
systemPrompt: string;
|
||||
bodyPrompt: string;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
/** Multi-turn prompt composition scenario fixture. */
|
||||
export type PromptScenario = {
|
||||
scenario: string;
|
||||
focus: string;
|
||||
expectedStableSystemAfterTurnIds: string[];
|
||||
turns: PromptScenarioTurn[];
|
||||
};
|
||||
|
||||
function buildCommonSystemParams(workspaceDir: string) {
|
||||
const toolNames = [
|
||||
"bash",
|
||||
"read",
|
||||
"edit",
|
||||
"grep",
|
||||
"glob",
|
||||
"message",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"web_search",
|
||||
"x_search",
|
||||
"web_fetch",
|
||||
];
|
||||
return {
|
||||
runtimeInfo: {
|
||||
agentId: "main",
|
||||
host: "cache-lab",
|
||||
repoRoot: workspaceDir,
|
||||
os: "Darwin 24.0.0",
|
||||
arch: "arm64",
|
||||
node: process.version,
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
defaultModel: "anthropic/claude-sonnet-4-6",
|
||||
shell: "zsh",
|
||||
},
|
||||
userTimezone: "America/Los_Angeles",
|
||||
userTime: "Monday, March 16th, 2026 - 9:00 PM",
|
||||
userTimeFormat: "12" as const,
|
||||
toolNames,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
extraSystemPrompt?: string;
|
||||
skillsPrompt?: string;
|
||||
reactionGuidance?: { level: "minimal" | "extensive"; channel: string };
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
silentReplyPromptMode?: "generic" | "none";
|
||||
}) {
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames } =
|
||||
buildCommonSystemParams(params.workspaceDir);
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
runtimeInfo,
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
toolNames,
|
||||
modelAliasLines: [],
|
||||
promptMode: "full",
|
||||
silentReplyPromptMode: params.silentReplyPromptMode,
|
||||
acpEnabled: true,
|
||||
skillsPrompt: params.skillsPrompt,
|
||||
reactionGuidance: params.reactionGuidance,
|
||||
contextFiles: params.contextFiles,
|
||||
});
|
||||
}
|
||||
|
||||
function buildAutoReplyBody(params: { ctx: TemplateContext; body: string; eventLine?: string }) {
|
||||
return [params.eventLine, buildInboundUserContextPrefix(params.ctx), params.body]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildAutoReplyModelPrompt(params: { ctx: TemplateContext; body: string }): string {
|
||||
const inboundUserContext = buildInboundUserContextPrefix(params.ctx);
|
||||
const envelope = buildReplyPromptEnvelope({
|
||||
ctx: params.ctx,
|
||||
sessionCtx: params.ctx,
|
||||
baseBody: params.body,
|
||||
hasUserBody: true,
|
||||
inboundUserContext,
|
||||
isBareSessionReset: false,
|
||||
startupAction: "new",
|
||||
});
|
||||
return buildCurrentInboundPrompt({
|
||||
context: envelope.currentInboundContext,
|
||||
prompt: envelope.queuedBody,
|
||||
});
|
||||
}
|
||||
|
||||
async function readContextFiles(workspaceDir: string, fileNames: string[]) {
|
||||
return Promise.all(
|
||||
fileNames.map(async (fileName) => ({
|
||||
path: fileName,
|
||||
content: await fs.readFile(path.join(workspaceDir, fileName), "utf-8"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAutoReplySystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
sessionCtx: TemplateContext;
|
||||
includeGroupChatContext?: boolean;
|
||||
includeGroupIntro?: boolean;
|
||||
groupSystemPrompt?: string;
|
||||
}) {
|
||||
const extraSystemPromptParts = [
|
||||
buildInboundMetaSystemPrompt(params.sessionCtx),
|
||||
params.sessionCtx.ChatType === "direct" || params.sessionCtx.ChatType === "dm"
|
||||
? buildDirectChatContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
})
|
||||
: "",
|
||||
params.includeGroupChatContext
|
||||
? buildGroupChatContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
silentReplyPolicy: "allow",
|
||||
})
|
||||
: "",
|
||||
params.includeGroupIntro
|
||||
? buildGroupIntro({
|
||||
defaultActivation: "mention",
|
||||
})
|
||||
: "",
|
||||
params.groupSystemPrompt?.trim() ?? "",
|
||||
].filter(Boolean);
|
||||
return buildSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
|
||||
silentReplyPromptMode:
|
||||
params.sessionCtx.ChatType === "direct" ||
|
||||
params.sessionCtx.ChatType === "dm" ||
|
||||
params.includeGroupChatContext
|
||||
? "none"
|
||||
: "generic",
|
||||
});
|
||||
}
|
||||
|
||||
function buildToolRichSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
skillsPrompt: string;
|
||||
contextFiles: Array<{ path: string; content: string }>;
|
||||
}) {
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildCommonSystemParams(
|
||||
params.workspaceDir,
|
||||
);
|
||||
const tools = [
|
||||
"bash",
|
||||
"read",
|
||||
"edit",
|
||||
"grep",
|
||||
"glob",
|
||||
"message",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"web_search",
|
||||
"x_search",
|
||||
"web_fetch",
|
||||
].map((name) => Object.assign({}, createStubTool(name), { description: `${name} tool` }));
|
||||
return buildEmbeddedSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
reasoningTagHint: false,
|
||||
runtimeInfo,
|
||||
tools,
|
||||
modelAliasLines: [],
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
acpEnabled: true,
|
||||
skillsPrompt: params.skillsPrompt,
|
||||
reactionGuidance: { level: "extensive", channel: "Telegram" },
|
||||
contextFiles: params.contextFiles,
|
||||
});
|
||||
}
|
||||
|
||||
function createDirectScenario(workspaceDir: string): PromptScenario {
|
||||
const baseCtx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "D123",
|
||||
AccountId: "A1",
|
||||
ChatType: "dm",
|
||||
SenderId: "U1",
|
||||
SenderName: "Alice",
|
||||
Body: "hi",
|
||||
BodyStripped: "hi",
|
||||
};
|
||||
return {
|
||||
scenario: "auto-reply-direct",
|
||||
focus:
|
||||
"Normal direct-chat turns with ids, reply context, think hint, and runtime event body injection",
|
||||
expectedStableSystemAfterTurnIds: ["t2", "t3", "t4"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "Direct turn with reply context",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m1",
|
||||
ReplyToId: "r1",
|
||||
ReplyToBody: "prior message",
|
||||
WasMentioned: true,
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m1",
|
||||
ReplyToId: "r1",
|
||||
ReplyToBody: "prior message",
|
||||
WasMentioned: true,
|
||||
},
|
||||
body: "Please summarize yesterday's decision.",
|
||||
}),
|
||||
notes: ["Direct chat baseline", "Per-message ids and reply context change in body only"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Direct turn with new message id",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m2",
|
||||
ReplyToId: "r2",
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m2",
|
||||
ReplyToId: "r2",
|
||||
},
|
||||
body: "Now open the read tool and inspect AGENTS.md.",
|
||||
}),
|
||||
notes: ["Steady-state direct turn", "No runtime event"],
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
label: "Direct turn with runtime event and think hint",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m3",
|
||||
ReplyToId: "r3",
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m3",
|
||||
ReplyToId: "r3",
|
||||
},
|
||||
eventLine: "System: [t] Model switched.",
|
||||
body: "low use tools if needed and tell me which file controls startup behavior",
|
||||
}),
|
||||
notes: ["Touches runtime event body path", "Touches think-hint parsing path"],
|
||||
},
|
||||
{
|
||||
id: "t4",
|
||||
label: "Direct turn after runtime event",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m4",
|
||||
ReplyToId: "r4",
|
||||
},
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "m4",
|
||||
ReplyToId: "r4",
|
||||
},
|
||||
body: "Repeat the startup file path only.",
|
||||
}),
|
||||
notes: ["Checks steady-state after event turn"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupScenario(workspaceDir: string): PromptScenario {
|
||||
const baseCtx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "C123",
|
||||
AccountId: "A1",
|
||||
ChatType: "group",
|
||||
GroupSubject: "ops",
|
||||
GroupChannel: "#ops",
|
||||
GroupMembers: "Bob, Cara, Dan, Eve",
|
||||
SenderId: "U2",
|
||||
SenderName: "Bob",
|
||||
Body: "hi",
|
||||
BodyStripped: "hi",
|
||||
};
|
||||
return {
|
||||
scenario: "auto-reply-group",
|
||||
focus: "Group chat bootstrap, steady state, and runtime event turns",
|
||||
expectedStableSystemAfterTurnIds: ["t2", "t3"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "First group turn with session-stable intro",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g1",
|
||||
WasMentioned: true,
|
||||
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
|
||||
},
|
||||
includeGroupChatContext: true,
|
||||
includeGroupIntro: true,
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g1",
|
||||
WasMentioned: true,
|
||||
InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }],
|
||||
},
|
||||
body: "Can you investigate this issue?",
|
||||
}),
|
||||
notes: ["Group intro belongs to the session-stable system prompt"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Steady-state group turn",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g2",
|
||||
WasMentioned: false,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
],
|
||||
},
|
||||
includeGroupChatContext: true,
|
||||
includeGroupIntro: true,
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g2",
|
||||
WasMentioned: false,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
],
|
||||
},
|
||||
body: "Give a short update.",
|
||||
}),
|
||||
notes: ["Group intro remains stable after turn one"],
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
label: "Group turn with runtime event",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g2",
|
||||
WasMentioned: false,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
],
|
||||
},
|
||||
includeGroupChatContext: true,
|
||||
includeGroupIntro: true,
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyBody({
|
||||
ctx: {
|
||||
...baseCtx,
|
||||
MessageSid: "g3",
|
||||
WasMentioned: true,
|
||||
InboundHistory: [
|
||||
{ sender: "Cara", timestamp: 1, body: "status?" },
|
||||
{ sender: "Dan", timestamp: 2, body: "please help" },
|
||||
{ sender: "Eve", timestamp: 3, body: "what changed?" },
|
||||
],
|
||||
},
|
||||
eventLine: "System: [t] Node connected.",
|
||||
body: "Tell the room whether tools are available.",
|
||||
}),
|
||||
notes: ["Runtime event lands in body", "System prompt should stay stable vs t2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createDiscordBoundaryScenario(workspaceDir: string): PromptScenario {
|
||||
const body = "Please summarize the deploy log.";
|
||||
const baseCtx: TemplateContext = {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:987654321",
|
||||
AccountId: "A1",
|
||||
ChatType: "channel",
|
||||
GroupSubject: "#ops-bridge",
|
||||
GroupChannel: "#ops-bridge",
|
||||
GroupSpace: "guild-123",
|
||||
SenderId: "U3",
|
||||
SenderName: "Cael",
|
||||
MessageSid: "1503084621145964846",
|
||||
Body: body,
|
||||
BodyStripped: body,
|
||||
UntrustedStructuredContext: [
|
||||
{
|
||||
label: "Discord channel metadata",
|
||||
source: "discord",
|
||||
type: "channel_metadata",
|
||||
payload: {
|
||||
topic: "Deploy coordination",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
scenario: "auto-reply-discord-boundary",
|
||||
focus:
|
||||
"Discord inbound body remains one user turn while supplemental context is structured metadata",
|
||||
expectedStableSystemAfterTurnIds: [],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "Discord turn with channel metadata",
|
||||
systemPrompt: buildAutoReplySystemPrompt({
|
||||
workspaceDir,
|
||||
sessionCtx: baseCtx,
|
||||
includeGroupChatContext: true,
|
||||
}),
|
||||
bodyPrompt: buildAutoReplyModelPrompt({
|
||||
ctx: baseCtx,
|
||||
body,
|
||||
}),
|
||||
notes: [
|
||||
"Inbound body should appear once in the model-bound prompt",
|
||||
"Channel metadata should not use raw EXTERNAL_UNTRUSTED_CONTENT wrappers",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function createToolRichScenario(workspaceDir: string): Promise<PromptScenario> {
|
||||
const skillsPrompt = [
|
||||
"<available_skills>",
|
||||
"<skill><name>checks</name><description>Run checks before landing changes.</description><location>/skills/checks/SKILL.md</location></skill>",
|
||||
"<skill><name>release</name><description>Release OpenClaw safely.</description><location>/skills/release/SKILL.md</location></skill>",
|
||||
"</available_skills>",
|
||||
].join("\n");
|
||||
const contextFiles = await readContextFiles(workspaceDir, ["AGENTS.md", "TOOLS.md", "SOUL.md"]);
|
||||
const systemPrompt = buildToolRichSystemPrompt({
|
||||
workspaceDir,
|
||||
skillsPrompt,
|
||||
contextFiles,
|
||||
});
|
||||
return {
|
||||
scenario: "tool-rich-agent-run",
|
||||
focus:
|
||||
"Tool-enabled system prompt with skills, reactions, workspace bootstrap, and a follow-up after fictional tool calls",
|
||||
expectedStableSystemAfterTurnIds: ["t2"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "Tool-rich turn asking for search, read, and file edits",
|
||||
systemPrompt,
|
||||
bodyPrompt: [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"```json",
|
||||
JSON.stringify({ message_id: "tool-1", sender_id: "U9", was_mentioned: true }, null, 2),
|
||||
"```",
|
||||
"",
|
||||
"high Search the workspace, read AGENTS.md, inspect the failing test, and propose a patch.",
|
||||
].join("\n"),
|
||||
notes: ["Touches tool list in system prompt", "Touches high-thinking hint in body"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Follow-up after a fictional tool call",
|
||||
systemPrompt,
|
||||
bodyPrompt: [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"```json",
|
||||
JSON.stringify({ message_id: "tool-2", sender_id: "U9" }, null, 2),
|
||||
"```",
|
||||
"",
|
||||
"Tool transcript summary (untrusted, for context):",
|
||||
"```json",
|
||||
JSON.stringify(
|
||||
[
|
||||
{ role: "assistant", action: "tool_use", name: "read", target: "AGENTS.md" },
|
||||
{ role: "tool", name: "read", result: "Loaded AGENTS.md" },
|
||||
{ role: "assistant", action: "tool_use", name: "grep", target: "failing test" },
|
||||
{ role: "tool", name: "grep", result: "Matched src/foo.ts:42" },
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"```",
|
||||
"",
|
||||
"Continue and explain the root cause.",
|
||||
].join("\n"),
|
||||
notes: ["Simulates tool-call-heavy conversation", "System prompt should stay stable"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function createBootstrapWarningScenario(workspaceDir: string): Promise<PromptScenario> {
|
||||
const bootstrapConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 1_500,
|
||||
bootstrapTotalMaxChars: 2_200,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const largeAgents = "# AGENTS.md\n\n" + "Rules.\n".repeat(5_000);
|
||||
const largeTools = "# TOOLS.md\n\n" + "Notes.\n".repeat(3_000);
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: largeAgents });
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: largeTools });
|
||||
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
|
||||
workspaceDir,
|
||||
config: bootstrapConfig,
|
||||
});
|
||||
const analysis = analyzeBootstrapBudget({
|
||||
files: buildBootstrapInjectionStats({
|
||||
bootstrapFiles,
|
||||
injectedFiles: contextFiles,
|
||||
}),
|
||||
bootstrapMaxChars: bootstrapConfig.agents.defaults.bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars: bootstrapConfig.agents.defaults.bootstrapTotalMaxChars,
|
||||
});
|
||||
if (!analysis.hasTruncation) {
|
||||
throw new Error("bootstrap-warning scenario expected truncated bootstrap context");
|
||||
}
|
||||
const warningFirst = buildBootstrapPromptWarning({
|
||||
analysis,
|
||||
mode: "once",
|
||||
seenSignatures: [],
|
||||
});
|
||||
const warningSeen = buildBootstrapPromptWarning({
|
||||
analysis,
|
||||
mode: "once",
|
||||
seenSignatures: warningFirst.warningSignaturesSeen,
|
||||
previousSignature: warningFirst.signature,
|
||||
});
|
||||
const warningAlways = buildBootstrapPromptWarning({
|
||||
analysis,
|
||||
mode: "always",
|
||||
seenSignatures: warningFirst.warningSignaturesSeen,
|
||||
previousSignature: warningFirst.signature,
|
||||
});
|
||||
return {
|
||||
scenario: "bootstrap-warning",
|
||||
focus: "Workspace bootstrap truncation warnings inside # Project Context",
|
||||
expectedStableSystemAfterTurnIds: ["t2", "t3"],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "First warning emission",
|
||||
systemPrompt: buildSystemPrompt({
|
||||
workspaceDir,
|
||||
contextFiles,
|
||||
}),
|
||||
bodyPrompt: appendBootstrapPromptWarning("hello", warningFirst.lines),
|
||||
notes: ["Warning is appended to the turn body", "System prompt should stay stable"],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Same truncation signature after once-mode dedupe",
|
||||
systemPrompt: buildSystemPrompt({
|
||||
workspaceDir,
|
||||
contextFiles,
|
||||
}),
|
||||
bodyPrompt: appendBootstrapPromptWarning("hello again", warningSeen.lines),
|
||||
notes: ["Once-mode removes warning lines", "Only the body tail changes now"],
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
label: "Always-mode warning",
|
||||
systemPrompt: buildSystemPrompt({
|
||||
workspaceDir,
|
||||
contextFiles,
|
||||
}),
|
||||
bodyPrompt: appendBootstrapPromptWarning("one more turn", warningAlways.lines),
|
||||
notes: [
|
||||
"Always-mode keeps warning in the body prompt tail",
|
||||
"System prompt remains stable",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function createMaintenanceScenario(workspaceDir: string): Promise<PromptScenario> {
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "AGENTS.md",
|
||||
content: [
|
||||
"## Session Startup",
|
||||
"Read AGENTS.md and MEMORY.md before responding.",
|
||||
"",
|
||||
"## Red Lines",
|
||||
"Do not delete production data.",
|
||||
"",
|
||||
"## Safety",
|
||||
"Never reveal secrets.",
|
||||
].join("\n"),
|
||||
});
|
||||
const memoryFlushPrompt = [
|
||||
"Pre-compaction memory flush.",
|
||||
"Store durable memories only in memory/2026-03-15.md (create memory/ if needed).",
|
||||
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.",
|
||||
"If nothing to store, reply with NO_REPLY.",
|
||||
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles)",
|
||||
"Reference UTC: 2026-03-16 04:30 UTC",
|
||||
].join("\n");
|
||||
const memoryFlushSystemPrompt = buildSystemPrompt({
|
||||
workspaceDir,
|
||||
extraSystemPrompt: [
|
||||
"Pre-compaction memory flush turn.",
|
||||
"The session is near auto-compaction; capture durable memories to disk.",
|
||||
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).",
|
||||
"You may reply, but usually NO_REPLY is correct.",
|
||||
].join(" "),
|
||||
});
|
||||
const postCompaction = [
|
||||
"[Post-compaction context refresh]",
|
||||
"",
|
||||
"Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence.",
|
||||
"",
|
||||
"Critical rules from AGENTS.md:",
|
||||
"",
|
||||
"## Session Startup",
|
||||
"Read AGENTS.md and MEMORY.md before responding.",
|
||||
"",
|
||||
"## Red Lines",
|
||||
"Do not delete production data.",
|
||||
"",
|
||||
"Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles)",
|
||||
"Reference UTC: 2026-03-16 04:30 UTC",
|
||||
].join("\n");
|
||||
const postCompactionSystemPrompt = buildSystemPrompt({
|
||||
workspaceDir,
|
||||
extraSystemPrompt: buildInboundMetaSystemPrompt({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "D123",
|
||||
AccountId: "A1",
|
||||
ChatType: "direct",
|
||||
}),
|
||||
});
|
||||
return {
|
||||
scenario: "maintenance-prompts",
|
||||
focus: "Memory flush and post-compaction maintenance prompts",
|
||||
expectedStableSystemAfterTurnIds: [],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
label: "Pre-compaction memory flush run",
|
||||
systemPrompt: memoryFlushSystemPrompt,
|
||||
bodyPrompt: memoryFlushPrompt,
|
||||
notes: [
|
||||
"Writes to memory/2026-03-15.md",
|
||||
"Separate maintenance run; expected to differ from normal user turns",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
label: "Post-compaction refresh context run",
|
||||
systemPrompt: postCompactionSystemPrompt,
|
||||
bodyPrompt: postCompaction,
|
||||
notes: [
|
||||
"Separate maintenance context payload",
|
||||
"Expected to differ from normal user turns",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a temp workspace with prompt composition context files. */
|
||||
export async function createWorkspaceWithPromptCompositionFiles(): Promise<string> {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-");
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "AGENTS.md",
|
||||
content: [
|
||||
"# AGENTS.md",
|
||||
"",
|
||||
"## Session Startup",
|
||||
"Read AGENTS.md and TOOLS.md before making changes.",
|
||||
"",
|
||||
"## Red Lines",
|
||||
"Do not rewrite user commits.",
|
||||
].join("\n"),
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "TOOLS.md",
|
||||
content: "# TOOLS.md\n\nUse rg before grep.\n",
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: "SOUL.md",
|
||||
content: "# SOUL.md\n\nBe concise but kind.\n",
|
||||
});
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
/** Create all prompt composition scenarios plus cleanup handles. */
|
||||
export async function createPromptCompositionScenarios(): Promise<{
|
||||
workspaceDir: string;
|
||||
warningWorkspaceDir: string;
|
||||
scenarios: PromptScenario[];
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const workspaceDir = await createWorkspaceWithPromptCompositionFiles();
|
||||
const warningWorkspaceDir = await makeTempWorkspace("openclaw-prompt-cache-warning-");
|
||||
const scenarios = [
|
||||
createDirectScenario(workspaceDir),
|
||||
createGroupScenario(workspaceDir),
|
||||
createDiscordBoundaryScenario(workspaceDir),
|
||||
await createToolRichScenario(workspaceDir),
|
||||
await createBootstrapWarningScenario(warningWorkspaceDir),
|
||||
await createMaintenanceScenario(workspaceDir),
|
||||
];
|
||||
return {
|
||||
workspaceDir,
|
||||
warningWorkspaceDir,
|
||||
scenarios,
|
||||
cleanup: async () => {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
await fs.rm(warningWorkspaceDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
8
test/helpers/agents/prompt-snapshot-paths.ts
Normal file
8
test/helpers/agents/prompt-snapshot-paths.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Shared prompt snapshot fixture directories.
|
||||
|
||||
/** Codex runtime happy-path prompt snapshot fixture directory. */
|
||||
export const CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR =
|
||||
"test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path";
|
||||
/** Codex model prompt fixture directory. */
|
||||
export const CODEX_MODEL_PROMPT_FIXTURE_DIR =
|
||||
"test/fixtures/agents/prompt-snapshots/codex-model-catalog";
|
||||
39
test/helpers/agents/transport-params-runtime-contract.ts
Normal file
39
test/helpers/agents/transport-params-runtime-contract.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Shared transport parameter contract fixtures for GPT-5 model tests.
|
||||
|
||||
/** Expected OpenAI GPT-5 transport defaults. */
|
||||
export const OPENAI_GPT5_TRANSPORT_DEFAULTS = {
|
||||
parallel_tool_calls: true,
|
||||
text_verbosity: "low",
|
||||
} as const;
|
||||
|
||||
/** OpenAI GPT-5 cases that should receive GPT transport defaults. */
|
||||
export const OPENAI_GPT5_TRANSPORT_DEFAULT_CASES = [
|
||||
{
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Non-OpenAI GPT-5 case that should not receive OpenAI defaults. */
|
||||
export const NON_OPENAI_GPT5_TRANSPORT_CASE = {
|
||||
provider: "openrouter",
|
||||
modelId: "gpt-5.4",
|
||||
} as const;
|
||||
|
||||
/** Payload APIs that support parallel_tool_calls in GPT tests. */
|
||||
export const GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS = [
|
||||
"openai-completions",
|
||||
"openai-responses",
|
||||
"openai-chatgpt-responses",
|
||||
"azure-openai-responses",
|
||||
] as const;
|
||||
|
||||
/** Payload APIs unrelated to GPT parallel tool call defaults. */
|
||||
export const UNRELATED_TOOL_CALLS_PAYLOAD_APIS = [
|
||||
"anthropic-messages",
|
||||
"google-generative-ai",
|
||||
] as const;
|
||||
89
test/helpers/auth-wizard.ts
Normal file
89
test/helpers/auth-wizard.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Auth wizard helpers drive authentication wizard flows in tests.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../src/runtime.js";
|
||||
import { makeTempWorkspace } from "../../src/test-helpers/workspace.js";
|
||||
import { captureEnv } from "../../src/test-utils/env.js";
|
||||
import type { WizardPrompter } from "../../src/wizard/prompts.js";
|
||||
import { createWizardPrompter as createBaseWizardPrompter } from "./wizard-prompter.js";
|
||||
|
||||
// Shared auth wizard test helpers for runtime/env setup.
|
||||
|
||||
/** Create a RuntimeEnv whose exit method throws for assertions. */
|
||||
export function createExitThrowingRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a WizardPrompter with default mock answers and caller overrides. */
|
||||
export function createWizardPrompter(
|
||||
overrides: Partial<WizardPrompter>,
|
||||
options?: { defaultSelect?: string },
|
||||
): WizardPrompter {
|
||||
return createBaseWizardPrompter(overrides, { defaultSelect: options?.defaultSelect ?? "" });
|
||||
}
|
||||
|
||||
/** Create isolated auth state and agent directories for auth tests. */
|
||||
export async function setupAuthTestEnv(
|
||||
prefix = "openclaw-auth-",
|
||||
options?: { agentSubdir?: string },
|
||||
): Promise<{
|
||||
stateDir: string;
|
||||
agentDir: string;
|
||||
}> {
|
||||
const stateDir = await makeTempWorkspace(prefix);
|
||||
const agentDir = path.join(stateDir, options?.agentSubdir ?? "agent");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
return { stateDir, agentDir };
|
||||
}
|
||||
|
||||
type AuthTestLifecycle = {
|
||||
setStateDir: (stateDir: string) => void;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Capture env and track one state dir for cleanup. */
|
||||
export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle {
|
||||
const envSnapshot = captureEnv(envKeys);
|
||||
let stateDir: string | null = null;
|
||||
return {
|
||||
setStateDir(nextStateDir: string) {
|
||||
stateDir = nextStateDir;
|
||||
},
|
||||
async cleanup() {
|
||||
if (stateDir) {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
stateDir = null;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Return OPENCLAW_AGENT_DIR or fail the test clearly. */
|
||||
export function requireOpenClawAgentDir(): string {
|
||||
const agentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
if (!agentDir) {
|
||||
throw new Error("OPENCLAW_AGENT_DIR not set");
|
||||
}
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
/** Resolve the auth profile JSON path for an agent directory. */
|
||||
function authProfilePathForAgent(agentDir: string): string {
|
||||
return path.join(agentDir, "auth-profiles.json");
|
||||
}
|
||||
|
||||
/** Read and parse auth profiles for an agent directory. */
|
||||
export async function readAuthProfilesForAgent<T>(agentDir: string): Promise<T> {
|
||||
const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
431
test/helpers/auto-reply/trigger-handling-test-harness.ts
Normal file
431
test/helpers/auto-reply/trigger-handling-test-harness.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
// Auto-reply trigger harness builds trigger handling scenarios for tests.
|
||||
import { rmSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, expect, vi } from "vitest";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles.js";
|
||||
import type { EmbeddedAgentQueueMessageOutcome } from "../../../src/agents/embedded-agent-runner/runs.js";
|
||||
import { withFastReplyConfig } from "../../../src/auto-reply/reply/get-reply-fast-path.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
|
||||
|
||||
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||
type AnyMock = any;
|
||||
type AnyMocks = Record<string, any>;
|
||||
|
||||
function getSharedMocks<T>(key: string, create: () => T): T {
|
||||
const symbol = Symbol.for(key);
|
||||
const store = globalThis as Record<symbol, T | undefined>;
|
||||
if (!store[symbol]) {
|
||||
store[symbol] = create();
|
||||
}
|
||||
return store[symbol];
|
||||
}
|
||||
|
||||
const embeddedAgentMocks = getSharedMocks("openclaw.trigger-handling.embedded-agent-mocks", () => ({
|
||||
abortEmbeddedAgentRun: vi.fn().mockReturnValue(false),
|
||||
compactEmbeddedAgentSession: vi.fn(),
|
||||
runEmbeddedAgent: vi.fn(),
|
||||
queueEmbeddedAgentMessageWithOutcome: vi.fn(
|
||||
(sessionId: string, _text?: string, _options?: unknown): EmbeddedAgentQueueMessageOutcome => ({
|
||||
queued: false,
|
||||
sessionId,
|
||||
reason: "not_streaming",
|
||||
gatewayHealth: "live",
|
||||
}),
|
||||
),
|
||||
resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined),
|
||||
isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
export function getAbortEmbeddedAgentRunMock(): AnyMock {
|
||||
return embeddedAgentMocks.abortEmbeddedAgentRun;
|
||||
}
|
||||
|
||||
export function getCompactEmbeddedAgentSessionMock(): AnyMock {
|
||||
return embeddedAgentMocks.compactEmbeddedAgentSession;
|
||||
}
|
||||
|
||||
export function getRunEmbeddedAgentMock(): AnyMock {
|
||||
return embeddedAgentMocks.runEmbeddedAgent;
|
||||
}
|
||||
|
||||
const installEmbeddedAgentMock = () =>
|
||||
vi.doMock("../../../src/agents/embedded-agent.js", () => ({
|
||||
abortEmbeddedAgentRun: (...args: unknown[]) =>
|
||||
embeddedAgentMocks.abortEmbeddedAgentRun(...args),
|
||||
compactEmbeddedAgentSession: (...args: unknown[]) =>
|
||||
embeddedAgentMocks.compactEmbeddedAgentSession(...args),
|
||||
runEmbeddedAgent: (...args: unknown[]) => embeddedAgentMocks.runEmbeddedAgent(...args),
|
||||
queueEmbeddedAgentMessageWithOutcome: (sessionId: string, text: string, options?: unknown) =>
|
||||
embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options),
|
||||
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
||||
resolveActiveEmbeddedRunSessionId: (...args: unknown[]) =>
|
||||
embeddedAgentMocks.resolveActiveEmbeddedRunSessionId(...args),
|
||||
isEmbeddedAgentRunActive: (...args: unknown[]) =>
|
||||
embeddedAgentMocks.isEmbeddedAgentRunActive(...args),
|
||||
isEmbeddedAgentRunStreaming: (...args: unknown[]) =>
|
||||
embeddedAgentMocks.isEmbeddedAgentRunStreaming(...args),
|
||||
}));
|
||||
|
||||
installEmbeddedAgentMock();
|
||||
|
||||
vi.doMock("../../../src/agents/embedded-agent-runner/runs.js", () => ({
|
||||
abortEmbeddedAgentRun: (...args: unknown[]) => embeddedAgentMocks.abortEmbeddedAgentRun(...args),
|
||||
formatEmbeddedAgentQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) =>
|
||||
outcome.reason && outcome.sessionId
|
||||
? `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=live`
|
||||
: undefined,
|
||||
queueEmbeddedAgentMessageWithOutcome: (sessionId: string, text: string, options?: unknown) =>
|
||||
embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options),
|
||||
resolveActiveEmbeddedRunSessionId: (...args: unknown[]) =>
|
||||
embeddedAgentMocks.resolveActiveEmbeddedRunSessionId(...args),
|
||||
}));
|
||||
|
||||
const providerUsageMocks = vi.hoisted(() => ({
|
||||
loadProviderUsageSummary: vi.fn().mockResolvedValue({
|
||||
updatedAt: 0,
|
||||
providers: [],
|
||||
}),
|
||||
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
|
||||
formatUsageWindowSummary: vi.fn().mockReturnValue("Claude 80% left"),
|
||||
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
||||
}));
|
||||
|
||||
export function getProviderUsageMocks(): AnyMocks {
|
||||
return providerUsageMocks;
|
||||
}
|
||||
|
||||
vi.mock("../../../src/infra/provider-usage.js", () => providerUsageMocks);
|
||||
|
||||
const DEFAULT_MODEL_CATALOG = [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "anthropic/claude-opus-4-7",
|
||||
name: "Claude Opus 4.7 (OpenRouter)",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
{ provider: "openai", id: "gpt-5.5-mini", name: "GPT-5.5 mini" },
|
||||
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
|
||||
{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5 (Codex)" },
|
||||
{ provider: "minimax", id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
];
|
||||
|
||||
const modelCatalogMocks = getSharedMocks("openclaw.trigger-handling.model-catalog-mocks", () => ({
|
||||
loadManifestModelCatalog: vi.fn(() => DEFAULT_MODEL_CATALOG),
|
||||
loadModelCatalog: vi.fn().mockResolvedValue(DEFAULT_MODEL_CATALOG),
|
||||
resetModelCatalogCacheForTest: vi.fn(),
|
||||
}));
|
||||
|
||||
const installModelCatalogMock = () =>
|
||||
vi.doMock("../../../src/agents/model-catalog.js", () => modelCatalogMocks);
|
||||
|
||||
installModelCatalogMock();
|
||||
|
||||
vi.doMock("../../../src/agents/model-catalog.runtime.js", () => ({
|
||||
loadManifestModelCatalog: () => modelCatalogMocks.loadManifestModelCatalog(),
|
||||
loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args),
|
||||
}));
|
||||
|
||||
vi.doMock("../../../src/plugins/provider-runtime.runtime.js", () => ({
|
||||
augmentModelCatalogWithProviderPlugins: async (params: { catalog?: unknown[] }) =>
|
||||
params.catalog ?? [],
|
||||
buildProviderAuthDoctorHintWithPlugin: () => undefined,
|
||||
buildProviderMissingAuthMessageWithPlugin: () => undefined,
|
||||
formatProviderAuthProfileApiKeyWithPlugin: (params: { apiKey?: string }) => params.apiKey,
|
||||
prepareProviderRuntimeAuth: async () => undefined,
|
||||
refreshProviderOAuthCredentialWithPlugin: async () => undefined,
|
||||
}));
|
||||
|
||||
const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({
|
||||
runWithModelFallback: vi.fn(
|
||||
async (params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string, runOptions?: unknown) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await params.run(params.provider, params.model),
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
attempts: [],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const installModelFallbackMock = () =>
|
||||
vi.doMock("../../../src/agents/model-fallback.js", () => modelFallbackMocks);
|
||||
|
||||
installModelFallbackMock();
|
||||
|
||||
vi.doMock("../../../src/infra/git-commit.js", () => ({
|
||||
resolveCommitHash: vi.fn(() => "abcdef0"),
|
||||
}));
|
||||
|
||||
const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({
|
||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
||||
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
||||
}));
|
||||
|
||||
const installWebSessionMock = () =>
|
||||
vi.doMock("../../../src/plugins/runtime/runtime-web-channel-plugin.js", () => ({
|
||||
webAuthExists: (...args: unknown[]) => webSessionMocks.webAuthExists(...args),
|
||||
getWebAuthAgeMs: (...args: unknown[]) => webSessionMocks.getWebAuthAgeMs(...args),
|
||||
readWebSelfId: (...args: unknown[]) => webSessionMocks.readWebSelfId(...args),
|
||||
}));
|
||||
|
||||
installWebSessionMock();
|
||||
|
||||
export const MAIN_SESSION_KEY = "agent:main:main";
|
||||
|
||||
type TempHomeEnvSnapshot = {
|
||||
home: string | undefined;
|
||||
userProfile: string | undefined;
|
||||
homeDrive: string | undefined;
|
||||
homePath: string | undefined;
|
||||
openclawHome: string | undefined;
|
||||
stateDir: string | undefined;
|
||||
};
|
||||
|
||||
let suiteTempHomeRoot = "";
|
||||
let suiteTempHomeId = 0;
|
||||
|
||||
function snapshotTempHomeEnv(): TempHomeEnvSnapshot {
|
||||
return {
|
||||
home: process.env.HOME,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
homeDrive: process.env.HOMEDRIVE,
|
||||
homePath: process.env.HOMEPATH,
|
||||
openclawHome: process.env.OPENCLAW_HOME,
|
||||
stateDir: process.env.OPENCLAW_STATE_DIR,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreTempHomeEnv(snapshot: TempHomeEnvSnapshot): void {
|
||||
const restoreKey = (key: string, value: string | undefined) => {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
};
|
||||
|
||||
restoreKey("HOME", snapshot.home);
|
||||
restoreKey("USERPROFILE", snapshot.userProfile);
|
||||
restoreKey("HOMEDRIVE", snapshot.homeDrive);
|
||||
restoreKey("HOMEPATH", snapshot.homePath);
|
||||
restoreKey("OPENCLAW_HOME", snapshot.openclawHome);
|
||||
restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir);
|
||||
}
|
||||
|
||||
function setTempHomeEnv(home: string): void {
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
process.env.OPENCLAW_STATE_DIR = join(home, ".openclaw");
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
const match = home.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
process.env.HOMEDRIVE = match[1];
|
||||
process.env.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
suiteTempHomeRoot = await fs.mkdtemp(join(os.tmpdir(), "openclaw-triggers-suite-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!suiteTempHomeRoot) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rmSync(suiteTempHomeRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort temp cleanup only.
|
||||
}
|
||||
suiteTempHomeRoot = "";
|
||||
suiteTempHomeId = 0;
|
||||
});
|
||||
|
||||
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const home = join(suiteTempHomeRoot, `case-${++suiteTempHomeId}`);
|
||||
const snapshot = snapshotTempHomeEnv();
|
||||
await fs.mkdir(join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
|
||||
setTempHomeEnv(home);
|
||||
|
||||
try {
|
||||
// Hard reset shared mocks so non-isolated runs don't inherit prior behavior.
|
||||
embeddedAgentMocks.runEmbeddedAgent.mockReset();
|
||||
embeddedAgentMocks.abortEmbeddedAgentRun.mockReset().mockReturnValue(false);
|
||||
embeddedAgentMocks.compactEmbeddedAgentSession.mockReset();
|
||||
embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome
|
||||
.mockReset()
|
||||
.mockImplementation((sessionId: string) => ({
|
||||
queued: false,
|
||||
sessionId,
|
||||
reason: "not_streaming",
|
||||
gatewayHealth: "live",
|
||||
}));
|
||||
embeddedAgentMocks.isEmbeddedAgentRunActive.mockReset().mockReturnValue(false);
|
||||
embeddedAgentMocks.isEmbeddedAgentRunStreaming.mockReset().mockReturnValue(false);
|
||||
modelFallbackMocks.runWithModelFallback.mockClear();
|
||||
return await fn(home);
|
||||
} finally {
|
||||
restoreTempHomeEnv(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
export function makeCfg(home: string): OpenClawConfig {
|
||||
return withFastReplyConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
workspace: join(home, "openclaw"),
|
||||
// Test harness: avoid 1s coalescer idle sleeps that dominate trigger suites.
|
||||
blockStreamingCoalesce: { idleMs: 1 },
|
||||
// Trigger tests assert routing/authorization behavior, not delivery pacing.
|
||||
humanDelay: { mode: "off" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
queue: {
|
||||
debounceMs: 0,
|
||||
},
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
} as OpenClawConfig);
|
||||
}
|
||||
|
||||
export async function loadGetReplyFromConfig() {
|
||||
return (await import("../../../src/auto-reply/reply.js")).getReplyFromConfig;
|
||||
}
|
||||
|
||||
export function installTriggerHandlingReplyHarness(
|
||||
setGetReplyFromConfig: (
|
||||
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig,
|
||||
) => void,
|
||||
): void {
|
||||
beforeAll(async () => {
|
||||
setGetReplyFromConfig(await loadGetReplyFromConfig());
|
||||
});
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
}
|
||||
|
||||
export function requireSessionStorePath(cfg: { session?: { store?: string } }): string {
|
||||
const storePath = cfg.session?.store;
|
||||
if (!storePath) {
|
||||
throw new Error("expected session store path");
|
||||
}
|
||||
return storePath;
|
||||
}
|
||||
|
||||
export async function expectInlineCommandHandledAndStripped(params: {
|
||||
home: string;
|
||||
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
|
||||
body: string;
|
||||
stripToken: string;
|
||||
blockReplyContains: string;
|
||||
requestOverrides?: Record<string, unknown>;
|
||||
}) {
|
||||
const runEmbeddedAgentMock = mockRunEmbeddedAgentOk();
|
||||
runEmbeddedAgentMock.mockClear();
|
||||
const { blockReplies, handlers } = createBlockReplyCollector();
|
||||
const res = await params.getReplyFromConfig(
|
||||
{
|
||||
Body: params.body,
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
...params.requestOverrides,
|
||||
},
|
||||
handlers,
|
||||
makeCfg(params.home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(blockReplies.length).toBe(1);
|
||||
expect(blockReplies[0]?.text).toContain(params.blockReplyContains);
|
||||
expect(runEmbeddedAgentMock).toHaveBeenCalled();
|
||||
const lastCall = runEmbeddedAgentMock.mock.calls[runEmbeddedAgentMock.mock.calls.length - 1];
|
||||
const prompt = lastCall?.[0]?.prompt ?? "";
|
||||
expect(prompt).not.toContain(params.stripToken);
|
||||
expect(text).toBe("ok");
|
||||
}
|
||||
|
||||
export async function expectBareNewOrResetAcknowledged(params: {
|
||||
home: string;
|
||||
body: "/new" | "/reset";
|
||||
getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig;
|
||||
}) {
|
||||
const runEmbeddedAgentMock = getRunEmbeddedAgentMock();
|
||||
runEmbeddedAgentMock.mockClear();
|
||||
runEmbeddedAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await params.getReplyFromConfig(
|
||||
{
|
||||
Body: params.body,
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(params.home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe(params.body === "/reset" ? "✅ Session reset." : "✅ New session started.");
|
||||
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
export function installTriggerHandlingE2eTestHooks() {
|
||||
afterEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
}
|
||||
|
||||
export function mockRunEmbeddedAgentOk(text = "ok"): AnyMock {
|
||||
const runEmbeddedAgentMock = getRunEmbeddedAgentMock();
|
||||
runEmbeddedAgentMock.mockResolvedValue({
|
||||
payloads: [{ text }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
return runEmbeddedAgentMock;
|
||||
}
|
||||
|
||||
export function createBlockReplyCollector() {
|
||||
const blockReplies: Array<{ text?: string }> = [];
|
||||
return {
|
||||
blockReplies,
|
||||
handlers: {
|
||||
onBlockReply: async (payload: { text?: string }) => {
|
||||
blockReplies.push(payload);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
25
test/helpers/bounded-child-output.test.ts
Normal file
25
test/helpers/bounded-child-output.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Bounded child output tests cover tail-only test child output buffering.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBoundedChildOutput } from "./bounded-child-output.js";
|
||||
|
||||
describe("bounded child output", () => {
|
||||
it("keeps only the latest output bytes", () => {
|
||||
const output = createBoundedChildOutput(16);
|
||||
|
||||
output.append(`DO_NOT_KEEP${"x".repeat(32)}`);
|
||||
output.append("\nrecent tail");
|
||||
|
||||
const text = output.text();
|
||||
expect(text).toContain("recent tail");
|
||||
expect(text).not.toContain("DO_NOT_KEEP");
|
||||
expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(16);
|
||||
});
|
||||
|
||||
it("keeps only the tail of a single oversized chunk", () => {
|
||||
const output = createBoundedChildOutput(8);
|
||||
|
||||
output.append(Buffer.from("old-prefix-recent"));
|
||||
|
||||
expect(output.text()).toBe("x-recent");
|
||||
});
|
||||
});
|
||||
43
test/helpers/bounded-child-output.ts
Normal file
43
test/helpers/bounded-child-output.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const DEFAULT_CHILD_OUTPUT_TAIL_BYTES = 128 * 1024;
|
||||
|
||||
export function createBoundedChildOutput(maxBytes = DEFAULT_CHILD_OUTPUT_TAIL_BYTES) {
|
||||
const limit =
|
||||
Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : DEFAULT_CHILD_OUTPUT_TAIL_BYTES;
|
||||
let chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
const trim = () => {
|
||||
while (totalBytes > limit && chunks.length > 0) {
|
||||
const first = chunks[0];
|
||||
if (!first) {
|
||||
break;
|
||||
}
|
||||
const excess = totalBytes - limit;
|
||||
if (first.byteLength <= excess) {
|
||||
chunks.shift();
|
||||
totalBytes -= first.byteLength;
|
||||
continue;
|
||||
}
|
||||
chunks[0] = Buffer.from(first.subarray(excess));
|
||||
totalBytes -= excess;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
append(chunk: unknown): void {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
if (buffer.byteLength >= limit) {
|
||||
chunks = [Buffer.from(buffer.subarray(buffer.byteLength - limit))];
|
||||
totalBytes = limit;
|
||||
return;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
totalBytes += buffer.byteLength;
|
||||
trim();
|
||||
},
|
||||
text(): string {
|
||||
return Buffer.concat(chunks, totalBytes).toString("utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
75
test/helpers/browser-bundled-plugin-fixture.ts
Normal file
75
test/helpers/browser-bundled-plugin-fixture.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Browser bundled plugin fixtures build browser extension plugin layouts for tests.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Bundled browser plugin fixture used by plugin/package tests.
|
||||
|
||||
const BROWSER_FIXTURE_MANIFEST = {
|
||||
id: "browser",
|
||||
enabledByDefault: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
const BROWSER_FIXTURE_ENTRY = `module.exports = {
|
||||
id: "browser",
|
||||
name: "Browser",
|
||||
description: "Bundled browser fixture plugin",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
register(api) {
|
||||
api.registerTool((ctx) => ({
|
||||
name: "browser",
|
||||
label: "browser",
|
||||
description: "browser fixture tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
async execute() {
|
||||
return {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {
|
||||
workspaceOnly: ctx.fsPolicy?.workspaceOnly ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
api.registerCli(({ program }) => {
|
||||
program.command("browser");
|
||||
}, { commands: ["browser"] });
|
||||
api.registerGatewayMethod("browser.request", async () => ({ ok: true }), {
|
||||
scope: "operator.admin",
|
||||
});
|
||||
api.registerService({
|
||||
id: "browser-control",
|
||||
start() {},
|
||||
});
|
||||
},
|
||||
};`;
|
||||
|
||||
/** Create a temporary bundled browser plugin fixture and cleanup callback. */
|
||||
export function createBundledBrowserPluginFixture(): { rootDir: string; cleanup: () => void } {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-browser-bundled-"));
|
||||
const pluginDir = path.join(rootDir, "browser");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(BROWSER_FIXTURE_MANIFEST, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), BROWSER_FIXTURE_ENTRY, "utf8");
|
||||
return {
|
||||
rootDir,
|
||||
cleanup() {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
8
test/helpers/bundled-runtime-sidecars.ts
Normal file
8
test/helpers/bundled-runtime-sidecars.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Bundled runtime sidecar paths that package/build tests expect to exist.
|
||||
|
||||
/** Runtime sidecar files shipped with bundled channel plugins. */
|
||||
export const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
|
||||
"dist/extensions/discord/runtime-api.js",
|
||||
"dist/extensions/telegram/runtime-api.js",
|
||||
"dist/extensions/telegram/thread-bindings-runtime.js",
|
||||
] as const;
|
||||
23
test/helpers/captured-io.ts
Normal file
23
test/helpers/captured-io.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// In-memory stdout/stderr capture helper for command tests.
|
||||
|
||||
/** Create a minimal IO object plus readers for captured output. */
|
||||
export function createCapturedIo() {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write(chunk: unknown) {
|
||||
stdout += String(chunk);
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write(chunk: unknown) {
|
||||
stderr += String(chunk);
|
||||
},
|
||||
},
|
||||
},
|
||||
readStdout: () => stdout,
|
||||
readStderr: () => stderr,
|
||||
};
|
||||
}
|
||||
106
test/helpers/config/bundled-channel-config-runtime.ts
Normal file
106
test/helpers/config/bundled-channel-config-runtime.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Bundled channel config runtime helper loads public bundled channel config surfaces.
|
||||
import * as bundledChannelModule from "../../../src/channels/plugins/bundled.js";
|
||||
import type {
|
||||
ChannelConfigRuntimeSchema,
|
||||
ChannelConfigSchema,
|
||||
} from "../../../src/channels/plugins/types.plugin.js";
|
||||
import { listBundledPluginMetadata } from "../../../src/plugins/bundled-plugin-metadata.js";
|
||||
|
||||
// Shared bundled channel config runtime maps for config contract tests.
|
||||
|
||||
type BundledChannelRuntimeMap = ReadonlyMap<string, ChannelConfigRuntimeSchema>;
|
||||
type BundledChannelConfigSchemaMap = ReadonlyMap<string, ChannelConfigSchema>;
|
||||
type BundledChannelPluginShape = {
|
||||
id: string;
|
||||
configSchema?: ChannelConfigSchema;
|
||||
};
|
||||
type BundledChannelMaps = {
|
||||
runtimeMap: Map<string, ChannelConfigRuntimeSchema>;
|
||||
configSchemaMap: Map<string, ChannelConfigSchema>;
|
||||
};
|
||||
|
||||
let cachedBundledChannelMaps: BundledChannelMaps | undefined;
|
||||
|
||||
/** Build runtime/config maps from public bundled channel plugin metadata. */
|
||||
function buildBundledChannelMaps(
|
||||
plugins: readonly BundledChannelPluginShape[],
|
||||
): BundledChannelMaps {
|
||||
const runtimeMap = new Map<string, ChannelConfigRuntimeSchema>();
|
||||
const configSchemaMap = new Map<string, ChannelConfigSchema>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const channelSchema = plugin.configSchema;
|
||||
if (!channelSchema) {
|
||||
continue;
|
||||
}
|
||||
configSchemaMap.set(plugin.id, channelSchema);
|
||||
if (channelSchema.runtime) {
|
||||
runtimeMap.set(plugin.id, channelSchema.runtime);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of listBundledPluginMetadata({ includeChannelConfigs: true })) {
|
||||
const channelConfigs = entry.manifest.channelConfigs;
|
||||
if (!channelConfigs) {
|
||||
continue;
|
||||
}
|
||||
for (const [channelId, channelConfig] of Object.entries(channelConfigs)) {
|
||||
const channelSchema = channelConfig?.schema as Record<string, unknown> | undefined;
|
||||
if (!channelSchema) {
|
||||
continue;
|
||||
}
|
||||
if (!configSchemaMap.has(channelId)) {
|
||||
configSchemaMap.set(channelId, {
|
||||
schema: channelSchema,
|
||||
...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}),
|
||||
...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}),
|
||||
});
|
||||
}
|
||||
if (channelConfig.runtime && !runtimeMap.has(channelId)) {
|
||||
runtimeMap.set(channelId, channelConfig.runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { runtimeMap, configSchemaMap };
|
||||
}
|
||||
|
||||
/** Read bundled channel plugin surfaces when available in this test process. */
|
||||
function readBundledChannelPlugins(): readonly BundledChannelPluginShape[] | undefined {
|
||||
try {
|
||||
if (typeof bundledChannelModule.listBundledChannelPlugins !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
const plugins = bundledChannelModule.listBundledChannelPlugins();
|
||||
return Array.isArray(plugins) ? (plugins as readonly BundledChannelPluginShape[]) : undefined;
|
||||
} catch (error) {
|
||||
if (error instanceof ReferenceError) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return cached maps when live bundled plugin surfaces were available. */
|
||||
function getBundledChannelMaps(): BundledChannelMaps {
|
||||
const plugins = readBundledChannelPlugins();
|
||||
if (plugins && cachedBundledChannelMaps) {
|
||||
return cachedBundledChannelMaps;
|
||||
}
|
||||
|
||||
const maps = buildBundledChannelMaps(plugins ?? []);
|
||||
if (plugins) {
|
||||
cachedBundledChannelMaps = maps;
|
||||
}
|
||||
return maps;
|
||||
}
|
||||
|
||||
/** Return runtime config schemas keyed by bundled channel id. */
|
||||
export function getBundledChannelRuntimeMap(): BundledChannelRuntimeMap {
|
||||
return getBundledChannelMaps().runtimeMap;
|
||||
}
|
||||
|
||||
/** Return channel config schemas keyed by bundled channel id. */
|
||||
export function getBundledChannelConfigSchemaMap(): BundledChannelConfigSchemaMap {
|
||||
return getBundledChannelMaps().configSchemaMap;
|
||||
}
|
||||
150
test/helpers/config/config-honor-audit.ts
Normal file
150
test/helpers/config/config-honor-audit.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Config honor audit helper checks config fields against expected consumers.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeBaseConfigSchemaResponse } from "../../../src/config/schema-base.js";
|
||||
|
||||
// Config honor audit helpers that compare schema keys with proof inventories.
|
||||
|
||||
/** Inventory row describing where one config key is declared, merged, consumed, and tested. */
|
||||
export type ConfigHonorInventoryRow = {
|
||||
key: string;
|
||||
schemaPaths: string[];
|
||||
typePaths: string[];
|
||||
mergePaths: string[];
|
||||
consumerPaths: string[];
|
||||
reloadPaths: string[];
|
||||
testPaths: string[];
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
type ConfigHonorProofKey =
|
||||
| "schemaPaths"
|
||||
| "typePaths"
|
||||
| "mergePaths"
|
||||
| "consumerPaths"
|
||||
| "reloadPaths"
|
||||
| "testPaths";
|
||||
|
||||
/** Result of auditing one config honor inventory. */
|
||||
export type ConfigHonorAuditResult = {
|
||||
schemaKeys: string[];
|
||||
missingKeys: string[];
|
||||
extraKeys: string[];
|
||||
missingSchemaPaths: string[];
|
||||
missingFiles: string[];
|
||||
missingProofs: Array<{
|
||||
key: string;
|
||||
missing: ConfigHonorProofKey[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const BASE_CONFIG_SCHEMA = computeBaseConfigSchemaResponse({
|
||||
generatedAt: "2026-05-05T00:00:00.000Z",
|
||||
});
|
||||
|
||||
/** Return true when a dotted schema path exists in the generated base config schema. */
|
||||
function hasSchemaPath(schemaPath: string): boolean {
|
||||
const segments = schemaPath.split(".");
|
||||
let current: unknown = BASE_CONFIG_SCHEMA.schema;
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (segment === "*") {
|
||||
const items = (current as { items?: unknown }).items;
|
||||
if (!items || typeof items !== "object") {
|
||||
return false;
|
||||
}
|
||||
current = items;
|
||||
continue;
|
||||
}
|
||||
const properties = (current as { properties?: Record<string, unknown> }).properties;
|
||||
if (!properties || !Object.hasOwn(properties, segment)) {
|
||||
return false;
|
||||
}
|
||||
current = properties[segment];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** List leaf schema keys for the requested config prefixes. */
|
||||
export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const prefix of prefixes) {
|
||||
const segments = prefix.split(".");
|
||||
let current: unknown = BASE_CONFIG_SCHEMA.schema;
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object") {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
if (segment === "*") {
|
||||
current = (current as { items?: unknown }).items ?? null;
|
||||
continue;
|
||||
}
|
||||
current = (current as { properties?: Record<string, unknown> }).properties?.[segment] ?? null;
|
||||
}
|
||||
const properties = (current as { properties?: Record<string, unknown> } | null)?.properties;
|
||||
if (!properties) {
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(properties)) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys].toSorted();
|
||||
}
|
||||
|
||||
/** Audit an inventory against schema keys, proof paths, and file existence. */
|
||||
export function auditConfigHonorInventory(params: {
|
||||
prefixes: string[];
|
||||
rows: ConfigHonorInventoryRow[];
|
||||
expectedKeys?: string[];
|
||||
repoRoot?: string;
|
||||
}): ConfigHonorAuditResult {
|
||||
const repoRoot = params.repoRoot ?? REPO_ROOT;
|
||||
const schemaKeys = listSchemaLeafKeysForPrefixes(params.prefixes);
|
||||
const expectedKeys = new Set(params.expectedKeys ?? schemaKeys);
|
||||
const rowKeys = new Set(params.rows.map((row) => row.key));
|
||||
const missingKeys = [...expectedKeys].filter((key) => !rowKeys.has(key)).toSorted();
|
||||
const extraKeys = params.rows
|
||||
.map((row) => row.key)
|
||||
.filter((key) => !expectedKeys.has(key))
|
||||
.toSorted();
|
||||
|
||||
const missingSchemaPaths = params.rows.flatMap((row) =>
|
||||
row.schemaPaths.filter((schemaPath) => !hasSchemaPath(schemaPath)),
|
||||
);
|
||||
|
||||
const missingFiles = params.rows.flatMap((row) => {
|
||||
const files = [...row.typePaths, ...row.mergePaths, ...row.consumerPaths, ...row.testPaths];
|
||||
return files
|
||||
.filter((relativePath) => !fs.existsSync(path.join(repoRoot, relativePath)))
|
||||
.map((relativePath) => `${row.key}:${relativePath}`);
|
||||
});
|
||||
|
||||
const missingProofs = params.rows
|
||||
.map((row) => {
|
||||
const missing: ConfigHonorProofKey[] = [
|
||||
row.schemaPaths.length === 0 ? "schemaPaths" : null,
|
||||
row.typePaths.length === 0 ? "typePaths" : null,
|
||||
row.mergePaths.length === 0 ? "mergePaths" : null,
|
||||
row.consumerPaths.length === 0 ? "consumerPaths" : null,
|
||||
row.reloadPaths.length === 0 ? "reloadPaths" : null,
|
||||
row.testPaths.length === 0 ? "testPaths" : null,
|
||||
].filter((value): value is ConfigHonorProofKey => value !== null);
|
||||
return missing.length > 0 ? { key: row.key, missing } : null;
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
|
||||
return {
|
||||
schemaKeys,
|
||||
missingKeys,
|
||||
extraKeys,
|
||||
missingSchemaPaths,
|
||||
missingFiles,
|
||||
missingProofs,
|
||||
};
|
||||
}
|
||||
177
test/helpers/config/heartbeat-config-honor.inventory.ts
Normal file
177
test/helpers/config/heartbeat-config-honor.inventory.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Heartbeat config honor inventory lists heartbeat config ownership rows.
|
||||
import type { ConfigHonorInventoryRow } from "./config-honor-audit.js";
|
||||
|
||||
// Inventory of heartbeat config keys and the proof paths that should honor them.
|
||||
|
||||
/** Config prefixes audited for heartbeat key coverage. */
|
||||
export const HEARTBEAT_CONFIG_PREFIXES = [
|
||||
"agents.defaults.heartbeat",
|
||||
"agents.list.*.heartbeat",
|
||||
] as const;
|
||||
|
||||
/** Heartbeat config honor inventory consumed by config audit tests. */
|
||||
export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [
|
||||
{
|
||||
key: "every",
|
||||
schemaPaths: ["agents.defaults.heartbeat.every", "agents.list.*.heartbeat.every"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"src/gateway/config-reload.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
schemaPaths: ["agents.defaults.heartbeat.model", "agents.list.*.heartbeat.model"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.model-override.test.ts",
|
||||
"src/gateway/config-reload.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "prompt",
|
||||
schemaPaths: ["agents.defaults.heartbeat.prompt", "agents.list.*.heartbeat.prompt"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "includeSystemPromptSection",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.includeSystemPromptSection",
|
||||
"agents.list.*.heartbeat.includeSystemPromptSection",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/agents/heartbeat-system-prompt.ts"],
|
||||
consumerPaths: [
|
||||
"src/agents/heartbeat-system-prompt.ts",
|
||||
"src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts",
|
||||
],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/agents/heartbeat-system-prompt.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "ackMaxChars",
|
||||
schemaPaths: ["agents.defaults.heartbeat.ackMaxChars", "agents.list.*.heartbeat.ackMaxChars"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "suppressToolErrorWarnings",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.suppressToolErrorWarnings",
|
||||
"agents.list.*.heartbeat.suppressToolErrorWarnings",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "timeoutSeconds",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.timeoutSeconds",
|
||||
"agents.list.*.heartbeat.timeoutSeconds",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/auto-reply/reply/get-reply.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/config/zod-schema.agent-defaults.test.ts",
|
||||
"src/infra/heartbeat-runner.model-override.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lightContext",
|
||||
schemaPaths: ["agents.defaults.heartbeat.lightContext", "agents.list.*.heartbeat.lightContext"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/bootstrap-files.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.model-override.test.ts",
|
||||
"src/agents/bootstrap-files.test.ts",
|
||||
"src/gateway/config-reload.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "isolatedSession",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.isolatedSession",
|
||||
"agents.list.*.heartbeat.isolatedSession",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
schemaPaths: ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"src/cron/service.main-job-passes-heartbeat-target-last.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "to",
|
||||
schemaPaths: ["agents.defaults.heartbeat.to", "agents.list.*.heartbeat.to"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "accountId",
|
||||
schemaPaths: ["agents.defaults.heartbeat.accountId", "agents.list.*.heartbeat.accountId"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: [
|
||||
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "directPolicy",
|
||||
schemaPaths: ["agents.defaults.heartbeat.directPolicy", "agents.list.*.heartbeat.directPolicy"],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"],
|
||||
consumerPaths: ["src/infra/outbound/targets.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
{
|
||||
key: "includeReasoning",
|
||||
schemaPaths: [
|
||||
"agents.defaults.heartbeat.includeReasoning",
|
||||
"agents.list.*.heartbeat.includeReasoning",
|
||||
],
|
||||
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
|
||||
mergePaths: ["src/infra/heartbeat-runner.ts"],
|
||||
consumerPaths: ["src/infra/heartbeat-runner.ts"],
|
||||
reloadPaths: ["src/gateway/config-reload-plan.ts"],
|
||||
testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"],
|
||||
},
|
||||
];
|
||||
18
test/helpers/config/redact-snapshot-test-hints.ts
Normal file
18
test/helpers/config/redact-snapshot-test-hints.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Redaction snapshot hints list config UI hints used by redaction tests.
|
||||
import type { ConfigUiHints } from "../../../src/config/schema.js";
|
||||
|
||||
// Keep this fixture minimal so redaction tests exercise the hint-matching
|
||||
// behavior they care about without paying to build the full config schema graph.
|
||||
export const redactSnapshotTestHints: ConfigUiHints = {
|
||||
"agents.defaults.memorySearch.remote.apiKey": { sensitive: true },
|
||||
"agents.list[].memorySearch.remote.apiKey": { sensitive: true },
|
||||
"broadcast.apiToken[]": { sensitive: true },
|
||||
"env.GROQ_API_KEY": { sensitive: true },
|
||||
"gateway.auth.password": { sensitive: true },
|
||||
"models.providers.*.apiKey": { sensitive: true },
|
||||
"models.providers.*.baseUrl": { sensitive: true },
|
||||
"models.providers.*.request.headers.*": { sensitive: true },
|
||||
"models.providers.*.request.auth.token": { sensitive: true },
|
||||
"models.providers.*.request.proxy.url": { sensitive: true },
|
||||
"skills.entries.*.env.GEMINI_API_KEY": { sensitive: true },
|
||||
};
|
||||
159
test/helpers/cron/service-regression-fixtures.ts
Normal file
159
test/helpers/cron/service-regression-fixtures.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// Cron service regression fixtures build reusable scheduled job states.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { clearAllBootstrapSnapshots } from "../../../src/agents/bootstrap-cache.js";
|
||||
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions/store.js";
|
||||
import type { CronServiceDeps } from "../../../src/cron/service/state.js";
|
||||
import {
|
||||
createDeferred,
|
||||
createRunningCronServiceState,
|
||||
} from "../../../src/cron/service.test-harness.js";
|
||||
import type { CronJob, CronJobState } from "../../../src/cron/types.js";
|
||||
import { resetAgentRunContextForTest } from "../../../src/infra/agent-events.js";
|
||||
import {
|
||||
resetCommandQueueStateForTest,
|
||||
waitForActiveTasks,
|
||||
} from "../../../src/process/command-queue.js";
|
||||
import { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js";
|
||||
|
||||
const TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1_000;
|
||||
|
||||
export const noopLogger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
trace: () => {},
|
||||
};
|
||||
|
||||
export function setupCronRegressionFixtures(options?: { prefix?: string; baseTimeIso?: string }) {
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "cron-issues-"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetCommandQueueStateForTest();
|
||||
useFrozenTime(options?.baseTimeIso ?? "2026-02-06T10:05:00.000Z");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllTimers();
|
||||
vi.restoreAllMocks();
|
||||
useRealTime();
|
||||
await waitForActiveTasks(250);
|
||||
resetCommandQueueStateForTest();
|
||||
clearSessionStoreCacheForTest();
|
||||
resetAgentRunContextForTest();
|
||||
clearAllBootstrapSnapshots();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
useRealTime();
|
||||
await waitForActiveTasks(250);
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
return {
|
||||
makeStorePath() {
|
||||
return {
|
||||
storePath: path.join(fixtureRoot, `case-${fixtureCount++}.jobs.json`),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { createDeferred, createRunningCronServiceState };
|
||||
|
||||
export function topOfHourOffsetMs(jobId: string) {
|
||||
const digest = crypto.createHash("sha256").update(jobId).digest();
|
||||
return digest.readUInt32BE(0) % TOP_OF_HOUR_STAGGER_MS;
|
||||
}
|
||||
|
||||
export function createDueIsolatedJob(params: {
|
||||
id: string;
|
||||
nowMs: number;
|
||||
nextRunAtMs: number;
|
||||
deleteAfterRun?: boolean;
|
||||
}): CronJob {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.id,
|
||||
enabled: true,
|
||||
deleteAfterRun: params.deleteAfterRun ?? false,
|
||||
createdAtMs: params.nowMs,
|
||||
updatedAtMs: params.nowMs,
|
||||
schedule: { kind: "at", at: new Date(params.nextRunAtMs).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: params.id },
|
||||
delivery: { mode: "none" },
|
||||
state: { nextRunAtMs: params.nextRunAtMs },
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultIsolatedRunner(): CronServiceDeps["runIsolatedAgentJob"] {
|
||||
return vi.fn().mockResolvedValue({
|
||||
status: "ok",
|
||||
summary: "ok",
|
||||
}) as CronServiceDeps["runIsolatedAgentJob"];
|
||||
}
|
||||
|
||||
export function createAbortAwareIsolatedRunner(summary = "late") {
|
||||
let observedAbortSignal: AbortSignal | undefined;
|
||||
const started = createDeferred<void>();
|
||||
const runIsolatedAgentJob = vi.fn(async ({ abortSignal, onExecutionStarted }) => {
|
||||
observedAbortSignal = abortSignal;
|
||||
started.resolve();
|
||||
onExecutionStarted?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!abortSignal) {
|
||||
return;
|
||||
}
|
||||
if (abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
return { status: "ok" as const, summary };
|
||||
}) as CronServiceDeps["runIsolatedAgentJob"];
|
||||
|
||||
return {
|
||||
runIsolatedAgentJob,
|
||||
getObservedAbortSignal: () => observedAbortSignal,
|
||||
waitForStart: () => started.promise,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIsolatedRegressionJob(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: number;
|
||||
schedule: CronJob["schedule"];
|
||||
payload: CronJob["payload"];
|
||||
state?: CronJobState;
|
||||
}): CronJob {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
createdAtMs: params.scheduledAt - 86_400_000,
|
||||
updatedAtMs: params.scheduledAt - 86_400_000,
|
||||
schedule: params.schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: params.payload,
|
||||
delivery: { mode: "announce" },
|
||||
state: params.state ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCronStoreSnapshot(storePath: string, jobs: unknown[]) {
|
||||
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }), "utf-8");
|
||||
}
|
||||
74
test/helpers/gateway-e2e-harness.test.ts
Normal file
74
test/helpers/gateway-e2e-harness.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Gateway E2E harness tests cover helper server and probe behavior.
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { postJson } from "./gateway-e2e-harness.js";
|
||||
|
||||
let server: Server | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server?.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
async function listen(handler: Parameters<typeof createServer>[0]): Promise<string> {
|
||||
server = createServer(handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
server?.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server did not get a TCP address");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
describe("postJson", () => {
|
||||
it("times out stalled Gateway HTTP helpers", async () => {
|
||||
const baseUrl = await listen((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.write('{"ok":');
|
||||
});
|
||||
|
||||
await expect(postJson(`${baseUrl}/stall`, {}, undefined, { timeoutMs: 25 })).rejects.toThrow(
|
||||
"timed out after 25ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a wall-clock timeout instead of an idle socket timeout", async () => {
|
||||
const baseUrl = await listen((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
const interval = setInterval(() => {
|
||||
res.write(" ");
|
||||
}, 5);
|
||||
res.on("close", () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
await expect(postJson(`${baseUrl}/slow`, {}, undefined, { timeoutMs: 30 })).rejects.toThrow(
|
||||
"timed out after 30ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized Gateway HTTP helper responses", async () => {
|
||||
const baseUrl = await listen((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ data: "x".repeat(128) }));
|
||||
});
|
||||
|
||||
await expect(
|
||||
postJson(`${baseUrl}/large`, {}, undefined, { maxResponseBytes: 32 }),
|
||||
).rejects.toThrow("response exceeded 32 bytes");
|
||||
});
|
||||
});
|
||||
239
test/helpers/gateway-e2e-harness.ts
Normal file
239
test/helpers/gateway-e2e-harness.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// Gateway E2E harness starts test gateway processes and HTTP probes.
|
||||
import { request as httpRequest } from "node:http";
|
||||
import path from "node:path";
|
||||
import { GatewayClient } from "../../src/gateway/client.js";
|
||||
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
|
||||
import { extractFirstTextBlock } from "../../src/shared/chat-message-content.js";
|
||||
import { sleep } from "../../src/utils.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
|
||||
import { createOpenClawTestInstance, type OpenClawTestInstance } from "./openclaw-test-instance.js";
|
||||
|
||||
export { extractFirstTextBlock };
|
||||
|
||||
export type GatewayInstance = OpenClawTestInstance;
|
||||
|
||||
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 10_000;
|
||||
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 15_000;
|
||||
const GATEWAY_NODE_STATUS_POLL_MS = 20;
|
||||
const POST_JSON_TIMEOUT_MS = 15_000;
|
||||
const POST_JSON_MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
|
||||
export type PostJsonOptions = {
|
||||
maxResponseBytes?: number;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
|
||||
const inst = await createOpenClawTestInstance({ name });
|
||||
try {
|
||||
await inst.startGateway();
|
||||
return inst;
|
||||
} catch (err) {
|
||||
await inst.cleanup();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopGatewayInstance(inst: GatewayInstance) {
|
||||
await inst.cleanup();
|
||||
}
|
||||
|
||||
export async function postJson(
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers?: Record<string, string>,
|
||||
options: PostJsonOptions = {},
|
||||
): Promise<{ status: number; json: unknown }> {
|
||||
const payload = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
const timeoutMs = options.timeoutMs ?? POST_JSON_TIMEOUT_MS;
|
||||
const maxResponseBytes = options.maxResponseBytes ?? POST_JSON_MAX_RESPONSE_BYTES;
|
||||
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let responseBytes = 0;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
const finish = (result: { status: number; json: unknown } | { error: Error }) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
if ("error" in result) {
|
||||
reject(result.error);
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const req = httpRequest(
|
||||
{
|
||||
method: "POST",
|
||||
hostname: parsed.hostname,
|
||||
port: Number(parsed.port),
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(payload),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
responseBytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (responseBytes > maxResponseBytes) {
|
||||
const error = new Error(`POST ${url} response exceeded ${maxResponseBytes} bytes`);
|
||||
req.destroy(error);
|
||||
res.destroy(error);
|
||||
finish({ error });
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
let json: unknown = null;
|
||||
if (data.trim()) {
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
} catch {
|
||||
json = data;
|
||||
}
|
||||
}
|
||||
finish({ status: res.statusCode ?? 0, json });
|
||||
});
|
||||
res.on("error", (error) => finish({ error }));
|
||||
},
|
||||
);
|
||||
timeout = setTimeout(() => {
|
||||
req.destroy(new Error(`POST ${url} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
req.on("error", (error) => finish({ error }));
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectNode(
|
||||
inst: GatewayInstance,
|
||||
label: string,
|
||||
): Promise<{ client: GatewayClient; nodeId: string }> {
|
||||
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
|
||||
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const nodeId = deviceIdentity.deviceId;
|
||||
const client = await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${inst.port}`,
|
||||
token: inst.gatewayToken,
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: label,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["system"],
|
||||
commands: ["system.run"],
|
||||
deviceIdentity,
|
||||
timeoutMessage: `timeout waiting for ${label} to connect`,
|
||||
});
|
||||
return { client, nodeId };
|
||||
}
|
||||
|
||||
async function connectStatusClient(
|
||||
inst: GatewayInstance,
|
||||
timeoutMs = GATEWAY_CONNECT_STATUS_TIMEOUT_MS,
|
||||
): Promise<GatewayClient> {
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(client);
|
||||
};
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: `ws://127.0.0.1:${inst.port}`,
|
||||
connectChallengeTimeoutMs: 0,
|
||||
token: inst.gatewayToken,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
clientDisplayName: `status-${inst.name}`,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
onHelloOk: () => {
|
||||
finish();
|
||||
},
|
||||
onConnectError: (err) => finish(err),
|
||||
onClose: (code, reason) => {
|
||||
finish(new Error(`gateway closed (${code}): ${reason}`));
|
||||
},
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
finish(new Error(`timeout waiting for status client hello for ${inst.name}`));
|
||||
}, timeoutMs);
|
||||
|
||||
client.start();
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForNodeStatus(
|
||||
inst: GatewayInstance,
|
||||
nodeId: string,
|
||||
timeoutMs = GATEWAY_NODE_STATUS_TIMEOUT_MS,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
let client: GatewayClient | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
client = await connectStatusClient(
|
||||
inst,
|
||||
Math.min(2_000, GATEWAY_CONNECT_STATUS_TIMEOUT_MS, Math.max(1, deadline - Date.now())),
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
|
||||
}
|
||||
}
|
||||
if (!client) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const list = await client.request("node.list", {});
|
||||
const match = list.nodes?.find((n) => n.nodeId === nodeId);
|
||||
if (match?.connected && match?.paired) {
|
||||
return;
|
||||
}
|
||||
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
|
||||
} finally {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
const suffix = lastError instanceof Error ? `: ${lastError.message}` : "";
|
||||
throw new Error(`timeout waiting for node status for ${nodeId}${suffix}`);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Android node capability policy config fixture describes gateway policy config.
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
// Test helper for unwrapping gateway config.get response shapes.
|
||||
|
||||
/** Narrow unknown payloads to plain records for fixture parsing. */
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** Unwrap current and legacy remote config snapshot envelopes. */
|
||||
export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig {
|
||||
const rawObj = asRecord(raw);
|
||||
const resolved = asRecord(rawObj.resolved);
|
||||
if (Object.keys(resolved).length > 0) {
|
||||
return resolved as OpenClawConfig;
|
||||
}
|
||||
|
||||
const wrapped = asRecord(rawObj.config);
|
||||
if (Object.keys(wrapped).length > 0) {
|
||||
return wrapped as OpenClawConfig;
|
||||
}
|
||||
|
||||
const legacyPayload = asRecord(rawObj.payload);
|
||||
const legacyResolved = asRecord(legacyPayload.resolved);
|
||||
if (Object.keys(legacyResolved).length > 0) {
|
||||
return legacyResolved as OpenClawConfig;
|
||||
}
|
||||
|
||||
const legacyConfig = asRecord(legacyPayload.config);
|
||||
if (Object.keys(legacyConfig).length > 0) {
|
||||
return legacyConfig as OpenClawConfig;
|
||||
}
|
||||
|
||||
if (Object.keys(rawObj).length > 0 && !Object.hasOwn(rawObj, "payload")) {
|
||||
return rawObj as OpenClawConfig;
|
||||
}
|
||||
|
||||
throw new Error("remote gateway config.get returned empty config payload");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Android node capability policy source fixture describes gateway connection inputs.
|
||||
import type { GatewayConnectionDetails } from "../../../src/gateway/call.js";
|
||||
|
||||
// Test helper for deciding when Android node policy config should be fetched remotely.
|
||||
|
||||
/** Return true when gateway details represent a remote node, not local loopback. */
|
||||
export function shouldFetchRemotePolicyConfig(details: GatewayConnectionDetails): boolean {
|
||||
return details.urlSource !== "local loopback";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Android node capability required-command helper.
|
||||
|
||||
export const ANDROID_NODE_REQUIRED_NON_INTERACTIVE_COMMANDS = [
|
||||
"device.health",
|
||||
"device.info",
|
||||
"device.permissions",
|
||||
"device.status",
|
||||
] as const;
|
||||
|
||||
export function findMissingRequiredAndroidNodeCommands(params: {
|
||||
commandsToRun: readonly string[];
|
||||
requiredCommands: readonly string[];
|
||||
}): string[] {
|
||||
const runnable = new Set(params.commandsToRun);
|
||||
return params.requiredCommands
|
||||
.filter((command) => !runnable.has(command))
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
8
test/helpers/image-fixtures.ts
Normal file
8
test/helpers/image-fixtures.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Image fixture helper re-exports shared media fixture paths.
|
||||
export {
|
||||
createGrayscaleAlphaPngBuffer,
|
||||
createNoisyPngBuffer,
|
||||
createNoisyRgbaBuffer,
|
||||
createSolidPngBuffer,
|
||||
createTinyJpegBuffer,
|
||||
} from "../../src/plugin-sdk/test-helpers/image-fixtures.js";
|
||||
113
test/helpers/infra/heartbeat-runner-channel-plugins.ts
Normal file
113
test/helpers/infra/heartbeat-runner-channel-plugins.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Heartbeat runner channel plugin fixtures build channel plugin contracts for tests.
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelMessagingAdapter,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../src/infra/outbound/send-deps.js";
|
||||
import { createOutboundTestPlugin } from "../../../src/test-utils/channel-plugins.js";
|
||||
|
||||
// Channel plugin fixtures used by heartbeat runner tests.
|
||||
|
||||
type HeartbeatSendChannelId = "slack" | "telegram" | "whatsapp";
|
||||
type HeartbeatSendFn = (
|
||||
to: string,
|
||||
text: string,
|
||||
opts?: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
/** Create an outbound adapter that routes through heartbeat send deps. */
|
||||
function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter {
|
||||
return {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ to, text, deps, cfg, accountId, replyToId, threadId, ...opts }) => {
|
||||
const send = resolveOutboundSendDep<HeartbeatSendFn>(deps as OutboundSendDeps, channelId);
|
||||
if (!send) {
|
||||
throw new Error(`Missing ${channelId} outbound send dependency`);
|
||||
}
|
||||
const baseOptions = {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId,
|
||||
};
|
||||
const sendOptions =
|
||||
channelId === "telegram"
|
||||
? {
|
||||
...baseOptions,
|
||||
...(typeof threadId === "number" ? { messageThreadId: threadId } : {}),
|
||||
...(typeof replyToId === "string" ? { replyToMessageId: Number(replyToId) } : {}),
|
||||
}
|
||||
: {
|
||||
...baseOptions,
|
||||
...opts,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
};
|
||||
return (await send(to, text, sendOptions)) as never;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a channel plugin fixture with heartbeat/outbound behavior. */
|
||||
function createHeartbeatChannelPlugin(params: {
|
||||
id: HeartbeatSendChannelId;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
heartbeat?: ChannelPlugin["heartbeat"];
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
...createOutboundTestPlugin({
|
||||
id: params.id as ChannelId,
|
||||
label: params.label,
|
||||
docsPath: params.docsPath,
|
||||
outbound: createHeartbeatOutboundAdapter(params.id),
|
||||
...(params.messaging ? { messaging: params.messaging } : {}),
|
||||
}),
|
||||
...(params.heartbeat ? { heartbeat: params.heartbeat } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Slack heartbeat channel fixture. */
|
||||
export const heartbeatRunnerSlackPlugin = createHeartbeatChannelPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
});
|
||||
|
||||
/** Telegram heartbeat channel fixture with thread preservation. */
|
||||
export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
messaging: {
|
||||
preserveHeartbeatThreadIdForGroupRoute: true,
|
||||
},
|
||||
});
|
||||
|
||||
/** WhatsApp heartbeat channel fixture with readiness checks. */
|
||||
export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
heartbeat: {
|
||||
checkReady: async ({ cfg, deps }) => {
|
||||
if (cfg.web?.enabled === false) {
|
||||
return { ok: false, reason: "whatsapp-disabled" };
|
||||
}
|
||||
const authExists = await (deps?.webAuthExists ?? (async () => true))();
|
||||
if (!authExists) {
|
||||
return { ok: false, reason: "whatsapp-not-linked" };
|
||||
}
|
||||
const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() : true;
|
||||
if (!listenerActive) {
|
||||
return { ok: false, reason: "whatsapp-not-running" };
|
||||
}
|
||||
return { ok: true, reason: "ok" };
|
||||
},
|
||||
},
|
||||
});
|
||||
21
test/helpers/live-image-probe.test.ts
Normal file
21
test/helpers/live-image-probe.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Live image probe tests cover generated probe image payloads.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderCatFacePngBase64, renderSolidColorPngBase64 } from "./live-image-probe.js";
|
||||
|
||||
describe("live image probe", () => {
|
||||
it("leaves room for the unclipped bottom CAT label", () => {
|
||||
const png = Buffer.from(renderCatFacePngBase64(), "base64");
|
||||
|
||||
expect(png.toString("ascii", 1, 4)).toBe("PNG");
|
||||
expect(png.readUInt32BE(16)).toBe(256);
|
||||
expect(png.readUInt32BE(20)).toBeGreaterThanOrEqual(274);
|
||||
});
|
||||
|
||||
it("renders a small solid-color probe attachment", () => {
|
||||
const png = Buffer.from(renderSolidColorPngBase64({ r: 220, g: 32, b: 32 }), "base64");
|
||||
|
||||
expect(png.toString("ascii", 1, 4)).toBe("PNG");
|
||||
expect(png.readUInt32BE(16)).toBe(192);
|
||||
expect(png.readUInt32BE(20)).toBe(192);
|
||||
});
|
||||
});
|
||||
461
test/helpers/live-image-probe.ts
Normal file
461
test/helpers/live-image-probe.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
// Live image probe helpers build tiny image fixtures for live provider tests.
|
||||
import { encodePngRgba, fillPixel } from "../../src/media/png-encode.js";
|
||||
|
||||
const GLYPH_ROWS_5X7: Record<string, number[]> = {
|
||||
"0": [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
|
||||
"1": [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
|
||||
"2": [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
|
||||
"3": [0b11110, 0b00001, 0b00001, 0b01110, 0b00001, 0b00001, 0b11110],
|
||||
"4": [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
|
||||
"5": [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
|
||||
"6": [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
|
||||
"7": [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
|
||||
"8": [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
|
||||
"9": [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
|
||||
|
||||
A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
|
||||
B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
|
||||
C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
|
||||
D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
|
||||
E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
|
||||
F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
|
||||
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
|
||||
};
|
||||
|
||||
function drawGlyph5x7(params: {
|
||||
buf: Buffer;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
char: string;
|
||||
scale: number;
|
||||
color: { r: number; g: number; b: number; a?: number };
|
||||
}) {
|
||||
const rows = GLYPH_ROWS_5X7[params.char];
|
||||
if (!rows) {
|
||||
return;
|
||||
}
|
||||
for (let row = 0; row < 7; row += 1) {
|
||||
const bits = rows[row] ?? 0;
|
||||
for (let col = 0; col < 5; col += 1) {
|
||||
const on = (bits & (1 << (4 - col))) !== 0;
|
||||
if (!on) {
|
||||
continue;
|
||||
}
|
||||
for (let dy = 0; dy < params.scale; dy += 1) {
|
||||
for (let dx = 0; dx < params.scale; dx += 1) {
|
||||
fillPixel(
|
||||
params.buf,
|
||||
params.x + col * params.scale + dx,
|
||||
params.y + row * params.scale + dy,
|
||||
params.width,
|
||||
params.color.r,
|
||||
params.color.g,
|
||||
params.color.b,
|
||||
params.color.a ?? 255,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawText(params: {
|
||||
buf: Buffer;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
scale: number;
|
||||
color: { r: number; g: number; b: number; a?: number };
|
||||
}) {
|
||||
const text = params.text.toUpperCase();
|
||||
let cursorX = params.x;
|
||||
for (const raw of text) {
|
||||
const ch = raw in GLYPH_ROWS_5X7 ? raw : raw.toUpperCase();
|
||||
drawGlyph5x7({
|
||||
buf: params.buf,
|
||||
width: params.width,
|
||||
x: cursorX,
|
||||
y: params.y,
|
||||
char: ch,
|
||||
scale: params.scale,
|
||||
color: params.color,
|
||||
});
|
||||
cursorX += 6 * params.scale;
|
||||
}
|
||||
}
|
||||
|
||||
function measureTextWidthPx(text: string, scale: number) {
|
||||
return text.length * 6 * scale - scale; // 5px glyph + 1px space
|
||||
}
|
||||
|
||||
export function renderBitmapTextPngBase64(
|
||||
text: string,
|
||||
options: {
|
||||
background?: { r: number; g: number; b: number; a?: number };
|
||||
foreground?: { r: number; g: number; b: number; a?: number };
|
||||
padding?: number;
|
||||
scale?: number;
|
||||
} = {},
|
||||
): string {
|
||||
const normalized = text.trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
throw new Error("bitmap text image requires non-empty text");
|
||||
}
|
||||
const unsupported = Array.from(normalized).filter((ch) => !(ch in GLYPH_ROWS_5X7));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(`bitmap text image contains unsupported glyphs: ${unsupported.join(",")}`);
|
||||
}
|
||||
const scale = Math.max(1, Math.floor(options.scale ?? 4));
|
||||
const padding = Math.max(0, Math.floor(options.padding ?? 8));
|
||||
const width = measureTextWidthPx(normalized, scale) + padding * 2;
|
||||
const height = 7 * scale + padding * 2;
|
||||
const background = options.background ?? { r: 245, g: 247, b: 250, a: 255 };
|
||||
const foreground = options.foreground ?? { r: 18, g: 24, b: 33, a: 255 };
|
||||
const buf = Buffer.alloc(width * height * 4);
|
||||
fillRect({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: width,
|
||||
h: height,
|
||||
color: background,
|
||||
});
|
||||
drawText({
|
||||
buf,
|
||||
width,
|
||||
x: padding,
|
||||
y: padding,
|
||||
text: normalized,
|
||||
scale,
|
||||
color: foreground,
|
||||
});
|
||||
return encodePngRgba(buf, width, height).toString("base64");
|
||||
}
|
||||
|
||||
function fillRect(params: {
|
||||
buf: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
color: { r: number; g: number; b: number; a?: number };
|
||||
}) {
|
||||
const startX = Math.max(0, params.x);
|
||||
const startY = Math.max(0, params.y);
|
||||
const endX = Math.min(params.width, params.x + params.w);
|
||||
const endY = Math.min(params.height, params.y + params.h);
|
||||
for (let y = startY; y < endY; y += 1) {
|
||||
for (let x = startX; x < endX; x += 1) {
|
||||
fillPixel(
|
||||
params.buf,
|
||||
x,
|
||||
y,
|
||||
params.width,
|
||||
params.color.r,
|
||||
params.color.g,
|
||||
params.color.b,
|
||||
params.color.a ?? 255,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillEllipse(params: {
|
||||
buf: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
rx: number;
|
||||
ry: number;
|
||||
color: { r: number; g: number; b: number; a?: number };
|
||||
}) {
|
||||
for (
|
||||
let y = Math.max(0, params.cy - params.ry);
|
||||
y <= Math.min(params.height - 1, params.cy + params.ry);
|
||||
y += 1
|
||||
) {
|
||||
for (
|
||||
let x = Math.max(0, params.cx - params.rx);
|
||||
x <= Math.min(params.width - 1, params.cx + params.rx);
|
||||
x += 1
|
||||
) {
|
||||
const dx = (x - params.cx) / params.rx;
|
||||
const dy = (y - params.cy) / params.ry;
|
||||
if (dx * dx + dy * dy <= 1) {
|
||||
fillPixel(
|
||||
params.buf,
|
||||
x,
|
||||
y,
|
||||
params.width,
|
||||
params.color.r,
|
||||
params.color.g,
|
||||
params.color.b,
|
||||
params.color.a ?? 255,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillTriangle(params: {
|
||||
buf: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
a: { x: number; y: number };
|
||||
b: { x: number; y: number };
|
||||
c: { x: number; y: number };
|
||||
color: { r: number; g: number; b: number; a?: number };
|
||||
}) {
|
||||
const minX = Math.max(0, Math.min(params.a.x, params.b.x, params.c.x));
|
||||
const maxX = Math.min(params.width - 1, Math.max(params.a.x, params.b.x, params.c.x));
|
||||
const minY = Math.max(0, Math.min(params.a.y, params.b.y, params.c.y));
|
||||
const maxY = Math.min(params.height - 1, Math.max(params.a.y, params.b.y, params.c.y));
|
||||
const area =
|
||||
(params.b.x - params.a.x) * (params.c.y - params.a.y) -
|
||||
(params.b.y - params.a.y) * (params.c.x - params.a.x);
|
||||
if (area === 0) {
|
||||
return;
|
||||
}
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
const w0 =
|
||||
(params.b.x - params.a.x) * (y - params.a.y) - (params.b.y - params.a.y) * (x - params.a.x);
|
||||
const w1 =
|
||||
(params.c.x - params.b.x) * (y - params.b.y) - (params.c.y - params.b.y) * (x - params.b.x);
|
||||
const w2 =
|
||||
(params.a.x - params.c.x) * (y - params.c.y) - (params.a.y - params.c.y) * (x - params.c.x);
|
||||
if ((w0 <= 0 && w1 <= 0 && w2 <= 0) || (w0 >= 0 && w1 >= 0 && w2 >= 0)) {
|
||||
fillPixel(
|
||||
params.buf,
|
||||
x,
|
||||
y,
|
||||
params.width,
|
||||
params.color.r,
|
||||
params.color.g,
|
||||
params.color.b,
|
||||
params.color.a ?? 255,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBlockCatLabel(params: {
|
||||
buf: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
color: { r: number; g: number; b: number; a?: number };
|
||||
}) {
|
||||
const t = 12;
|
||||
const h = 78;
|
||||
const w = 58;
|
||||
const gap = 20;
|
||||
const cX = params.x;
|
||||
const aX = cX + w + gap;
|
||||
const tX = aX + w + gap;
|
||||
|
||||
fillRect({ ...params, x: cX, y: params.y, w, h: t, color: params.color });
|
||||
fillRect({ ...params, x: cX, y: params.y, w: t, h, color: params.color });
|
||||
fillRect({ ...params, x: cX, y: params.y + h - t, w, h: t, color: params.color });
|
||||
|
||||
fillRect({ ...params, x: aX, y: params.y, w, h: t, color: params.color });
|
||||
fillRect({ ...params, x: aX, y: params.y, w: t, h, color: params.color });
|
||||
fillRect({ ...params, x: aX + w - t, y: params.y, w: t, h, color: params.color });
|
||||
fillRect({
|
||||
...params,
|
||||
x: aX,
|
||||
y: params.y + Math.floor((h - t) / 2),
|
||||
w,
|
||||
h: t,
|
||||
color: params.color,
|
||||
});
|
||||
|
||||
fillRect({ ...params, x: tX, y: params.y, w, h: t, color: params.color });
|
||||
fillRect({
|
||||
...params,
|
||||
x: tX + Math.floor((w - t) / 2),
|
||||
y: params.y,
|
||||
w: t,
|
||||
h,
|
||||
color: params.color,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderCatNoncePngBase64(nonce: string): string {
|
||||
const top = "CAT";
|
||||
const bottom = nonce.toUpperCase();
|
||||
|
||||
const scale = 12;
|
||||
const pad = 18;
|
||||
const gap = 18;
|
||||
|
||||
const topWidth = measureTextWidthPx(top, scale);
|
||||
const bottomWidth = measureTextWidthPx(bottom, scale);
|
||||
const width = Math.max(topWidth, bottomWidth) + pad * 2;
|
||||
const height = pad * 2 + 7 * scale + gap + 7 * scale;
|
||||
|
||||
const buf = Buffer.alloc(width * height * 4, 255);
|
||||
const black = { r: 0, g: 0, b: 0 };
|
||||
|
||||
drawText({
|
||||
buf,
|
||||
width,
|
||||
x: Math.floor((width - topWidth) / 2),
|
||||
y: pad,
|
||||
text: top,
|
||||
scale,
|
||||
color: black,
|
||||
});
|
||||
|
||||
drawText({
|
||||
buf,
|
||||
width,
|
||||
x: Math.floor((width - bottomWidth) / 2),
|
||||
y: pad + 7 * scale + gap,
|
||||
text: bottom,
|
||||
scale,
|
||||
color: black,
|
||||
});
|
||||
|
||||
const png = encodePngRgba(buf, width, height);
|
||||
return png.toString("base64");
|
||||
}
|
||||
|
||||
export function renderSolidColorPngBase64(color: { r: number; g: number; b: number }): string {
|
||||
const width = 192;
|
||||
const height = 192;
|
||||
const buf = Buffer.alloc(width * height * 4, 255);
|
||||
fillRect({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: width,
|
||||
h: height,
|
||||
color,
|
||||
});
|
||||
const png = encodePngRgba(buf, width, height);
|
||||
return png.toString("base64");
|
||||
}
|
||||
|
||||
export function renderCatFacePngBase64(): string {
|
||||
const width = 256;
|
||||
const height = 288;
|
||||
const buf = Buffer.alloc(width * height * 4, 255);
|
||||
const outline = { r: 40, g: 40, b: 40 };
|
||||
const innerEar = { r: 245, g: 182, b: 193 };
|
||||
const nose = { r: 222, g: 102, b: 138 };
|
||||
const whisker = { r: 30, g: 30, b: 30 };
|
||||
|
||||
fillTriangle({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
a: { x: 62, y: 74 },
|
||||
b: { x: 106, y: 12 },
|
||||
c: { x: 134, y: 88 },
|
||||
color: outline,
|
||||
});
|
||||
fillTriangle({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
a: { x: 194, y: 74 },
|
||||
b: { x: 150, y: 12 },
|
||||
c: { x: 122, y: 88 },
|
||||
color: outline,
|
||||
});
|
||||
fillTriangle({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
a: { x: 80, y: 70 },
|
||||
b: { x: 106, y: 34 },
|
||||
c: { x: 122, y: 80 },
|
||||
color: innerEar,
|
||||
});
|
||||
fillTriangle({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
a: { x: 176, y: 70 },
|
||||
b: { x: 150, y: 34 },
|
||||
c: { x: 134, y: 80 },
|
||||
color: innerEar,
|
||||
});
|
||||
fillEllipse({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
cx: 128,
|
||||
cy: 112,
|
||||
rx: 78,
|
||||
ry: 66,
|
||||
color: outline,
|
||||
});
|
||||
fillEllipse({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
cx: 98,
|
||||
cy: 100,
|
||||
rx: 9,
|
||||
ry: 12,
|
||||
color: { r: 255, g: 255, b: 255 },
|
||||
});
|
||||
fillEllipse({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
cx: 158,
|
||||
cy: 100,
|
||||
rx: 9,
|
||||
ry: 12,
|
||||
color: { r: 255, g: 255, b: 255 },
|
||||
});
|
||||
fillEllipse({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
cx: 128,
|
||||
cy: 130,
|
||||
rx: 22,
|
||||
ry: 17,
|
||||
color: { r: 255, g: 255, b: 255 },
|
||||
});
|
||||
fillTriangle({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
a: { x: 128, y: 122 },
|
||||
b: { x: 118, y: 136 },
|
||||
c: { x: 138, y: 136 },
|
||||
color: nose,
|
||||
});
|
||||
fillRect({ buf, width, height, x: 127, y: 136, w: 2, h: 15, color: whisker });
|
||||
fillRect({ buf, width, height, x: 74, y: 134, w: 42, h: 2, color: whisker });
|
||||
fillRect({ buf, width, height, x: 140, y: 134, w: 42, h: 2, color: whisker });
|
||||
fillRect({ buf, width, height, x: 80, y: 146, w: 34, h: 2, color: whisker });
|
||||
fillRect({ buf, width, height, x: 142, y: 146, w: 34, h: 2, color: whisker });
|
||||
drawBlockCatLabel({
|
||||
buf,
|
||||
width,
|
||||
height,
|
||||
x: 21,
|
||||
y: 190,
|
||||
color: outline,
|
||||
});
|
||||
|
||||
const png = encodePngRgba(buf, width, height);
|
||||
return png.toString("base64");
|
||||
}
|
||||
21
test/helpers/media-generation/bundled-provider-builders.ts
Normal file
21
test/helpers/media-generation/bundled-provider-builders.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Media generation provider builders create bundled provider fixtures for tests.
|
||||
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
||||
import { loadBundledPluginPublicSurfaceSourceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
// Public-surface loader for bundled media provider plugin tests.
|
||||
|
||||
type BundledPluginEntryModule = {
|
||||
default: {
|
||||
register(api: OpenClawPluginApi): void;
|
||||
};
|
||||
};
|
||||
|
||||
/** Load a bundled provider plugin entrypoint through the public surface helper. */
|
||||
export function loadBundledProviderPlugin(pluginId: string): BundledPluginEntryModule["default"] {
|
||||
return (
|
||||
loadBundledPluginPublicSurfaceSourceSync({
|
||||
pluginId,
|
||||
artifactBasename: "index.js",
|
||||
}) as BundledPluginEntryModule
|
||||
).default;
|
||||
}
|
||||
13
test/helpers/normalize-text.ts
Normal file
13
test/helpers/normalize-text.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Text normalization helper strips terminal control sequences from test output.
|
||||
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
|
||||
|
||||
// Snapshot text normalization for terminal output tests.
|
||||
|
||||
/** Strip ANSI, normalize line endings, ellipses, and emoji/surrogate pairs. */
|
||||
export function normalizeTestText(input: string): string {
|
||||
return stripAnsi(input)
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("…", "...")
|
||||
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?")
|
||||
.replace(/[\uD800-\uDFFF]/g, "?");
|
||||
}
|
||||
116
test/helpers/openclaw-test-instance.test.ts
Normal file
116
test/helpers/openclaw-test-instance.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// OpenClaw test instance tests cover spawned test instance lifecycle.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createOpenClawTestInstance, testing } from "./openclaw-test-instance.js";
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
} catch (error) {
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
return;
|
||||
}
|
||||
throw new Error(`Expected missing path: ${targetPath}`);
|
||||
}
|
||||
|
||||
describe("openclaw test instance", () => {
|
||||
it("keeps only bounded child output tails in helper logs", () => {
|
||||
const stdout = testing.createBoundedStringLog();
|
||||
const stderr = testing.createBoundedStringLog();
|
||||
|
||||
testing.appendLogChunk(stdout, `old stdout ${"x".repeat(64)}\n`, 32);
|
||||
testing.appendLogChunk(stdout, "recent stdout\n", 32);
|
||||
testing.appendLogChunk(stderr, `old stderr ${"y".repeat(64)}\n`, 32);
|
||||
testing.appendLogChunk(stderr, "recent stderr\n", 32);
|
||||
|
||||
const logs = testing.formatLogs(stdout, stderr);
|
||||
expect(logs).toContain("[output truncated to last");
|
||||
expect(logs).toContain("recent stdout");
|
||||
expect(logs).toContain("recent stderr");
|
||||
expect(logs).not.toContain("old stdout");
|
||||
expect(logs).not.toContain("old stderr");
|
||||
});
|
||||
|
||||
it("treats signaled gateway children as exited", () => {
|
||||
expect(testing.hasChildExited({ exitCode: null, signalCode: "SIGTERM" })).toBe(true);
|
||||
expect(testing.hasChildExited({ exitCode: 0, signalCode: null })).toBe(true);
|
||||
expect(testing.hasChildExited({ exitCode: null, signalCode: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("fails startup waits immediately after signaled gateway exits", async () => {
|
||||
await expect(
|
||||
testing.waitForPortOpen({ exitCode: null, signalCode: "SIGTERM" }, [], [], 1, 10_000),
|
||||
).rejects.toThrow("gateway exited before listening");
|
||||
});
|
||||
|
||||
it("signals test instance process groups on POSIX", () => {
|
||||
const child = {
|
||||
pid: 1234,
|
||||
kill: vi.fn(() => true),
|
||||
};
|
||||
const killProcess = vi.fn(() => true);
|
||||
|
||||
testing.signalOpenClawTestProcess(child, "SIGKILL", killProcess);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(killProcess).not.toHaveBeenCalled();
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
} else {
|
||||
expect(killProcess).toHaveBeenCalledWith(-1234, "SIGKILL");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates isolated config and spawn env without mutating process env", async () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const inst = await createOpenClawTestInstance({
|
||||
name: "instance-unit",
|
||||
gatewayToken: "gateway-token",
|
||||
hookToken: "hook-token",
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
env: {
|
||||
OPENCLAW_SKIP_CRON: "0",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(process.env.HOME).toBe(previousHome);
|
||||
expect(inst.homeDir).toBe(path.join(inst.state.root, "home"));
|
||||
expect(inst.stateDir).toBe(path.join(inst.homeDir, ".openclaw"));
|
||||
expect(inst.configPath).toBe(path.join(inst.stateDir, "openclaw.json"));
|
||||
expect(inst.env.HOME).toBe(inst.homeDir);
|
||||
expect(inst.env.OPENCLAW_STATE_DIR).toBe(inst.stateDir);
|
||||
expect(inst.env.OPENCLAW_CONFIG_PATH).toBe(inst.configPath);
|
||||
expect(inst.env.OPENCLAW_SKIP_CRON).toBe("0");
|
||||
|
||||
const config = JSON.parse(await fs.readFile(inst.configPath, "utf8"));
|
||||
expect(config).toStrictEqual({
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
port: inst.port,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "gateway-token",
|
||||
},
|
||||
controlUi: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "hook-token",
|
||||
path: "/hooks",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await inst.cleanup();
|
||||
}
|
||||
|
||||
await expectPathMissing(inst.state.root);
|
||||
});
|
||||
});
|
||||
542
test/helpers/openclaw-test-instance.ts
Normal file
542
test/helpers/openclaw-test-instance.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
// OpenClaw test instance helper spawns isolated OpenClaw processes.
|
||||
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import {
|
||||
BUILD_STAMP_FILE,
|
||||
RUNTIME_POSTBUILD_STAMP_FILE,
|
||||
} from "../../scripts/lib/local-build-metadata-paths.mjs";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
type OpenClawTestStateOptions,
|
||||
} from "../../src/test-utils/openclaw-test-state.js";
|
||||
import { sleep } from "../../src/utils.js";
|
||||
|
||||
export type OpenClawTestInstanceOptions = {
|
||||
name: string;
|
||||
cwd?: string;
|
||||
port?: number;
|
||||
gatewayToken?: string;
|
||||
hookToken?: string;
|
||||
config?: Record<string, unknown>;
|
||||
env?: Record<string, string | undefined>;
|
||||
state?: Omit<OpenClawTestStateOptions, "applyEnv" | "gateway" | "env">;
|
||||
gatewayArgs?: string[];
|
||||
startTimeoutMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type OpenClawTestInstanceCommandResult = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type OpenClawTestProcess = ChildProcessByStdio<null, Readable, Readable>;
|
||||
|
||||
export type OpenClawTestInstance = {
|
||||
name: string;
|
||||
port: number;
|
||||
url: string;
|
||||
hookToken: string;
|
||||
gatewayToken: string;
|
||||
homeDir: string;
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
state: OpenClawTestState;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
child?: OpenClawTestProcess;
|
||||
env: NodeJS.ProcessEnv;
|
||||
entrypoint: () => Promise<string[]>;
|
||||
cli: (
|
||||
args: string[],
|
||||
options?: { timeoutMs?: number },
|
||||
) => Promise<OpenClawTestInstanceCommandResult>;
|
||||
startGateway: () => Promise<void>;
|
||||
stopGateway: () => Promise<void>;
|
||||
logs: () => string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
const GATEWAY_START_TIMEOUT_MS = 60_000;
|
||||
const GATEWAY_STOP_TIMEOUT_MS = 1_500;
|
||||
const GATEWAY_ENTRYPOINT_PREPARE_TIMEOUT_MS = 120_000;
|
||||
const COMMAND_TIMEOUT_MS = 30_000;
|
||||
const LOG_TAIL_MAX_BYTES = 256 * 1024;
|
||||
const entrypointPromises = new Map<string, Promise<string[]>>();
|
||||
|
||||
type BoundedStringLog = string[] & {
|
||||
byteLength?: number;
|
||||
truncated?: boolean;
|
||||
};
|
||||
|
||||
type OpenClawTestChildProcess = Pick<OpenClawTestProcess, "kill" | "pid">;
|
||||
|
||||
function createBoundedStringLog(): string[] {
|
||||
const log = [] as BoundedStringLog;
|
||||
log.byteLength = 0;
|
||||
log.truncated = false;
|
||||
return log;
|
||||
}
|
||||
|
||||
function appendLogChunk(log: string[], chunk: unknown, maxBytes = LOG_TAIL_MAX_BYTES): void {
|
||||
const chunks = log as BoundedStringLog;
|
||||
const limit = Math.max(1, maxBytes);
|
||||
const text = String(chunk);
|
||||
const textBytes = Buffer.byteLength(text);
|
||||
if (textBytes >= limit) {
|
||||
const buffer = Buffer.from(text);
|
||||
const tail = buffer.subarray(buffer.length - limit).toString("utf8");
|
||||
chunks.splice(0, chunks.length, tail);
|
||||
chunks.byteLength = Buffer.byteLength(tail);
|
||||
chunks.truncated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
chunks.push(text);
|
||||
chunks.byteLength = (chunks.byteLength ?? 0) + textBytes;
|
||||
while ((chunks.byteLength ?? 0) > limit && chunks.length > 0) {
|
||||
const first = chunks[0] ?? "";
|
||||
const firstBytes = Buffer.byteLength(first);
|
||||
const overflow = (chunks.byteLength ?? 0) - limit;
|
||||
if (firstBytes <= overflow) {
|
||||
chunks.shift();
|
||||
chunks.byteLength = (chunks.byteLength ?? 0) - firstBytes;
|
||||
chunks.truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(first);
|
||||
const tail = buffer.subarray(overflow).toString("utf8");
|
||||
chunks[0] = tail;
|
||||
chunks.byteLength = chunks.reduce((total, entry) => total + Buffer.byteLength(entry), 0);
|
||||
chunks.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
function readLogBuffer(log: string[]): string {
|
||||
const text = log.join("");
|
||||
return (log as BoundedStringLog).truncated
|
||||
? `[output truncated to last ${LOG_TAIL_MAX_BYTES} bytes]\n${text}`
|
||||
: text;
|
||||
}
|
||||
|
||||
async function resolveBuiltGatewayEntrypoint(cwd: string): Promise<string[] | null> {
|
||||
const buildStampPath = path.join(cwd, "dist", BUILD_STAMP_FILE);
|
||||
const runtimePostBuildStampPath = path.join(cwd, "dist", RUNTIME_POSTBUILD_STAMP_FILE);
|
||||
for (const entrypoint of ["dist/index.js", "dist/index.mjs"]) {
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.access(path.join(cwd, entrypoint)),
|
||||
fs.access(buildStampPath),
|
||||
fs.access(runtimePostBuildStampPath),
|
||||
]);
|
||||
return [entrypoint];
|
||||
} catch {
|
||||
// try the next built entrypoint
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareGatewayEntrypoint(cwd: string): Promise<string[]> {
|
||||
const builtEntrypoint = await resolveBuiltGatewayEntrypoint(cwd);
|
||||
if (builtEntrypoint) {
|
||||
return builtEntrypoint;
|
||||
}
|
||||
|
||||
const stdout = createBoundedStringLog();
|
||||
const stderr = createBoundedStringLog();
|
||||
const child = spawn("node", ["scripts/run-node.mjs", "--help"], {
|
||||
cwd,
|
||||
env: { ...process.env, VITEST: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: shouldUseOpenClawTestProcessGroup(),
|
||||
});
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (d) => appendLogChunk(stdout, d));
|
||||
child.stderr?.on("data", (d) => appendLogChunk(stderr, d));
|
||||
|
||||
const completed = await Promise.race([
|
||||
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
sleep(GATEWAY_ENTRYPOINT_PREPARE_TIMEOUT_MS).then(() => null),
|
||||
]);
|
||||
|
||||
if (completed === null) {
|
||||
signalOpenClawTestProcess(child, "SIGKILL");
|
||||
throw new Error(`timeout preparing gateway entrypoint\n${formatLogs(stdout, stderr)}`);
|
||||
}
|
||||
if (completed.code !== 0) {
|
||||
throw new Error(
|
||||
`failed preparing gateway entrypoint (code=${String(completed.code)} signal=${String(
|
||||
completed.signal,
|
||||
)})\n${formatLogs(stdout, stderr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await resolveBuiltGatewayEntrypoint(cwd)) ?? ["scripts/run-node.mjs"];
|
||||
}
|
||||
|
||||
async function resolveGatewayEntrypoint(cwd: string): Promise<string[]> {
|
||||
let promise = entrypointPromises.get(cwd);
|
||||
if (!promise) {
|
||||
promise = prepareGatewayEntrypoint(cwd);
|
||||
entrypointPromises.set(cwd, promise);
|
||||
}
|
||||
return await promise;
|
||||
}
|
||||
|
||||
const getFreePort = async () => {
|
||||
const srv = net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
srv.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close();
|
||||
throw new Error("failed to bind ephemeral port");
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
srv.close(() => resolve());
|
||||
});
|
||||
return addr.port;
|
||||
};
|
||||
|
||||
async function waitForPortOpen(
|
||||
proc: OpenClawTestProcess,
|
||||
chunksOut: string[],
|
||||
chunksErr: string[],
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (hasChildExited(proc)) {
|
||||
throw new Error(
|
||||
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(
|
||||
proc.signalCode,
|
||||
)})\n${formatLogs(chunksOut, chunksErr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
|
||||
await sleep(10);
|
||||
}
|
||||
throw new Error(
|
||||
`timeout waiting for gateway to listen on port ${port}\n${formatLogs(chunksOut, chunksErr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForGatewayExit(child: OpenClawTestProcess, timeoutMs: number): Promise<boolean> {
|
||||
return await Promise.race([
|
||||
new Promise<boolean>((resolve) => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
child.once("exit", () => resolve(true));
|
||||
}),
|
||||
sleep(timeoutMs).then(() => false),
|
||||
]);
|
||||
}
|
||||
|
||||
function hasChildExited(child: Pick<OpenClawTestProcess, "exitCode" | "signalCode">) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeConfig(
|
||||
base: Record<string, unknown>,
|
||||
override: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!override) {
|
||||
return base;
|
||||
}
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
const existing = result[key];
|
||||
result[key] = isRecord(existing) && isRecord(value) ? mergeConfig(existing, value) : value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatLogs(stdout: string[], stderr: string[]): string {
|
||||
return `--- stdout ---\n${readLogBuffer(stdout)}\n--- stderr ---\n${readLogBuffer(stderr)}`;
|
||||
}
|
||||
|
||||
function createInstanceEnv(params: {
|
||||
stateEnv: NodeJS.ProcessEnv;
|
||||
extraEnv: Record<string, string | undefined>;
|
||||
}): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...params.stateEnv,
|
||||
OPENCLAW_GATEWAY_TOKEN: "",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "",
|
||||
OPENCLAW_SKIP_CHANNELS: "1",
|
||||
OPENCLAW_SKIP_PROVIDERS: "1",
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
||||
OPENCLAW_SKIP_CRON: "1",
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
||||
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
|
||||
VITEST: "1",
|
||||
};
|
||||
for (const [key, value] of Object.entries(params.extraEnv)) {
|
||||
if (value === undefined) {
|
||||
delete env[key];
|
||||
} else {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function createOpenClawTestInstance(
|
||||
options: OpenClawTestInstanceOptions,
|
||||
): Promise<OpenClawTestInstance> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const port = options.port ?? (await getFreePort());
|
||||
const gatewayToken = options.gatewayToken ?? `gateway-${options.name}-${randomUUID()}`;
|
||||
const hookToken = options.hookToken ?? `token-${options.name}-${randomUUID()}`;
|
||||
const state = await createOpenClawTestState({
|
||||
label: options.name,
|
||||
layout: "home",
|
||||
...options.state,
|
||||
applyEnv: false,
|
||||
env: options.env,
|
||||
});
|
||||
await state.writeConfig(
|
||||
mergeConfig(
|
||||
{
|
||||
gateway: {
|
||||
port,
|
||||
auth: { mode: "token", token: gatewayToken },
|
||||
controlUi: { enabled: false },
|
||||
},
|
||||
hooks: { enabled: true, token: hookToken, path: "/hooks" },
|
||||
},
|
||||
options.config,
|
||||
),
|
||||
);
|
||||
|
||||
const stdout = createBoundedStringLog();
|
||||
const stderr = createBoundedStringLog();
|
||||
const env = createInstanceEnv({
|
||||
stateEnv: state.env,
|
||||
extraEnv: options.env ?? {},
|
||||
});
|
||||
let child: OpenClawTestProcess | undefined;
|
||||
let cleaned = false;
|
||||
|
||||
const instance: OpenClawTestInstance = {
|
||||
name: options.name,
|
||||
port,
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
hookToken,
|
||||
gatewayToken,
|
||||
homeDir: state.home,
|
||||
stateDir: state.stateDir,
|
||||
configPath: state.configPath,
|
||||
state,
|
||||
stdout,
|
||||
stderr,
|
||||
get child() {
|
||||
return child;
|
||||
},
|
||||
env,
|
||||
entrypoint: () => resolveGatewayEntrypoint(cwd),
|
||||
cli: async (args, commandOptions = {}) => {
|
||||
const entrypoint = await resolveGatewayEntrypoint(cwd);
|
||||
return await runCommand({
|
||||
args: ["node", ...entrypoint, ...args],
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs: commandOptions.timeoutMs ?? COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
},
|
||||
startGateway: async () => {
|
||||
if (child && !hasChildExited(child) && !child.killed) {
|
||||
return;
|
||||
}
|
||||
const entrypoint = await resolveGatewayEntrypoint(cwd);
|
||||
child = spawn(
|
||||
"node",
|
||||
[
|
||||
...entrypoint,
|
||||
"gateway",
|
||||
"--port",
|
||||
String(port),
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--allow-unconfigured",
|
||||
...(options.gatewayArgs ?? []),
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: shouldUseOpenClawTestProcessGroup(),
|
||||
},
|
||||
);
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (d) => appendLogChunk(stdout, d));
|
||||
child.stderr?.on("data", (d) => appendLogChunk(stderr, d));
|
||||
|
||||
try {
|
||||
await waitForPortOpen(
|
||||
child,
|
||||
stdout,
|
||||
stderr,
|
||||
port,
|
||||
options.startTimeoutMs ?? GATEWAY_START_TIMEOUT_MS,
|
||||
);
|
||||
} catch (err) {
|
||||
await instance.stopGateway();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
stopGateway: async () => {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
if (!hasChildExited(child) && !child.killed) {
|
||||
try {
|
||||
signalOpenClawTestProcess(child, "SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
let exited = await waitForGatewayExit(
|
||||
child,
|
||||
options.stopTimeoutMs ?? GATEWAY_STOP_TIMEOUT_MS,
|
||||
);
|
||||
if (!exited && !hasChildExited(child) && !child.killed) {
|
||||
try {
|
||||
signalOpenClawTestProcess(child, "SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
exited = await waitForGatewayExit(child, options.stopTimeoutMs ?? GATEWAY_STOP_TIMEOUT_MS);
|
||||
}
|
||||
if (exited) {
|
||||
child = undefined;
|
||||
}
|
||||
},
|
||||
logs: () => formatLogs(stdout, stderr),
|
||||
cleanup: async () => {
|
||||
if (cleaned) {
|
||||
return;
|
||||
}
|
||||
cleaned = true;
|
||||
await instance.stopGateway();
|
||||
await state.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
async function runCommand(params: {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
}): Promise<OpenClawTestInstanceCommandResult> {
|
||||
const [command, ...args] = params.args;
|
||||
if (!command) {
|
||||
throw new Error("missing command");
|
||||
}
|
||||
const stdout = createBoundedStringLog();
|
||||
const stderr = createBoundedStringLog();
|
||||
const child = spawn(command, args, {
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: shouldUseOpenClawTestProcessGroup(),
|
||||
});
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (d) => appendLogChunk(stdout, d));
|
||||
child.stderr?.on("data", (d) => appendLogChunk(stderr, d));
|
||||
|
||||
const completed = await Promise.race([
|
||||
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
sleep(params.timeoutMs).then(() => null),
|
||||
]);
|
||||
if (completed === null) {
|
||||
signalOpenClawTestProcess(child, "SIGKILL");
|
||||
await waitForGatewayExit(child, GATEWAY_STOP_TIMEOUT_MS);
|
||||
throw new Error(
|
||||
`command timed out after ${params.timeoutMs}ms: ${params.args.join(" ")}\n${formatLogs(stdout, stderr)}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...completed,
|
||||
stdout: readLogBuffer(stdout),
|
||||
stderr: readLogBuffer(stderr),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldUseOpenClawTestProcessGroup(): boolean {
|
||||
return process.platform !== "win32";
|
||||
}
|
||||
|
||||
function signalOpenClawTestProcess(
|
||||
child: OpenClawTestChildProcess,
|
||||
signal: NodeJS.Signals,
|
||||
killProcess: (pid: number, signal: NodeJS.Signals) => boolean = (pid, nextSignal) =>
|
||||
process.kill(pid, nextSignal),
|
||||
): void {
|
||||
if (shouldUseOpenClawTestProcessGroup() && typeof child.pid === "number") {
|
||||
try {
|
||||
killProcess(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the direct child if the process group already exited.
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
appendLogChunk,
|
||||
createBoundedStringLog,
|
||||
formatLogs,
|
||||
hasChildExited,
|
||||
signalOpenClawTestProcess,
|
||||
waitForPortOpen,
|
||||
};
|
||||
20
test/helpers/paths.ts
Normal file
20
test/helpers/paths.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Test path helpers resolve repository-relative fixture paths.
|
||||
import path from "node:path";
|
||||
|
||||
// Cross-platform path containment helper for tests.
|
||||
|
||||
/** Return true when target is equal to or inside base, with Windows case folding. */
|
||||
export function isPathWithinBase(base: string, target: string): boolean {
|
||||
if (process.platform === "win32") {
|
||||
const normalizedBase = path.win32.normalize(path.win32.resolve(base));
|
||||
const normalizedTarget = path.win32.normalize(path.win32.resolve(target));
|
||||
|
||||
const rel = path.win32.relative(normalizedBase.toLowerCase(), normalizedTarget.toLowerCase());
|
||||
return rel === "" || (!rel.startsWith("..") && !path.win32.isAbsolute(rel));
|
||||
}
|
||||
|
||||
const normalizedBase = path.resolve(base);
|
||||
const normalizedTarget = path.resolve(target);
|
||||
const rel = path.relative(normalizedBase, normalizedTarget);
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
}
|
||||
27
test/helpers/pattern-file.ts
Normal file
27
test/helpers/pattern-file.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Pattern file helpers read file pattern lists for test configuration tests.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Temporary JSON pattern file helper for config/pattern tests.
|
||||
|
||||
/** Create a helper that writes JSON pattern files and cleans their temp dirs. */
|
||||
export function createPatternFileHelper(prefix: string) {
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
},
|
||||
writePatternFile(basename: string, value: unknown) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.add(dir);
|
||||
const filePath = path.join(dir, basename);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
|
||||
return filePath;
|
||||
},
|
||||
};
|
||||
}
|
||||
64
test/helpers/plugins/plugin-sdk-stub.cjs
Normal file
64
test/helpers/plugins/plugin-sdk-stub.cjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Universal CommonJS plugin-sdk stub for tests that only need import shape compatibility.
|
||||
"use strict";
|
||||
|
||||
const stub = new Proxy(
|
||||
function pluginSdkStub() {
|
||||
return stub;
|
||||
},
|
||||
{
|
||||
apply() {
|
||||
return stub;
|
||||
},
|
||||
construct() {
|
||||
return stub;
|
||||
},
|
||||
get(_target, prop) {
|
||||
if (prop === "__esModule") {
|
||||
return true;
|
||||
}
|
||||
if (prop === "default") {
|
||||
return stub;
|
||||
}
|
||||
if (prop === "then") {
|
||||
return undefined;
|
||||
}
|
||||
if (prop === Symbol.toPrimitive) {
|
||||
return () => "";
|
||||
}
|
||||
if (prop === "toJSON") {
|
||||
return () => undefined;
|
||||
}
|
||||
if (prop === "toString") {
|
||||
return () => "";
|
||||
}
|
||||
if (prop === "valueOf") {
|
||||
return () => 0;
|
||||
}
|
||||
return stub;
|
||||
},
|
||||
ownKeys(target) {
|
||||
return [...new Set([...Reflect.ownKeys(target), "__esModule", "default"])];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop === "__esModule") {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: true,
|
||||
writable: false,
|
||||
};
|
||||
}
|
||||
if (prop === "default") {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: stub,
|
||||
writable: false,
|
||||
};
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = stub;
|
||||
23
test/helpers/stt-live-audio.test.ts
Normal file
23
test/helpers/stt-live-audio.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// STT live audio tests validate live speech-to-text audio fixtures.
|
||||
import {
|
||||
expectOpenClawLiveTranscriptMarker,
|
||||
normalizeTranscriptForMatch,
|
||||
OPENCLAW_LIVE_TRANSCRIPT_MARKER_RE,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("normalizeTranscriptForMatch", () => {
|
||||
it("normalizes punctuation and common OpenClaw live transcription variants", () => {
|
||||
expect(normalizeTranscriptForMatch("Open-Claw integration OK")).toBe("openclawintegrationok");
|
||||
expect(normalizeTranscriptForMatch("Testing OpenFlaw realtime transcription")).toMatch(
|
||||
/open(?:claw|flaw)/,
|
||||
);
|
||||
expect(normalizeTranscriptForMatch("OpenCore xAI realtime transcription")).toMatch(
|
||||
OPENCLAW_LIVE_TRANSCRIPT_MARKER_RE,
|
||||
);
|
||||
expect(normalizeTranscriptForMatch("OpenCL xAI realtime transcription")).toMatch(
|
||||
OPENCLAW_LIVE_TRANSCRIPT_MARKER_RE,
|
||||
);
|
||||
expectOpenClawLiveTranscriptMarker("OpenClar integration OK");
|
||||
});
|
||||
});
|
||||
69
test/helpers/temp-dir.test.ts
Normal file
69
test/helpers/temp-dir.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTempDirs,
|
||||
createTempDirTracker,
|
||||
makeTempDir,
|
||||
useAutoCleanupTempDirTracker,
|
||||
} from "./temp-dir.js";
|
||||
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
describe("temp-dir test helpers", () => {
|
||||
it("keeps a non-executed temp warning fixture for CI proof", () => {
|
||||
// openclaw-temp-dir: allow test fixture for the temp warning report
|
||||
const warningFixture = 'tmp.dirSync({ prefix: "openclaw-warning-fixture-" })';
|
||||
|
||||
expect(warningFixture).toContain("tmp.dirSync");
|
||||
});
|
||||
|
||||
it("tracks created temp dirs and removes populated dirs", () => {
|
||||
const tracker = createTempDirTracker();
|
||||
const dir = tracker.make("openclaw-temp-dir-helper-");
|
||||
tempDirs.add(dir);
|
||||
fs.writeFileSync(path.join(dir, "artifact.txt"), "artifact\n", "utf8");
|
||||
|
||||
tracker.cleanup();
|
||||
tempDirs.delete(dir);
|
||||
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
expect([...tracker.dirs]).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports existing caller-owned temp dir collections", () => {
|
||||
const dir = makeTempDir(tempDirs, "openclaw-temp-dir-existing-");
|
||||
fs.mkdirSync(path.join(dir, "nested"), { recursive: true });
|
||||
|
||||
cleanupTempDirs(tempDirs);
|
||||
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
expect([...tempDirs]).toEqual([]);
|
||||
});
|
||||
|
||||
describe("auto-cleaning tracker", () => {
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of createdDirs.splice(0)) {
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
}
|
||||
expect([...autoCleanupTracker.dirs]).toEqual([]);
|
||||
});
|
||||
|
||||
const autoCleanupTracker = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
it("tracks temp dirs with Vitest cleanup", () => {
|
||||
const autoCleanedDir = autoCleanupTracker.make("openclaw-temp-dir-auto-");
|
||||
createdDirs.push(autoCleanedDir);
|
||||
fs.writeFileSync(path.join(autoCleanedDir, "artifact.txt"), "artifact\n", "utf8");
|
||||
|
||||
expect(fs.existsSync(autoCleanedDir)).toBe(true);
|
||||
expect("cleanup" in autoCleanupTracker).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
test/helpers/temp-dir.ts
Normal file
71
test/helpers/temp-dir.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Test temp directory helper creates and cleans up temporary directories.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Synchronous temporary directory helpers for tests.
|
||||
|
||||
export type TempDirCollection = string[] | Set<string>;
|
||||
export type RegisterTempDirCleanup = (cleanup: () => void) => unknown;
|
||||
|
||||
export interface TestTempDirTracker {
|
||||
readonly dirs: ReadonlySet<string>;
|
||||
make(prefix: string): string;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export interface AutoCleanupTempDirTracker {
|
||||
readonly dirs: ReadonlySet<string>;
|
||||
make(prefix: string): string;
|
||||
}
|
||||
|
||||
/** Create a temp dir and register it in an array or set for cleanup. */
|
||||
export function makeTempDir(tempDirs: TempDirCollection, prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
if (Array.isArray(tempDirs)) {
|
||||
tempDirs.push(dir);
|
||||
} else {
|
||||
tempDirs.add(dir);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Remove all tracked temporary directories and clear the tracker. */
|
||||
export function cleanupTempDirs(tempDirs: TempDirCollection): void {
|
||||
const dirs = Array.isArray(tempDirs) ? tempDirs.splice(0) : [...tempDirs];
|
||||
for (const dir of dirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
||||
}
|
||||
if (!Array.isArray(tempDirs)) {
|
||||
tempDirs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function createTempDirTracker(): TestTempDirTracker {
|
||||
const dirs = new Set<string>();
|
||||
return {
|
||||
dirs,
|
||||
make(prefix: string): string {
|
||||
return makeTempDir(dirs, prefix);
|
||||
},
|
||||
cleanup(): void {
|
||||
cleanupTempDirs(dirs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a temp dir tracker that Vitest cleans up after each test. */
|
||||
export function useAutoCleanupTempDirTracker(
|
||||
registerCleanup: RegisterTempDirCleanup,
|
||||
): AutoCleanupTempDirTracker {
|
||||
const tracker = createTempDirTracker();
|
||||
registerCleanup(() => {
|
||||
tracker.cleanup();
|
||||
});
|
||||
return {
|
||||
dirs: tracker.dirs,
|
||||
make(prefix: string): string {
|
||||
return tracker.make(prefix);
|
||||
},
|
||||
};
|
||||
}
|
||||
26
test/helpers/temp-repo.ts
Normal file
26
test/helpers/temp-repo.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Temporary repo helper creates Git repositories for integration tests.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Synchronous temporary repository helpers for tests.
|
||||
|
||||
/** Create and track a temporary repo root. */
|
||||
export function makeTempRepoRoot(tempDirs: string[], prefix: string): string {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(repoRoot);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
/** Write formatted JSON to a path, creating parent directories. */
|
||||
export function writeJsonFile(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/** Remove all tracked temporary directories. */
|
||||
export function cleanupTempDirs(tempDirs: string[]): void {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
||||
}
|
||||
}
|
||||
16
test/helpers/ui-style-fixtures.ts
Normal file
16
test/helpers/ui-style-fixtures.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// UI style fixtures load expected UI style files for tests.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function resolveStylePath(path: string): string {
|
||||
const candidates = [resolve(process.cwd(), path), resolve(process.cwd(), "..", path)];
|
||||
const cssPath = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!cssPath) {
|
||||
throw new Error(`Missing style fixture ${path}; checked ${candidates.join(", ")}`);
|
||||
}
|
||||
return cssPath;
|
||||
}
|
||||
|
||||
export function readStyleSheet(path: string): string {
|
||||
return readFileSync(resolveStylePath(path), "utf8");
|
||||
}
|
||||
25
test/helpers/vitest-config-paths.ts
Normal file
25
test/helpers/vitest-config-paths.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Vitest config path helpers resolve test config fixture paths.
|
||||
import path from "node:path";
|
||||
|
||||
// Path normalization helpers for Vitest config snapshot assertions.
|
||||
|
||||
/** Convert absolute paths to cwd-relative POSIX-style paths. */
|
||||
export function normalizeConfigPath(value: unknown): unknown {
|
||||
if (typeof value !== "string" || !path.isAbsolute(value)) {
|
||||
return value;
|
||||
}
|
||||
return path.relative(process.cwd(), value).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
/** Normalize one or many config path values. */
|
||||
export function normalizeConfigPaths(
|
||||
values: readonly unknown[] | string | undefined,
|
||||
): unknown[] | undefined {
|
||||
if (values === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return [normalizeConfigPath(values)];
|
||||
}
|
||||
return values.map((value) => normalizeConfigPath(value));
|
||||
}
|
||||
26
test/helpers/wizard-prompter.ts
Normal file
26
test/helpers/wizard-prompter.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Wizard prompter test helper provides mocked wizard prompt responses.
|
||||
import { vi } from "vitest";
|
||||
import type { WizardPrompter } from "../../src/wizard/prompts.js";
|
||||
|
||||
// Vitest mock prompter for wizard tests.
|
||||
|
||||
/** Create a WizardPrompter with default mocked responses and optional overrides. */
|
||||
export function createWizardPrompter(
|
||||
overrides?: Partial<WizardPrompter>,
|
||||
options?: { defaultSelect?: string },
|
||||
): WizardPrompter {
|
||||
const select = vi.fn(
|
||||
async () => options?.defaultSelect ?? "quickstart",
|
||||
) as unknown as WizardPrompter["select"];
|
||||
return {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select,
|
||||
multiselect: vi.fn(async () => []),
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user