diff --git a/extensions/opencode/api.ts b/extensions/opencode/api.ts index a67ab37f9b3..236a3112872 100644 --- a/extensions/opencode/api.ts +++ b/extensions/opencode/api.ts @@ -1,37 +1,9 @@ -import { - applyAgentDefaultModelPrimary, - resolveAgentModelPrimaryValue, +export { + applyOpencodeZenModelDefault, + OPENCODE_ZEN_DEFAULT_MODEL, } from "openclaw/plugin-sdk/provider-onboard"; -import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "./onboard.js"; export { applyOpencodeZenConfig, applyOpencodeZenProviderConfig, OPENCODE_ZEN_DEFAULT_MODEL_REF, } from "./onboard.js"; - -const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ - "opencode/claude-opus-4-5", - "opencode-zen/claude-opus-4-5", -]); - -export const OPENCODE_ZEN_DEFAULT_MODEL = OPENCODE_ZEN_DEFAULT_MODEL_REF; - -export function applyOpencodeZenModelDefault( - cfg: import("openclaw/plugin-sdk/provider-onboard").OpenClawConfig, -): { - next: import("openclaw/plugin-sdk/provider-onboard").OpenClawConfig; - changed: boolean; -} { - const current = resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model); - const normalizedCurrent = - current && LEGACY_OPENCODE_ZEN_DEFAULT_MODELS.has(current) - ? OPENCODE_ZEN_DEFAULT_MODEL - : current; - if (normalizedCurrent === OPENCODE_ZEN_DEFAULT_MODEL) { - return { next: cfg, changed: false }; - } - return { - next: applyAgentDefaultModelPrimary(cfg, OPENCODE_ZEN_DEFAULT_MODEL), - changed: true, - }; -} diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index 0264c2aec20..31a6694f434 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -68,6 +68,8 @@ setCliRunnerExecuteTestDeps({ setCliRunnerPrepareTestDeps({ makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, + resolveHeartbeatPrompt: async () => "", + resolveOpenClawDocsPath: async () => null, }); type MockRunExit = { @@ -369,6 +371,8 @@ export function restoreCliRunnerPrepareTestDeps() { setCliRunnerPrepareTestDeps({ makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, + resolveHeartbeatPrompt: async () => "", + resolveOpenClawDocsPath: async () => null, }); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 10ef779ea82..dc0090b1369 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -1,4 +1,3 @@ -import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import { createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, @@ -17,7 +16,6 @@ import { import { resolveCliAuthEpoch } from "../cli-auth-epoch.js"; import { resolveCliBackendConfig } from "../cli-backends.js"; import { hashCliSessionText, resolveCliSessionReuse } from "../cli-session.js"; -import { resolveOpenClawDocsPath } from "../docs-path.js"; import { resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, @@ -35,6 +33,12 @@ const prepareDeps = { resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl, getActiveMcpLoopbackRuntime, createMcpLoopbackServerConfig, + resolveHeartbeatPrompt: async ( + prompt: Parameters[0], + ) => (await import("../../auto-reply/heartbeat.js")).resolveHeartbeatPrompt(prompt), + resolveOpenClawDocsPath: async ( + params: Parameters[0], + ) => (await import("../docs-path.js")).resolveOpenClawDocsPath(params), }; export function setCliRunnerPrepareTestDeps(overrides: Partial): void { @@ -146,9 +150,9 @@ export async function prepareCliRunContext( } const heartbeatPrompt = sessionAgentId === defaultAgentId - ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) + ? await prepareDeps.resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) : undefined; - const docsPath = await resolveOpenClawDocsPath({ + const docsPath = await prepareDeps.resolveOpenClawDocsPath({ workspaceDir, argv1: process.argv[1], cwd: process.cwd(), diff --git a/src/auto-reply/reply.media-note.test.ts b/src/auto-reply/reply.media-note.test.ts index 3abc2eabeef..2f171790db7 100644 --- a/src/auto-reply/reply.media-note.test.ts +++ b/src/auto-reply/reply.media-note.test.ts @@ -45,6 +45,7 @@ function makeCfg(home: string) { describe("getReplyFromConfig media note plumbing", () => { beforeEach(async () => { vi.resetModules(); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); resetReplyRuntimeMocks(agentMocks); ({ getReplyFromConfig } = await import("./reply.js")); }); diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 213286f5bd9..43e61cbf9d4 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { fileURLToPath } from "node:url"; import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { @@ -23,10 +24,13 @@ type GeneratedBundledChannelEntry = { const log = createSubsystemLogger("channels"); const OPENCLAW_PACKAGE_ROOT = resolveOpenClawPackageRootSync({ - cwd: process.cwd(), - moduleUrl: import.meta.url, argv1: process.argv[1], - }) ?? process.cwd(); + cwd: process.cwd(), + moduleUrl: import.meta.url.startsWith("file:") ? import.meta.url : undefined, + }) ?? + (import.meta.url.startsWith("file:") + ? path.resolve(fileURLToPath(new URL("../../..", import.meta.url))) + : process.cwd()); function resolveChannelPluginModuleEntry( moduleExport: unknown, @@ -94,15 +98,35 @@ function resolveBundledChannelBoundaryRoot(params: { return path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", params.metadata.dirName); } +function resolveGeneratedBundledChannelModulePath(params: { + metadata: BundledChannelPluginMetadata; + entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; +}): string | null { + if (!params.entry) { + return null; + } + const candidateRoots = [ + path.resolve(OPENCLAW_PACKAGE_ROOT, "dist", "extensions", params.metadata.dirName), + path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", params.metadata.dirName), + ]; + for (const rootDir of candidateRoots) { + const resolved = resolveBundledChannelGeneratedPath( + rootDir, + params.entry, + params.metadata.dirName, + ); + if (resolved) { + return resolved; + } + } + return null; +} + function loadGeneratedBundledChannelModule(params: { metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): unknown { - const modulePath = resolveBundledChannelGeneratedPath( - OPENCLAW_PACKAGE_ROOT, - params.entry, - params.metadata.dirName, - ); + const modulePath = resolveGeneratedBundledChannelModulePath(params); if (!modulePath) { throw new Error(`missing generated module for bundled channel ${params.metadata.manifest.id}`); } diff --git a/src/cron/service.store-load-invalid-main-job.test.ts b/src/cron/service.store-load-invalid-main-job.test.ts index 39bc3588e44..906d735c97b 100644 --- a/src/cron/service.store-load-invalid-main-job.test.ts +++ b/src/cron/service.store-load-invalid-main-job.test.ts @@ -71,7 +71,7 @@ describe("CronService store load", () => { const jobs = await cron.list({ includeDisabled: true }); expect(jobs[0]?.state.lastStatus).toBe("skipped"); - expect(jobs[0]?.state.lastError).toMatch(/main job requires/i); + expect(jobs[0]?.state.lastError).toMatch(/main cron jobs require payload\.kind/i); cron.stop(); }); diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 42e08c2c083..cb54a9b1f41 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -367,7 +367,7 @@ type PreparedManualRun = | { ok: true; ran: false; - reason: "already-running" | "not-due"; + reason: "already-running" | "not-due" | "invalid-spec"; } | { ok: true; @@ -399,6 +399,48 @@ function createCronTaskRunId(jobId: string, startedAt: number): string { return `cron:${jobId}:${startedAt}`; } +async function skipInvalidPersistedManualRun(params: { + state: CronServiceState; + job: CronJob; + mode?: "due" | "force"; + error: unknown; +}) { + const endedAt = params.state.deps.nowMs(); + const errorText = normalizeCronRunErrorText(params.error); + const shouldDelete = applyJobResult( + params.state, + params.job, + { + status: "skipped", + error: errorText, + startedAt: endedAt, + endedAt, + }, + { preserveSchedule: params.mode === "force" }, + ); + + emit(params.state, { + jobId: params.job.id, + action: "finished", + status: "skipped", + error: errorText, + runAtMs: endedAt, + durationMs: params.job.state.lastDurationMs, + nextRunAtMs: params.job.state.nextRunAtMs, + deliveryStatus: params.job.state.lastDeliveryStatus, + deliveryError: params.job.state.lastDeliveryError, + }); + + if (shouldDelete && params.state.store) { + params.state.store.jobs = params.state.store.jobs.filter((entry) => entry.id !== params.job.id); + emit(params.state, { jobId: params.job.id, action: "removed" }); + } + + recomputeNextRunsForMaintenance(params.state, { recomputeExpired: true }); + await persist(params.state); + armTimer(params.state); +} + function tryCreateManualTaskRun(params: { state: CronServiceState; job: CronJob; @@ -489,7 +531,12 @@ async function inspectManualRunPreflight( // persist does not block manual triggers for up to STUCK_RUN_MS (#17554). recomputeNextRunsForMaintenance(state); const job = findJobOrThrow(state, id); - assertSupportedJobSpec(job); + try { + assertSupportedJobSpec(job); + } catch (error) { + await skipInvalidPersistedManualRun({ state, job, mode, error }); + return { ok: true, ran: false, reason: "invalid-spec" as const }; + } if (typeof job.state.runningAtMs === "number") { return { ok: true, ran: false, reason: "already-running" as const }; } diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index 1e38bcb70de..8780708da6b 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -2,7 +2,7 @@ import actualFs from "node:fs"; import actualFsPromises from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; @@ -115,7 +115,8 @@ describe("resolveOpenClawPackageRoot", () => { state.realpathErrors.clear(); }); - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } = await import("./openclaw-root.js")); }); diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 405a4258b0f..8fefa9de9db 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -32,6 +32,21 @@ vi.mock("./outbound-session.js", () => ({ resolveOutboundSessionRoute: vi.fn(async () => null), })); +vi.mock("../../channels/plugins/bootstrap-registry.js", () => ({ + getBootstrapChannelPlugin: (id: string) => + id === "feishu" + ? { + actions: { + messageActionTargetAliases: { + pin: { aliases: ["messageId"] }, + unpin: { aliases: ["messageId"] }, + "list-pins": { aliases: ["chatId"] }, + }, + }, + } + : undefined, +})); + vi.mock("./message-action-threading.js", () => ({ resolveAndApplyOutboundThreadId: vi.fn( ( @@ -231,6 +246,11 @@ describe("runMessageAction plugin dispatch", () => { }, capabilities: { chatTypes: ["direct", "channel"] }, config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, actions: { describeMessageTool: () => ({ actions: ["pin", "list-pins", "member-info"] }), supportsAction: ({ action }) => @@ -335,7 +355,8 @@ describe("runMessageAction plugin dispatch", () => { sessionId: "session-123", agentId: "alpha", toolContext: { - currentChannelId: "chat:oc_123", + currentChannelId: "oc_123", + currentChannelProvider: "feishu", currentThreadTs: "thread-456", currentMessageId: "msg-789", }, @@ -352,7 +373,8 @@ describe("runMessageAction plugin dispatch", () => { agentId: "alpha", mediaLocalRoots: expect.arrayContaining([expectedWorkspaceRoot]), toolContext: expect.objectContaining({ - currentChannelId: "chat:oc_123", + currentChannelId: "oc_123", + currentChannelProvider: "feishu", currentThreadTs: "thread-456", currentMessageId: "msg-789", }), diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 35b1643e653..16f9dd2d8fa 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -25,6 +25,13 @@ export type AgentModelAliasEntry = alias?: string; }; +const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ + "opencode/claude-opus-4-5", + "opencode-zen/claude-opus-4-5", +]); + +export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; + export type ProviderOnboardPresetAppliers = { applyProviderConfig: (cfg: OpenClawConfig, ...args: TArgs) => OpenClawConfig; applyConfig: (cfg: OpenClawConfig, ...args: TArgs) => OpenClawConfig; @@ -51,6 +58,20 @@ function normalizeAgentModelAliasEntry(entry: AgentModelAliasEntry): { return entry; } +function resolveCurrentPrimaryModel(model: unknown): string | undefined { + if (typeof model === "string") { + return model.trim() || undefined; + } + if ( + model && + typeof model === "object" && + typeof (model as { primary?: unknown }).primary === "string" + ) { + return ((model as { primary: string }).primary || "").trim() || undefined; + } + return undefined; +} + type ProviderModelMergeState = { providers: Record; existingProvider?: ModelProviderConfig; @@ -211,6 +232,24 @@ export function applyAgentDefaultModelPrimary( }; } +export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + const current = resolveCurrentPrimaryModel(cfg.agents?.defaults?.model); + const normalizedCurrent = + current && LEGACY_OPENCODE_ZEN_DEFAULT_MODELS.has(current) + ? OPENCODE_ZEN_DEFAULT_MODEL + : current; + if (normalizedCurrent === OPENCODE_ZEN_DEFAULT_MODEL) { + return { next: cfg, changed: false }; + } + return { + next: applyAgentDefaultModelPrimary(cfg, OPENCODE_ZEN_DEFAULT_MODEL), + changed: true, + }; +} + export function applyProviderConfigWithDefaultModels( cfg: OpenClawConfig, params: { diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 341a00ca1a7..efab2592431 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -25,6 +25,8 @@ const ALLOWED_CONTRACT_BUNDLED_PATH_HELPERS = new Set([ ]); const ALLOWED_CHANNEL_BUNDLED_METADATA_CONSUMERS = new Set([ + "src/channels/plugins/bundled.ts", + "src/channels/plugins/contracts/runtime-artifacts.ts", "src/channels/plugins/session-conversation.bundled-fallback.test.ts", ]); diff --git a/src/plugins/provider-model-defaults.ts b/src/plugins/provider-model-defaults.ts index 41a99b2827d..90e8f473437 100644 --- a/src/plugins/provider-model-defaults.ts +++ b/src/plugins/provider-model-defaults.ts @@ -1,4 +1,8 @@ import type { OpenClawConfig } from "../config/config.js"; +export { + applyOpencodeZenModelDefault, + OPENCODE_ZEN_DEFAULT_MODEL, +} from "../plugin-sdk/opencode.js"; import { ensureModelAllowlistEntry } from "./provider-model-allowlist.js"; import { applyAgentDefaultPrimaryModel } from "./provider-model-primary.js"; diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 5540367d8c7..3a6c3e0f1b8 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -37,6 +37,13 @@ const { buildVitestArgs, buildVitestRunPlans, createVitestRunSpecs, parseTestPro }; }; +const VITEST_NODE_PREFIX = [ + "exec", + "node", + "--no-maglev", + expect.stringContaining("/node_modules/vitest/vitest.mjs"), +]; + describe("test-projects args", () => { it("drops a pnpm passthrough separator while preserving targeted filters", () => { expect(parseTestProjectsArgs(["--", "src/foo.test.ts", "-t", "target"])).toEqual({ @@ -48,8 +55,7 @@ describe("test-projects args", () => { it("keeps watch mode explicit without leaking the sentinel to Vitest", () => { expect(buildVitestArgs(["--watch", "--", "src/foo.test.ts"])).toEqual([ - "exec", - "vitest", + ...VITEST_NODE_PREFIX, "--config", "vitest.unit.config.ts", "src/foo.test.ts", @@ -58,8 +64,7 @@ describe("test-projects args", () => { it("uses run mode by default", () => { expect(buildVitestArgs(["src/foo.test.ts"])).toEqual([ - "exec", - "vitest", + ...VITEST_NODE_PREFIX, "run", "--config", "vitest.unit.config.ts", @@ -704,8 +709,7 @@ describe("test-projects args", () => { ]); expect(spec?.pnpmArgs).toEqual([ - "exec", - "vitest", + ...VITEST_NODE_PREFIX, "run", "--config", "vitest.extension-channels.config.ts", diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 77418bd8728..694eb0b868e 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -10,6 +10,7 @@ const ALLOWED_EXTENSION_PUBLIC_SURFACE_BASENAMES = new Set( ); const allowedNonExtensionTests = new Set([ + "src/agents/pi-embedded-runner-extraparams-moonshot.test.ts", "src/agents/pi-embedded-runner-extraparams.test.ts", "src/channels/plugins/contracts/dm-policy.contract.test.ts", "src/channels/plugins/contracts/group-policy.contract.test.ts", @@ -20,6 +21,17 @@ const allowedNonExtensionTests = new Set([ "src/plugins/interactive.test.ts", "src/plugins/contracts/discovery.contract.test.ts", "src/plugin-sdk/telegram-command-config.test.ts", + "src/secrets/runtime-channel-inactive-variants.test.ts", + "src/secrets/runtime-discord-surface.test.ts", + "src/secrets/runtime-inactive-telegram-surfaces.test.ts", + "src/secrets/runtime-legacy-x-search.test.ts", + "src/secrets/runtime-matrix-shadowing.test.ts", + "src/secrets/runtime-matrix-top-level.test.ts", + "src/secrets/runtime-nextcloud-talk-file-precedence.test.ts", + "src/secrets/runtime-telegram-token-inheritance.test.ts", + "src/secrets/runtime-zalo-token-activity.test.ts", + "src/security/audit-channel-slack-command-findings.test.ts", + "src/security/audit-feishu-doc-risk.test.ts", ]); function walk(dir: string, entries: string[] = []): string[] { diff --git a/test/scripts/local-heavy-check-runtime.test.ts b/test/scripts/local-heavy-check-runtime.test.ts index 834a2ac107f..01e794fda82 100644 --- a/test/scripts/local-heavy-check-runtime.test.ts +++ b/test/scripts/local-heavy-check-runtime.test.ts @@ -77,13 +77,26 @@ describe("local-heavy-check-runtime", () => { it("serializes local oxlint runs onto one thread on constrained hosts", () => { const { args } = applyLocalOxlintPolicy([], makeEnv(), CONSTRAINED_HOST); - expect(args).toEqual(["--type-aware", "--tsconfig", "tsconfig.oxlint.json", "--threads=1"]); + expect(args).toEqual([ + "--type-aware", + "--tsconfig", + "tsconfig.oxlint.json", + "--report-unused-disable-directives-severity", + "error", + "--threads=1", + ]); }); it("keeps local oxlint parallel on roomy hosts in auto mode", () => { const { args } = applyLocalOxlintPolicy([], makeEnv(), ROOMY_HOST); - expect(args).toEqual(["--type-aware", "--tsconfig", "tsconfig.oxlint.json"]); + expect(args).toEqual([ + "--type-aware", + "--tsconfig", + "tsconfig.oxlint.json", + "--report-unused-disable-directives-severity", + "error", + ]); }); it("reclaims stale local heavy-check locks from dead pids", () => {