From 50c5f7590449dd01145f0d30e7ace48b41307576 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:13:21 -0600 Subject: [PATCH 001/661] Compaction: sanitize token split accounting (#24058) * Compaction: sanitize token split accounting * Tests/Compaction: type sanitize token estimate callback --- src/agents/compaction.token-sanitize.test.ts | 52 ++++++++++++++++++++ src/agents/compaction.ts | 8 ++- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 src/agents/compaction.token-sanitize.test.ts diff --git a/src/agents/compaction.token-sanitize.test.ts b/src/agents/compaction.token-sanitize.test.ts new file mode 100644 index 00000000000..f7fad927f61 --- /dev/null +++ b/src/agents/compaction.token-sanitize.test.ts @@ -0,0 +1,52 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { describe, expect, it, vi } from "vitest"; + +const piCodingAgentMocks = vi.hoisted(() => ({ + estimateTokens: vi.fn((_message: unknown) => 1), + generateSummary: vi.fn(async () => "summary"), +})); + +vi.mock("@mariozechner/pi-coding-agent", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-coding-agent", + ); + return { + ...actual, + estimateTokens: piCodingAgentMocks.estimateTokens, + generateSummary: piCodingAgentMocks.generateSummary, + }; +}); + +import { chunkMessagesByMaxTokens, splitMessagesByTokenShare } from "./compaction.js"; + +describe("compaction token accounting sanitization", () => { + it("does not pass toolResult.details into per-message token estimates", () => { + const messages: AgentMessage[] = [ + { + role: "toolResult", + toolCallId: "call_1", + toolName: "browser", + isError: false, + content: [{ type: "text", text: "ok" }], + details: { raw: "x".repeat(50_000) }, + timestamp: 1, + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + { + role: "user", + content: "next", + timestamp: 2, + }, + ]; + + splitMessagesByTokenShare(messages, 2); + chunkMessagesByMaxTokens(messages, 16); + + const calledWithDetails = piCodingAgentMocks.estimateTokens.mock.calls.some((call) => { + const message = call[0] as { details?: unknown } | undefined; + return Boolean(message?.details); + }); + + expect(calledWithDetails).toBe(false); + }); +}); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index ba9870afe46..da83723c3f6 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -23,6 +23,10 @@ export function estimateMessagesTokens(messages: AgentMessage[]): number { return safe.reduce((sum, message) => sum + estimateTokens(message), 0); } +function estimateCompactionMessageTokens(message: AgentMessage): number { + return estimateMessagesTokens([message]); +} + function normalizeParts(parts: number, messageCount: number): number { if (!Number.isFinite(parts) || parts <= 1) { return 1; @@ -49,7 +53,7 @@ export function splitMessagesByTokenShare( let currentTokens = 0; for (const message of messages) { - const messageTokens = estimateTokens(message); + const messageTokens = estimateCompactionMessageTokens(message); if ( chunks.length < normalizedParts - 1 && current.length > 0 && @@ -93,7 +97,7 @@ export function chunkMessagesByMaxTokens( let currentTokens = 0; for (const message of messages) { - const messageTokens = estimateTokens(message); + const messageTokens = estimateCompactionMessageTokens(message); if (currentChunk.length > 0 && currentTokens + messageTokens > effectiveMax) { chunks.push(currentChunk); currentChunk = []; From 5c9f9722afe5982147f5261983fb24c442f1e048 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:13:43 -0600 Subject: [PATCH 002/661] Agent runner: align compaction floor guidance (#24059) --- src/auto-reply/reply/agent-runner-execution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index eaabfe2f2d3..eb8605ccfe1 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -442,7 +442,7 @@ export async function runAgentTurnWithFallback(params: { return { kind: "final", payload: { - text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.", + text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.", }, }; } @@ -476,7 +476,7 @@ export async function runAgentTurnWithFallback(params: { return { kind: "final", payload: { - text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.", + text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.", }, }; } From 05691be5117048e33fdb72f96424654476d065db Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:13:59 -0600 Subject: [PATCH 003/661] Compaction: ignore tool result details in oversized checks (#24057) * Compaction: ignore tool result details in oversized checks * Tests/Compaction: type estimateTokens message callback --- .../compaction.tool-result-details.test.ts | 23 +++++++++++++++++-- src/agents/compaction.ts | 4 ++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/agents/compaction.tool-result-details.test.ts b/src/agents/compaction.tool-result-details.test.ts index 79c883a729f..f76fd951168 100644 --- a/src/agents/compaction.tool-result-details.test.ts +++ b/src/agents/compaction.tool-result-details.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const piCodingAgentMocks = vi.hoisted(() => ({ generateSummary: vi.fn(async () => "summary"), - estimateTokens: vi.fn(() => 1), + estimateTokens: vi.fn((_message: unknown) => 1), })); vi.mock("@mariozechner/pi-coding-agent", async () => { @@ -17,7 +17,7 @@ vi.mock("@mariozechner/pi-coding-agent", async () => { }; }); -import { summarizeWithFallback } from "./compaction.js"; +import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js"; describe("compaction toolResult details stripping", () => { beforeEach(() => { @@ -64,4 +64,23 @@ describe("compaction toolResult details stripping", () => { expect(serialized).not.toContain("Ignore previous instructions"); expect(serialized).not.toContain('"details"'); }); + + it("ignores toolResult.details when evaluating oversized messages", () => { + piCodingAgentMocks.estimateTokens.mockImplementation((message: unknown) => { + const record = message as { details?: unknown }; + return record.details ? 10_000 : 10; + }); + + const toolResult = { + role: "toolResult", + toolCallId: "call_1", + toolName: "browser", + isError: false, + content: [{ type: "text", text: "ok" }], + details: { raw: "x".repeat(100_000) }, + timestamp: 2, + } as unknown as AgentMessage; + + expect(isOversizedForSummary(toolResult, 1_000)).toBe(false); + }); }); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index da83723c3f6..25163471839 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -152,7 +152,7 @@ export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindo * If single message > 50% of context, it can't be summarized safely. */ export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean { - const tokens = estimateTokens(msg) * SAFETY_MARGIN; + const tokens = estimateCompactionMessageTokens(msg) * SAFETY_MARGIN; return tokens > contextWindow * 0.5; } @@ -240,7 +240,7 @@ export async function summarizeWithFallback(params: { for (const msg of messages) { if (isOversizedForSummary(msg, contextWindow)) { const role = (msg as { role?: string }).role ?? "message"; - const tokens = estimateTokens(msg); + const tokens = estimateCompactionMessageTokens(msg); oversizedNotes.push( `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`, ); From 457835b10444762b8d02739f7794da802b460242 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:16:45 -0600 Subject: [PATCH 004/661] Compaction: count only completed auto-compactions (#24056) * Compaction: count only completed auto-compactions * Compaction: count only non-retry completions * Changelog: note completed-only compaction counting * Agents/Compaction: guard optional compaction increment --- CHANGELOG.md | 1 + .../pi-embedded-subscribe.handlers.compaction.ts | 4 +++- ...le-compaction-retries-before-resolving.test.ts | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee24be3329..5832e21775f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index f28e47d1a9d..a9dda4110e0 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -5,7 +5,6 @@ import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handler export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) { ctx.state.compactionInFlight = true; - ctx.incrementCompactionCount(); ctx.ensureCompactionPromise(); ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`); emitAgentEvent({ @@ -40,6 +39,9 @@ export function handleAutoCompactionEnd( ) { ctx.state.compactionInFlight = false; const willRetry = Boolean(evt.willRetry); + if (!willRetry) { + ctx.incrementCompactionCount?.(); + } if (willRetry) { ctx.noteCompactionRetry(); ctx.resetForCompactionRetry(); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts index bab3d4e3dfe..334839730f6 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts @@ -30,6 +30,21 @@ describe("subscribeEmbeddedPiSession", () => { expect(resolved).toBe(true); }); + it("does not count compaction until end event", async () => { + const { emit, subscription } = createSubscribedSessionHarness({ + runId: "run-compaction-count", + }); + + emit({ type: "auto_compaction_start" }); + expect(subscription.getCompactionCount()).toBe(0); + + emit({ type: "auto_compaction_end", willRetry: true }); + expect(subscription.getCompactionCount()).toBe(0); + + emit({ type: "auto_compaction_end", willRetry: false }); + expect(subscription.getCompactionCount()).toBe(1); + }); + it("emits compaction events on the agent event bus", async () => { const { emit } = createSubscribedSessionHarness({ runId: "run-compaction", From a54dc7fe80fc02e2a02e6901668a468fcb0cf8b4 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:24:08 -0600 Subject: [PATCH 005/661] Cron: suppress fallback main summary for delivery-target errors (openclaw#24074) thanks @Takhoffman Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/cron/isolated-agent/run.ts | 25 ++++++++----------- ...runs-one-shot-main-job-disables-it.test.ts | 22 ++++++++++++++++ src/cron/service/timer.ts | 4 ++- src/cron/types.ts | 2 ++ 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5832e21775f..90f2f695be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves. - Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory. - Cron/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory. +- Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) - Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic. - Plugins/Install: strip `workspace:*` devDependency entries from copied plugin manifests before `npm install --omit=dev`, preventing `EUNSUPPORTEDPROTOCOL` install failures for npm-published channel plugins (including Feishu and MS Teams). - Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip `openclaw: workspace:*` from plugin `devDependencies` during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603) diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 5af6119a151..28e35f21e87 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -635,29 +635,26 @@ export async function runCronIsolatedAgentTurn(params: { // `true` means we confirmed at least one outbound send reached the target. // Keep this strict so timer fallback can safely decide whether to wake main. let delivered = skipMessagingToolDelivery; + const failDeliveryTarget = (error: string) => + withRunSession({ + status: "error", + error, + errorKind: "delivery-target", + summary, + outputText, + ...telemetry, + }); if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) { if (resolvedDelivery.error) { if (!deliveryBestEffort) { - return withRunSession({ - status: "error", - error: resolvedDelivery.error.message, - summary, - outputText, - ...telemetry, - }); + return failDeliveryTarget(resolvedDelivery.error.message); } logWarn(`[cron:${params.job.id}] ${resolvedDelivery.error.message}`); return withRunSession({ status: "ok", summary, outputText, ...telemetry }); } const failOrWarnMissingDeliveryField = (message: string) => { if (!deliveryBestEffort) { - return withRunSession({ - status: "error", - error: message, - summary, - outputText, - ...telemetry, - }); + return failDeliveryTarget(message); } logWarn(`[cron:${params.job.id}] ${message}`); return withRunSession({ status: "ok", summary, outputText, ...telemetry }); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index f1b23f6379c..027a464357d 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -680,6 +680,28 @@ describe("CronService", () => { await store.cleanup(); }); + it("does not post fallback main summary for isolated delivery-target errors", async () => { + const runIsolatedAgentJob = vi.fn(async () => ({ + status: "error" as const, + summary: "last output", + error: "Channel is required when multiple channels are configured: telegram, discord", + errorKind: "delivery-target" as const, + })); + const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = + await createIsolatedAnnounceHarness(runIsolatedAgentJob); + await runIsolatedAnnounceJobAndWait({ + cron, + events, + name: "isolated delivery target error test", + status: "error", + }); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + cron.stop(); + await store.cleanup(); + }); + it("rejects unsupported session/payload combinations", async () => { ensureDir(fixturesRoot); const store = await makeStorePath(); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 53d154cc439..34cdab97f5a 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -737,7 +737,9 @@ export async function executeJobCore( // See: https://github.com/openclaw/openclaw/issues/15692 const summaryText = res.summary?.trim(); const deliveryPlan = resolveCronDeliveryPlan(job); - if (summaryText && deliveryPlan.requested && !res.delivered) { + const suppressMainSummary = + res.status === "error" && res.errorKind === "delivery-target" && deliveryPlan.requested; + if (summaryText && deliveryPlan.requested && !res.delivered && !suppressMainSummary) { const prefix = "Cron"; const label = res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`; diff --git a/src/cron/types.ts b/src/cron/types.ts index ec1f8752c5b..837cba2168e 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -47,6 +47,8 @@ export type CronRunTelemetry = { export type CronRunOutcome = { status: CronRunStatus; error?: string; + /** Optional classifier for execution errors to guide fallback behavior. */ + errorKind?: "delivery-target"; summary?: string; sessionId?: string; sessionKey?: string; From 558a0137bb76d8c794fc44d2eb839b05591cd168 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 05:13:39 +0100 Subject: [PATCH 006/661] chore(release): bump versions to 2026.2.23 --- CHANGELOG.md | 2 +- apps/android/app/build.gradle.kts | 4 ++-- apps/ios/ShareExtension/Info.plist | 4 ++-- apps/ios/Sources/Info.plist | 4 ++-- apps/ios/Tests/Info.plist | 4 ++-- apps/ios/WatchApp/Info.plist | 4 ++-- apps/ios/WatchExtension/Info.plist | 4 ++-- apps/ios/project.yml | 20 +++++++++---------- .../Sources/OpenClaw/Resources/Info.plist | 4 ++-- docs/platforms/mac/release.md | 14 ++++++------- package.json | 2 +- 11 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f2f695be6..3bfadb1189a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Docs: https://docs.openclaw.ai ## Unreleased -## 2026.2.22 (Unreleased) +## 2026.2.23 (Unreleased) ### Changes diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b91b1e21537..52e1014e7ba 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602210 - versionName = "2026.2.21" + versionCode = 202602230 + versionName = "2026.2.23" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 0656afbf2d7..aedea62a5e1 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index c3b469e7092..c34fccb5052 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 20260220 + 20260223 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 7fc8d827044..a3420e27321 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index cc5dbf6cdda..4e309b031a6 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 WKCompanionAppBundleIdentifier $(OPENCLAW_APP_BUNDLE_ID) WKWatchKitApp diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index 2d6b7baa7b8..1b5f28dfc43 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,9 +15,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 613322f3e8e..1028876e510 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -92,8 +92,8 @@ targets: - CFBundleURLName: ai.openclaw.ios CFBundleURLSchemes: - openclaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -146,8 +146,8 @@ targets: path: ShareExtension/Info.plist properties: CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" @@ -176,8 +176,8 @@ targets: path: WatchApp/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" WKWatchKitApp: true @@ -200,8 +200,8 @@ targets: path: WatchExtension/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionAttributes: WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" @@ -228,5 +228,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index e7ca1ad5487..3a425368d09 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 202602210 + 202602230 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 7d3a8d0190b..029ab3eed93 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,17 +34,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +APP_VERSION=2026.2.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.21.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.23.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.21.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.21.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +APP_VERSION=2026.2.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.21.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.23.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.21.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.23.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.21.zip` (and `OpenClaw-2026.2.21.dSYM.zip`) to the GitHub release for tag `v2026.2.21`. +- Upload `OpenClaw-2026.2.23.zip` (and `OpenClaw-2026.2.23.dSYM.zip`) to the GitHub release for tag `v2026.2.23`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/package.json b/package.json index 69f10411241..be8ec9577e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.22-2", + "version": "2026.2.23", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 382fe8009aa2e4daf59aa27ad75ed0f9409c1d3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 05:20:14 +0100 Subject: [PATCH 007/661] refactor!: remove google-antigravity provider support --- CHANGELOG.md | 1 + extensions/google-antigravity-auth/README.md | 24 - extensions/google-antigravity-auth/index.ts | 424 ---------------- .../openclaw.plugin.json | 9 - .../google-antigravity-auth/package.json | 15 - src/agents/auth-profiles/oauth.ts | 2 +- src/agents/live-model-filter.ts | 4 - ...orward-compat.antigravity-gemini31.test.ts | 72 --- src/agents/model-forward-compat.ts | 137 +---- src/agents/pi-embedded-helpers/google.ts | 17 +- ...ed-runner.google-sanitize-thinking.test.ts | 309 ------------ src/agents/pi-embedded-runner/google.test.ts | 20 - src/agents/pi-embedded-runner/google.ts | 88 +--- src/agents/pi-embedded-runner/model.test.ts | 56 --- src/agents/pi-embedded-runner/run/attempt.ts | 6 +- src/agents/pi-tools.schema.ts | 6 +- src/agents/transcript-policy.ts | 13 +- .../agent-runner.misc.runreplyagent.test.ts | 8 +- src/commands/auth-choice-options.ts | 7 +- .../auth-choice.apply.google-antigravity.ts | 14 - src/commands/auth-choice.apply.ts | 2 - .../auth-choice.preferred-provider.ts | 1 - .../models.auth.provider-resolution.test.ts | 14 +- src/commands/models.list.test.ts | 255 ---------- src/commands/models/list.registry.ts | 71 +-- src/commands/onboard-types.ts | 1 - src/config/plugin-auto-enable.test.ts | 6 +- src/config/plugin-auto-enable.ts | 1 - ...rovider-usage.auth.normalizes-keys.test.ts | 8 +- src/infra/provider-usage.auth.ts | 3 +- .../provider-usage.fetch.antigravity.test.ts | 469 ------------------ src/infra/provider-usage.fetch.antigravity.ts | 305 ------------ src/infra/provider-usage.fetch.ts | 1 - src/infra/provider-usage.load.ts | 3 - src/infra/provider-usage.shared.test.ts | 2 +- src/infra/provider-usage.shared.ts | 2 - src/infra/provider-usage.test.ts | 16 +- src/infra/provider-usage.types.ts | 1 - src/plugins/enable.test.ts | 12 +- src/utils/provider-utils.ts | 5 - src/utils/utils-misc.test.ts | 6 - 41 files changed, 43 insertions(+), 2373 deletions(-) delete mode 100644 extensions/google-antigravity-auth/README.md delete mode 100644 extensions/google-antigravity-auth/index.ts delete mode 100644 extensions/google-antigravity-auth/openclaw.plugin.json delete mode 100644 extensions/google-antigravity-auth/package.json delete mode 100644 src/agents/model-forward-compat.antigravity-gemini31.test.ts delete mode 100644 src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts delete mode 100644 src/commands/auth-choice.apply.google-antigravity.ts delete mode 100644 src/infra/provider-usage.fetch.antigravity.test.ts delete mode 100644 src/infra/provider-usage.fetch.antigravity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bfadb1189a..d07e9868b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai ### Breaking +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. - **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. - **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. - **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. diff --git a/extensions/google-antigravity-auth/README.md b/extensions/google-antigravity-auth/README.md deleted file mode 100644 index 4e1dee975ea..00000000000 --- a/extensions/google-antigravity-auth/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Google Antigravity Auth (OpenClaw plugin) - -OAuth provider plugin for **Google Antigravity** (Cloud Code Assist). - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable google-antigravity-auth -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider google-antigravity --set-default -``` - -## Notes - -- Antigravity uses Google Cloud project quotas. -- If requests fail, ensure Gemini for Google Cloud is enabled. diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts deleted file mode 100644 index 055cb15e00b..00000000000 --- a/extensions/google-antigravity-auth/index.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { createServer } from "node:http"; -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - isWSL2Sync, - type OpenClawPluginApi, - type ProviderAuthContext, -} from "openclaw/plugin-sdk"; - -// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync -const decode = (s: string) => Buffer.from(s, "base64").toString(); -const CLIENT_ID = decode( - "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", -); -const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); -const REDIRECT_URI = "http://localhost:51121/oauth-callback"; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; -const DEFAULT_MODEL = "google-antigravity/claude-opus-4-6-thinking"; - -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", -]; - -const CODE_ASSIST_ENDPOINTS = [ - "https://cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", -]; - -const RESPONSE_PAGE = ` - - - - OpenClaw Antigravity OAuth - - -
-

Authentication complete

-

You can return to the terminal.

-
- -`; - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -function buildAuthUrl(params: { challenge: string; state: string }): string { - const url = new URL(AUTH_URL); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("response_type", "code"); - url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("scope", SCOPES.join(" ")); - url.searchParams.set("code_challenge", params.challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", params.state); - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - return url.toString(); -} - -function parseCallbackInput(input: string): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter in URL" }; - } - return { code, state }; - } catch { - return { error: "Paste the full redirect URL (not just the code)." }; - } -} - -async function startCallbackServer(params: { timeoutMs: number }) { - const redirect = new URL(REDIRECT_URI); - const port = redirect.port ? Number(redirect.port) : 51121; - - let settled = false; - let resolveCallback: (url: URL) => void; - let rejectCallback: (err: Error) => void; - - const callbackPromise = new Promise((resolve, reject) => { - resolveCallback = (url) => { - if (settled) { - return; - } - settled = true; - resolve(url); - }; - rejectCallback = (err) => { - if (settled) { - return; - } - settled = true; - reject(err); - }; - }); - - const timeout = setTimeout(() => { - rejectCallback(new Error("Timed out waiting for OAuth callback")); - }, params.timeoutMs); - timeout.unref?.(); - - const server = createServer((request, response) => { - if (!request.url) { - response.writeHead(400, { "Content-Type": "text/plain" }); - response.end("Missing URL"); - return; - } - - const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`); - if (url.pathname !== redirect.pathname) { - response.writeHead(404, { "Content-Type": "text/plain" }); - response.end("Not found"); - return; - } - - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - response.end(RESPONSE_PAGE); - resolveCallback(url); - - setImmediate(() => { - server.close(); - }); - }); - - await new Promise((resolve, reject) => { - const onError = (err: Error) => { - server.off("error", onError); - reject(err); - }; - server.once("error", onError); - server.listen(port, "127.0.0.1", () => { - server.off("error", onError); - resolve(); - }); - }); - - return { - waitForCallback: () => callbackPromise, - close: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - -async function exchangeCode(params: { - code: string; - verifier: string; -}): Promise<{ access: string; refresh: string; expires: number }> { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: params.code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: params.verifier, - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Token exchange failed: ${text}`); - } - - const data = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - const access = data.access_token?.trim(); - const refresh = data.refresh_token?.trim(); - const expiresIn = data.expires_in ?? 0; - - if (!access) { - throw new Error("Token exchange returned no access_token"); - } - if (!refresh) { - throw new Error("Token exchange returned no refresh_token"); - } - - const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; - return { access, refresh, expires }; -} - -async function fetchUserEmail(accessToken: string): Promise { - try { - const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - return undefined; - } - const data = (await response.json()) as { email?: string }; - return data.email; - } catch { - return undefined; - } -} - -async function fetchProjectId(accessToken: string): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), - }; - - for (const endpoint of CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }), - }); - - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - cloudaicompanionProject?: string | { id?: string }; - }; - - if (typeof data.cloudaicompanionProject === "string") { - return data.cloudaicompanionProject; - } - if ( - data.cloudaicompanionProject && - typeof data.cloudaicompanionProject === "object" && - data.cloudaicompanionProject.id - ) { - return data.cloudaicompanionProject.id; - } - } catch { - // ignore - } - } - - return DEFAULT_PROJECT_ID; -} - -async function loginAntigravity(params: { - isRemote: boolean; - openUrl: (url: string) => Promise; - prompt: (message: string) => Promise; - note: (message: string, title?: string) => Promise; - log: (message: string) => void; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}): Promise<{ - access: string; - refresh: string; - expires: number; - email?: string; - projectId: string; -}> { - const { verifier, challenge } = generatePkce(); - const state = randomBytes(16).toString("hex"); - const authUrl = buildAuthUrl({ challenge, state }); - - let callbackServer: Awaited> | null = null; - const needsManual = shouldUseManualOAuthFlow(params.isRemote); - if (!needsManual) { - try { - callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 }); - } catch { - callbackServer = null; - } - } - - if (!callbackServer) { - await params.note( - [ - "Open the URL in your local browser.", - "After signing in, copy the full redirect URL and paste it back here.", - "", - `Auth URL: ${authUrl}`, - `Redirect URI: ${REDIRECT_URI}`, - ].join("\n"), - "Google Antigravity OAuth", - ); - // Output raw URL below the box for easy copying (fixes #1772) - params.log(""); - params.log("Copy this URL:"); - params.log(authUrl); - params.log(""); - } - - if (!needsManual) { - params.progress.update("Opening Google sign-in…"); - try { - await params.openUrl(authUrl); - } catch { - // ignore - } - } - - let code = ""; - let returnedState = ""; - - if (callbackServer) { - params.progress.update("Waiting for OAuth callback…"); - const callback = await callbackServer.waitForCallback(); - code = callback.searchParams.get("code") ?? ""; - returnedState = callback.searchParams.get("state") ?? ""; - await callbackServer.close(); - } else { - params.progress.update("Waiting for redirect URL…"); - const input = await params.prompt("Paste the redirect URL: "); - const parsed = parseCallbackInput(input); - if ("error" in parsed) { - throw new Error(parsed.error); - } - code = parsed.code; - returnedState = parsed.state; - } - - if (!code) { - throw new Error("Missing OAuth code"); - } - if (returnedState !== state) { - throw new Error("OAuth state mismatch. Please try again."); - } - - params.progress.update("Exchanging code for tokens…"); - const tokens = await exchangeCode({ code, verifier }); - const email = await fetchUserEmail(tokens.access); - const projectId = await fetchProjectId(tokens.access); - - params.progress.stop("Antigravity OAuth complete"); - return { ...tokens, email, projectId }; -} - -const antigravityPlugin = { - id: "google-antigravity-auth", - name: "Google Antigravity Auth", - description: "OAuth flow for Google Antigravity (Cloud Code Assist)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: "google-antigravity", - label: "Google Antigravity", - docsPath: "/providers/models", - aliases: ["antigravity"], - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting Antigravity OAuth…"); - try { - const result = await loginAntigravity({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - prompt: async (message) => String(await ctx.prompter.text({ message })), - note: ctx.prompter.note, - log: (message) => ctx.runtime.log(message), - progress: spin, - }); - - return buildOauthProviderAuthResult({ - providerId: "google-antigravity", - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - credentialExtra: { projectId: result.projectId }, - notes: [ - "Antigravity uses Google Cloud project quotas.", - "Enable Gemini for Google Cloud on your project if requests fail.", - ], - }); - } catch (err) { - spin.stop("Antigravity OAuth failed"); - throw err; - } - }, - }, - ], - }); - }, -}; - -export default antigravityPlugin; diff --git a/extensions/google-antigravity-auth/openclaw.plugin.json b/extensions/google-antigravity-auth/openclaw.plugin.json deleted file mode 100644 index 2ef207f0486..00000000000 --- a/extensions/google-antigravity-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "google-antigravity-auth", - "providers": ["google-antigravity"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json deleted file mode 100644 index e730f4dcbe4..00000000000 --- a/extensions/google-antigravity-auth/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@openclaw/google-antigravity-auth", - "version": "2026.2.22", - "private": true, - "description": "OpenClaw Google Antigravity OAuth provider plugin", - "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 37ca04745c3..a4f10b6a587 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -55,7 +55,7 @@ function isProfileConfigCompatible(params: { } function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string { - const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity"; + const needsProjectId = provider === "google-gemini-cli"; return needsProjectId ? JSON.stringify({ token: credentials.access, diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index c4ad0957d81..26ee0adfa00 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -56,10 +56,6 @@ export function isModernModelRef(ref: ModelRef): boolean { return matchesPrefix(id, GOOGLE_PREFIXES); } - if (provider === "google-antigravity") { - return matchesPrefix(id, GOOGLE_PREFIXES) || matchesPrefix(id, ANTHROPIC_PREFIXES); - } - if (provider === "zai") { return matchesPrefix(id, ZAI_PREFIXES); } diff --git a/src/agents/model-forward-compat.antigravity-gemini31.test.ts b/src/agents/model-forward-compat.antigravity-gemini31.test.ts deleted file mode 100644 index 256d20cbf34..00000000000 --- a/src/agents/model-forward-compat.antigravity-gemini31.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; -import { describe, expect, it } from "vitest"; -import { resolveForwardCompatModel } from "./model-forward-compat.js"; -import type { ModelRegistry } from "./pi-model-discovery.js"; - -function makeRegistry(): ModelRegistry { - const templates = new Map>(); - templates.set("google-antigravity/gemini-3-pro-high", { - id: "gemini-3-pro-high", - name: "Gemini 3 Pro High", - provider: "google-antigravity", - api: "google-antigravity", - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - } as Model); - templates.set("google-antigravity/gemini-3-pro-low", { - id: "gemini-3-pro-low", - name: "Gemini 3 Pro Low", - provider: "google-antigravity", - api: "google-antigravity", - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - reasoning: true, - } as Model); - - const registry = { - find: (provider: string, modelId: string) => templates.get(`${provider}/${modelId}`) ?? null, - } as unknown as ModelRegistry; - return registry; -} - -describe("resolveForwardCompatModel (google-antigravity Gemini 3.1)", () => { - it("resolves gemini-3-1-pro-high from gemini-3-pro-high template", () => { - const model = resolveForwardCompatModel( - "google-antigravity", - "gemini-3-1-pro-high", - makeRegistry(), - ); - expect(model?.provider).toBe("google-antigravity"); - expect(model?.id).toBe("gemini-3-1-pro-high"); - }); - - it("resolves gemini-3-1-pro-low from gemini-3-pro-low template", () => { - const model = resolveForwardCompatModel( - "google-antigravity", - "gemini-3-1-pro-low", - makeRegistry(), - ); - expect(model?.provider).toBe("google-antigravity"); - expect(model?.id).toBe("gemini-3-1-pro-low"); - }); - - it("supports dot-notation model ids", () => { - const high = resolveForwardCompatModel( - "google-antigravity", - "gemini-3.1-pro-high", - makeRegistry(), - ); - const low = resolveForwardCompatModel( - "google-antigravity", - "gemini-3.1-pro-low", - makeRegistry(), - ); - expect(high?.id).toBe("gemini-3.1-pro-high"); - expect(low?.id).toBe("gemini-3.1-pro-low"); - }); -}); diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts index 93e6a57b855..a160302f7eb 100644 --- a/src/agents/model-forward-compat.ts +++ b/src/agents/model-forward-compat.ts @@ -17,51 +17,6 @@ const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet const ZAI_GLM5_MODEL_ID = "glm-5"; const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const; -const ANTIGRAVITY_OPUS_46_MODEL_ID = "claude-opus-4-6"; -const ANTIGRAVITY_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; -const ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; -const ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID = "claude-opus-4-6-thinking"; -const ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID = "claude-opus-4.6-thinking"; -const ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS = [ - "claude-opus-4-5-thinking", - "claude-opus-4.5-thinking", -] as const; -const ANTIGRAVITY_GEMINI_31_PRO_HIGH_MODEL_ID = "gemini-3-1-pro-high"; -const ANTIGRAVITY_GEMINI_31_PRO_DOT_HIGH_MODEL_ID = "gemini-3.1-pro-high"; -const ANTIGRAVITY_GEMINI_31_PRO_LOW_MODEL_ID = "gemini-3-1-pro-low"; -const ANTIGRAVITY_GEMINI_31_PRO_DOT_LOW_MODEL_ID = "gemini-3.1-pro-low"; -const ANTIGRAVITY_GEMINI_31_PRO_HIGH_TEMPLATE_MODEL_IDS = ["gemini-3-pro-high"] as const; -const ANTIGRAVITY_GEMINI_31_PRO_LOW_TEMPLATE_MODEL_IDS = ["gemini-3-pro-low"] as const; - -export const ANTIGRAVITY_OPUS_46_FORWARD_COMPAT_CANDIDATES = [ - { - id: ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID, - templatePrefixes: [ - "google-antigravity/claude-opus-4-5-thinking", - "google-antigravity/claude-opus-4.5-thinking", - ], - availabilityAliasIds: [] as const, - }, - { - id: ANTIGRAVITY_OPUS_46_MODEL_ID, - templatePrefixes: ["google-antigravity/claude-opus-4-5", "google-antigravity/claude-opus-4.5"], - availabilityAliasIds: [] as const, - }, -] as const; - -export const ANTIGRAVITY_GEMINI_31_FORWARD_COMPAT_CANDIDATES = [ - { - id: ANTIGRAVITY_GEMINI_31_PRO_HIGH_MODEL_ID, - templatePrefixes: ["google-antigravity/gemini-3-pro-high"], - availabilityAliasIds: [ANTIGRAVITY_GEMINI_31_PRO_DOT_HIGH_MODEL_ID], - }, - { - id: ANTIGRAVITY_GEMINI_31_PRO_LOW_MODEL_ID, - templatePrefixes: ["google-antigravity/gemini-3-pro-low"], - availabilityAliasIds: [ANTIGRAVITY_GEMINI_31_PRO_DOT_LOW_MODEL_ID], - }, -] as const; - function cloneFirstTemplateModel(params: { normalizedProvider: string; trimmedModelId: string; @@ -245,94 +200,6 @@ function resolveZaiGlm5ForwardCompatModel( } as Model); } -function resolveAntigravityOpus46ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "google-antigravity") { - return undefined; - } - - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - const isOpus46 = - lower === ANTIGRAVITY_OPUS_46_MODEL_ID || - lower === ANTIGRAVITY_OPUS_46_DOT_MODEL_ID || - lower.startsWith(`${ANTIGRAVITY_OPUS_46_MODEL_ID}-`) || - lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_MODEL_ID}-`); - const isOpus46Thinking = - lower === ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID || - lower === ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID || - lower.startsWith(`${ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID}-`) || - lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID}-`); - if (!isOpus46 && !isOpus46Thinking) { - return undefined; - } - - const templateIds: string[] = []; - if (lower.startsWith(ANTIGRAVITY_OPUS_46_MODEL_ID)) { - templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_MODEL_ID, "claude-opus-4-5")); - } - if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID)) { - templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5")); - } - if (lower.startsWith(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID)) { - templateIds.push( - lower.replace(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID, "claude-opus-4-5-thinking"), - ); - } - if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID)) { - templateIds.push( - lower.replace(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID, "claude-opus-4.5-thinking"), - ); - } - templateIds.push(...ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS); - templateIds.push(...ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS); - - return cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId, - templateIds, - modelRegistry, - }); -} - -function resolveAntigravityGemini31ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "google-antigravity") { - return undefined; - } - - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - const isGemini31High = - lower === ANTIGRAVITY_GEMINI_31_PRO_HIGH_MODEL_ID || - lower === ANTIGRAVITY_GEMINI_31_PRO_DOT_HIGH_MODEL_ID; - const isGemini31Low = - lower === ANTIGRAVITY_GEMINI_31_PRO_LOW_MODEL_ID || - lower === ANTIGRAVITY_GEMINI_31_PRO_DOT_LOW_MODEL_ID; - if (!isGemini31High && !isGemini31Low) { - return undefined; - } - - const templateIds = isGemini31High - ? [...ANTIGRAVITY_GEMINI_31_PRO_HIGH_TEMPLATE_MODEL_IDS] - : [...ANTIGRAVITY_GEMINI_31_PRO_LOW_TEMPLATE_MODEL_IDS]; - - return cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId, - templateIds, - modelRegistry, - }); -} - export function resolveForwardCompatModel( provider: string, modelId: string, @@ -342,8 +209,6 @@ export function resolveForwardCompatModel( resolveOpenAICodexGpt53FallbackModel(provider, modelId, modelRegistry) ?? resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveAntigravityOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveAntigravityGemini31ForwardCompatModel(provider, modelId, modelRegistry) + resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ); } diff --git a/src/agents/pi-embedded-helpers/google.ts b/src/agents/pi-embedded-helpers/google.ts index f62095f0bc3..46367b98a5a 100644 --- a/src/agents/pi-embedded-helpers/google.ts +++ b/src/agents/pi-embedded-helpers/google.ts @@ -1,22 +1,7 @@ import { sanitizeGoogleTurnOrdering } from "./bootstrap.js"; export function isGoogleModelApi(api?: string | null): boolean { - return ( - api === "google-gemini-cli" || api === "google-generative-ai" || api === "google-antigravity" - ); -} - -export function isAntigravityClaude(params: { - api?: string | null; - provider?: string | null; - modelId?: string; -}): boolean { - const provider = params.provider?.toLowerCase(); - const api = params.api?.toLowerCase(); - if (provider !== "google-antigravity" && api !== "google-antigravity") { - return false; - } - return params.modelId?.toLowerCase().includes("claude") ?? false; + return api === "google-gemini-cli" || api === "google-generative-ai"; } export { sanitizeGoogleTurnOrdering }; diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts deleted file mode 100644 index 4e08b49cbd0..00000000000 --- a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { SessionManager } from "@mariozechner/pi-coding-agent"; -import { describe, expect, it } from "vitest"; -import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js"; - -type AssistantContentBlock = { - type?: string; - text?: string; - thinking?: string; - thinkingSignature?: string; - thought_signature?: string; - thoughtSignature?: string; - id?: string; - name?: string; - arguments?: unknown; -}; - -function getAssistantMessage(out: AgentMessage[]) { - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as - | { content?: AssistantContentBlock[] } - | undefined; - if (!assistant) { - throw new Error("Expected assistant message in sanitized history"); - } - return assistant; -} - -async function sanitizeGoogleAssistantWithContent(content: unknown[]) { - const sessionManager = SessionManager.inMemory(); - const input = [ - { - role: "user", - content: "hi", - }, - { - role: "assistant", - content, - }, - ] as unknown as AgentMessage[]; - - const out = await sanitizeSessionHistory({ - messages: input, - modelApi: "google-antigravity", - sessionManager, - sessionId: "session:google", - }); - - return getAssistantMessage(out); -} - -async function sanitizeSimpleSession(params: { - modelApi: string; - sessionId: string; - content: unknown[]; - modelId?: string; - provider?: string; -}) { - const sessionManager = SessionManager.inMemory(); - const input = [ - { - role: "user", - content: "hi", - }, - { - role: "assistant", - content: params.content, - }, - ] as unknown as AgentMessage[]; - - return sanitizeSessionHistory({ - messages: input, - modelApi: params.modelApi, - provider: params.provider, - modelId: params.modelId, - sessionManager, - sessionId: params.sessionId, - }); -} - -function geminiThoughtSignatureInput() { - return [ - { type: "text", text: "hello", thought_signature: "msg_abc123" }, - { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, - { - type: "toolCall", - id: "call_1", - name: "read", - arguments: { path: "/tmp/foo" }, - thoughtSignature: '{"id":1}', - }, - { - type: "toolCall", - id: "call_2", - name: "read", - arguments: { path: "/tmp/bar" }, - thoughtSignature: "c2ln", - }, - ]; -} - -describe("sanitizeSessionHistory (google thinking)", () => { - it("keeps thinking blocks without signatures for Google models", async () => { - const assistant = await sanitizeGoogleAssistantWithContent([ - { type: "thinking", thinking: "reasoning" }, - ]); - expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); - expect(assistant.content?.[0]?.thinking).toBe("reasoning"); - }); - - it("keeps thinking blocks with signatures for Google models", async () => { - const assistant = await sanitizeGoogleAssistantWithContent([ - { type: "thinking", thinking: "reasoning", thinkingSignature: "sig" }, - ]); - expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); - expect(assistant.content?.[0]?.thinking).toBe("reasoning"); - expect(assistant.content?.[0]?.thinkingSignature).toBe("sig"); - }); - - it("keeps thinking blocks with Anthropic-style signatures for Google models", async () => { - const assistant = await sanitizeGoogleAssistantWithContent([ - { type: "thinking", thinking: "reasoning", signature: "sig" }, - ]); - expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); - expect(assistant.content?.[0]?.thinking).toBe("reasoning"); - }); - - it("converts unsigned thinking blocks to text for Antigravity Claude", async () => { - const out = await sanitizeSimpleSession({ - modelApi: "google-antigravity", - modelId: "anthropic/claude-3.5-sonnet", - sessionId: "session:antigravity-claude", - content: [{ type: "thinking", thinking: "reasoning" }], - }); - - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; text?: string }>; - }; - expect(assistant.content).toEqual([{ type: "text", text: "reasoning" }]); - }); - - it("maps base64 signatures to thinkingSignature for Antigravity Claude", async () => { - const out = await sanitizeSimpleSession({ - modelApi: "google-antigravity", - modelId: "anthropic/claude-3.5-sonnet", - sessionId: "session:antigravity-claude", - content: [{ type: "thinking", thinking: "reasoning", signature: "c2ln" }], - }); - - const assistant = getAssistantMessage(out); - expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); - expect(assistant.content?.[0]?.thinking).toBe("reasoning"); - expect(assistant.content?.[0]?.thinkingSignature).toBe("c2ln"); - }); - - it("preserves order for mixed assistant content", async () => { - const sessionManager = SessionManager.inMemory(); - const input = [ - { - role: "user", - content: "hi", - }, - { - role: "assistant", - content: [ - { type: "text", text: "hello" }, - { type: "thinking", thinking: "internal note" }, - { type: "text", text: "world" }, - ], - }, - ] as unknown as AgentMessage[]; - - const out = await sanitizeSessionHistory({ - messages: input, - modelApi: "google-antigravity", - sessionManager, - sessionId: "session:google-mixed", - }); - - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string; text?: string; thinking?: string }>; - }; - expect(assistant.content?.map((block) => block.type)).toEqual(["text", "thinking", "text"]); - expect(assistant.content?.[1]?.thinking).toBe("internal note"); - }); - - it("strips non-base64 thought signatures for OpenRouter Gemini", async () => { - const out = await sanitizeSimpleSession({ - modelApi: "openrouter", - provider: "openrouter", - modelId: "google/gemini-1.5-pro", - sessionId: "session:openrouter-gemini", - content: geminiThoughtSignatureInput(), - }); - - const assistant = getAssistantMessage(out); - expect(assistant.content).toEqual([ - { type: "text", text: "hello" }, - { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, - { - type: "toolCall", - id: "call_1", - name: "read", - arguments: { path: "/tmp/foo" }, - }, - { - type: "toolCall", - id: "call_2", - name: "read", - arguments: { path: "/tmp/bar" }, - thoughtSignature: "c2ln", - }, - ]); - }); - - it("strips non-base64 thought signatures for native Google Gemini", async () => { - const out = await sanitizeSimpleSession({ - modelApi: "google-generative-ai", - provider: "google", - modelId: "gemini-2.0-flash", - sessionId: "session:google-gemini", - content: geminiThoughtSignatureInput(), - }); - - const assistant = getAssistantMessage(out); - expect(assistant.content).toEqual([ - { type: "text", text: "hello" }, - { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, - { - type: "toolCall", - id: "call1", - name: "read", - arguments: { path: "/tmp/foo" }, - }, - { - type: "toolCall", - id: "call2", - name: "read", - arguments: { path: "/tmp/bar" }, - thoughtSignature: "c2ln", - }, - ]); - }); - - it("keeps mixed signed/unsigned thinking blocks for Google models", async () => { - const assistant = await sanitizeGoogleAssistantWithContent([ - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "thinking", thinking: "unsigned" }, - ]); - expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "thinking"]); - expect(assistant.content?.[0]?.thinking).toBe("signed"); - expect(assistant.content?.[1]?.thinking).toBe("unsigned"); - }); - - it("keeps empty thinking blocks for Google models", async () => { - const assistant = await sanitizeGoogleAssistantWithContent([ - { type: "thinking", thinking: " " }, - ]); - expect(assistant?.content?.map((block) => block.type)).toEqual(["thinking"]); - }); - - it("keeps thinking blocks for non-Google models", async () => { - const out = await sanitizeSimpleSession({ - modelApi: "openai", - sessionId: "session:openai", - content: [{ type: "thinking", thinking: "reasoning" }], - }); - - const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { - content?: Array<{ type?: string }>; - }; - expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); - }); - - it("sanitizes tool call ids for Google APIs", async () => { - const sessionManager = SessionManager.inMemory(); - const longId = `call_${"a".repeat(60)}`; - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: longId, name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: longId, - toolName: "read", - content: [{ type: "text", text: "ok" }], - }, - ] as unknown as AgentMessage[]; - - const out = await sanitizeSessionHistory({ - messages: input, - modelApi: "google-antigravity", - sessionManager, - sessionId: "session:google", - }); - - const assistant = out.find( - (msg) => (msg as { role?: unknown }).role === "assistant", - ) as Extract; - const toolCall = assistant.content?.[0] as { id?: string }; - expect(toolCall.id).toBeDefined(); - expect(toolCall.id?.length).toBeLessThanOrEqual(40); - - const toolResult = out.find( - (msg) => (msg as { role?: unknown }).role === "toolResult", - ) as Extract; - expect(toolResult.toolCallId).toBe(toolCall.id); - }); -}); diff --git a/src/agents/pi-embedded-runner/google.test.ts b/src/agents/pi-embedded-runner/google.test.ts index 76e067a3764..d0a04665c68 100644 --- a/src/agents/pi-embedded-runner/google.test.ts +++ b/src/agents/pi-embedded-runner/google.test.ts @@ -42,26 +42,6 @@ describe("sanitizeToolsForGoogle", () => { expectFormatRemoved(sanitized, "additionalProperties"); }); - it("strips unsupported schema keywords for google-antigravity", () => { - const tool = createTool({ - type: "object", - patternProperties: { - "^x-": { type: "string" }, - }, - properties: { - foo: { - type: "string", - format: "uuid", - }, - }, - }); - const [sanitized] = sanitizeToolsForGoogle({ - tools: [tool], - provider: "google-antigravity", - }); - expectFormatRemoved(sanitized, "patternProperties"); - }); - it("returns original tools for non-google providers", () => { const tool = createTool({ type: "object", diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index ce702d63b51..42970ea4ef6 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -25,7 +25,7 @@ import { import type { TranscriptPolicy } from "../transcript-policy.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js"; import { log } from "./logger.js"; -import { dropThinkingBlocks, isAssistantMessageWithContent } from "./thinking.js"; +import { dropThinkingBlocks } from "./thinking.js"; import { describeUnknownError } from "./utils.js"; const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap"; @@ -52,85 +52,8 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ "maxProperties", ]); -const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; const INTER_SESSION_PREFIX_BASE = "[Inter-session message]"; -function isValidAntigravitySignature(value: unknown): value is string { - if (typeof value !== "string") { - return false; - } - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - if (trimmed.length % 4 !== 0) { - return false; - } - return ANTIGRAVITY_SIGNATURE_RE.test(trimmed); -} - -export function sanitizeAntigravityThinkingBlocks(messages: AgentMessage[]): AgentMessage[] { - let touched = false; - const out: AgentMessage[] = []; - for (const msg of messages) { - if (!isAssistantMessageWithContent(msg)) { - out.push(msg); - continue; - } - const assistant = msg; - type AssistantContentBlock = Extract["content"][number]; - const nextContent: AssistantContentBlock[] = []; - let contentChanged = false; - for (const block of assistant.content) { - if ( - !block || - typeof block !== "object" || - (block as { type?: unknown }).type !== "thinking" - ) { - nextContent.push(block); - continue; - } - const rec = block as { - thinkingSignature?: unknown; - signature?: unknown; - thought_signature?: unknown; - thoughtSignature?: unknown; - }; - const candidate = - rec.thinkingSignature ?? rec.signature ?? rec.thought_signature ?? rec.thoughtSignature; - if (!isValidAntigravitySignature(candidate)) { - // Preserve reasoning content as plain text when signatures are invalid/missing. - // Antigravity Claude rejects unsigned thinking blocks, but dropping them loses context. - const thinkingText = (block as { thinking?: unknown }).thinking; - if (typeof thinkingText === "string" && thinkingText.trim()) { - nextContent.push({ type: "text", text: thinkingText } as AssistantContentBlock); - } - contentChanged = true; - continue; - } - if (rec.thinkingSignature !== candidate) { - const nextBlock = { - ...(block as unknown as Record), - thinkingSignature: candidate, - } as AssistantContentBlock; - nextContent.push(nextBlock); - contentChanged = true; - } else { - nextContent.push(block); - } - } - if (contentChanged) { - touched = true; - } - if (nextContent.length === 0) { - touched = true; - continue; - } - out.push(contentChanged ? { ...assistant, content: nextContent } : msg); - } - return touched ? out : messages; -} - function buildInterSessionPrefix(message: AgentMessage): string { const provenance = normalizeInputProvenance((message as { provenance?: unknown }).provenance); if (!provenance) { @@ -284,7 +207,7 @@ export function sanitizeToolsForGoogle< // AND Claude models. This field does not support JSON Schema keywords such as // patternProperties, additionalProperties, $ref, etc. We must clean schemas // for every provider that routes through this path. - if (params.provider !== "google-gemini-cli" && params.provider !== "google-antigravity") { + if (params.provider !== "google-gemini-cli") { return params.tools; } return params.tools.map((tool) => { @@ -301,7 +224,7 @@ export function sanitizeToolsForGoogle< } export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider: string }) { - if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") { + if (params.provider !== "google-gemini-cli") { return; } const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`); @@ -481,10 +404,7 @@ export async function sanitizeSessionHistory(params: { const droppedThinking = policy.dropThinkingBlocks ? dropThinkingBlocks(sanitizedImages) : sanitizedImages; - const sanitizedThinking = policy.sanitizeThinkingSignatures - ? sanitizeAntigravityThinkingBlocks(droppedThinking) - : droppedThinking; - const sanitizedToolCalls = sanitizeToolCallInputs(sanitizedThinking, { + const sanitizedToolCalls = sanitizeToolCallInputs(droppedThinking, { allowedToolNames: params.allowedToolNames, }); const repairedTools = policy.repairToolUseResultPairing diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 31b3d6511b0..f0fb134263d 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -232,62 +232,6 @@ describe("resolveModel", () => { }); }); - it("builds an antigravity forward-compat fallback for claude-opus-4-6-thinking", () => { - mockDiscoveredModel({ - provider: "google-antigravity", - modelId: "claude-opus-4-5-thinking", - templateModel: buildForwardCompatTemplate({ - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking", - provider: "google-antigravity", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - }), - }); - - expectResolvedForwardCompatFallback({ - provider: "google-antigravity", - id: "claude-opus-4-6-thinking", - expectedModel: { - provider: "google-antigravity", - id: "claude-opus-4-6-thinking", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - contextWindow: 200000, - maxTokens: 64000, - }, - }); - }); - - it("builds an antigravity forward-compat fallback for claude-opus-4-6", () => { - mockDiscoveredModel({ - provider: "google-antigravity", - modelId: "claude-opus-4-5", - templateModel: buildForwardCompatTemplate({ - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - provider: "google-antigravity", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - }), - }); - - expectResolvedForwardCompatFallback({ - provider: "google-antigravity", - id: "claude-opus-4-6", - expectedModel: { - provider: "google-antigravity", - id: "claude-opus-4-6", - api: "google-gemini-cli", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - contextWindow: 200000, - maxTokens: 64000, - }, - }); - }); - it("builds a zai forward-compat fallback for glm-5", () => { mockDiscoveredModel({ provider: "zai", diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e98b3607b30..ab9c557f84a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -82,7 +82,6 @@ import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent } from "../extra-params.js"; import { logToolSchemasForGoogle, - sanitizeAntigravityThinkingBlocks, sanitizeSessionHistory, sanitizeToolsForGoogle, } from "../google.js"; @@ -1062,10 +1061,7 @@ export async function runEmbeddedAttempt( sessionManager.resetLeaf(); } const sessionContext = sessionManager.buildSessionContext(); - const sanitizedOrphan = transcriptPolicy.sanitizeThinkingSignatures - ? sanitizeAntigravityThinkingBlocks(sessionContext.messages) - : sessionContext.messages; - activeSession.agent.replaceMessages(sanitizedOrphan); + activeSession.agent.replaceMessages(sessionContext.messages); log.warn( `Removed orphaned user message to prevent consecutive user turns. ` + `runId=${params.runId} sessionId=${params.sessionId}`, diff --git a/src/agents/pi-tools.schema.ts b/src/agents/pi-tools.schema.ts index 41fdefb766e..f17d0077626 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/pi-tools.schema.ts @@ -78,16 +78,14 @@ export function normalizeToolParameters( // - Gemini rejects several JSON Schema keywords, so we scrub those. // - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`. // (TypeBox root unions compile to `{ anyOf: [...] }` without `type`). - // - Anthropic (google-antigravity) expects full JSON Schema draft 2020-12 compliance. + // - Anthropic expects full JSON Schema draft 2020-12 compliance. // // Normalize once here so callers can always pass `tools` through unchanged. const isGeminiProvider = options?.modelProvider?.toLowerCase().includes("google") || options?.modelProvider?.toLowerCase().includes("gemini"); - const isAnthropicProvider = - options?.modelProvider?.toLowerCase().includes("anthropic") || - options?.modelProvider?.toLowerCase().includes("google-antigravity"); + const isAnthropicProvider = options?.modelProvider?.toLowerCase().includes("anthropic"); // If schema already has type + properties (no top-level anyOf to merge), // clean it for Gemini compatibility (but only if using Gemini, not Anthropic) diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 0458c3d1a24..a94d7eb2c9f 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -1,5 +1,5 @@ import { normalizeProviderId } from "./model-selection.js"; -import { isAntigravityClaude, isGoogleModelApi } from "./pi-embedded-helpers/google.js"; +import { isGoogleModelApi } from "./pi-embedded-helpers/google.js"; import type { ToolCallIdMode } from "./tool-call-id.js"; export type TranscriptSanitizeMode = "full" | "images-only"; @@ -88,12 +88,6 @@ export function resolveTranscriptPolicy(params: { const isOpenRouterGemini = (provider === "openrouter" || provider === "opencode") && modelId.toLowerCase().includes("gemini"); - const isAntigravityClaudeModel = isAntigravityClaude({ - api: params.modelApi, - provider, - modelId, - }); - const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude"); // GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with @@ -112,16 +106,15 @@ export function resolveTranscriptPolicy(params: { const repairToolUseResultPairing = isGoogle || isAnthropic; const sanitizeThoughtSignatures = isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined; - const sanitizeThinkingSignatures = isAntigravityClaudeModel; return { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds, toolCallIdMode, repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing, - preserveSignatures: isAntigravityClaudeModel, + preserveSignatures: false, sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, - sanitizeThinkingSignatures, + sanitizeThinkingSignatures: false, dropThinkingBlocks, applyGoogleTurnOrdering: !isOpenAi && isGoogle, validateGeminiTurns: !isOpenAi && isGoogle, diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 5d04655525c..14819dd9c79 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -1179,8 +1179,8 @@ describe("runReplyAgent fallback reasoning tags", () => { }); runWithModelFallbackMock.mockImplementationOnce( async ({ run }: RunWithModelFallbackParams) => ({ - result: await run("google-antigravity", "gemini-3"), - provider: "google-antigravity", + result: await run("google-gemini-cli", "gemini-3"), + provider: "google-gemini-cli", model: "gemini-3", }), ); @@ -1199,8 +1199,8 @@ describe("runReplyAgent fallback reasoning tags", () => { return { payloads: [{ text: "ok" }], meta: {} }; }); runWithModelFallbackMock.mockImplementation(async ({ run }: RunWithModelFallbackParams) => ({ - result: await run("google-antigravity", "gemini-3"), - provider: "google-antigravity", + result: await run("google-gemini-cli", "gemini-3"), + provider: "google-gemini-cli", model: "gemini-3", })); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 0bc5c299cc1..ea2f7218cb7 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -62,7 +62,7 @@ const AUTH_CHOICE_GROUP_DEFS: { value: "google", label: "Google", hint: "Gemini API key + OAuth", - choices: ["gemini-api-key", "google-antigravity", "google-gemini-cli"], + choices: ["gemini-api-key", "google-gemini-cli"], }, { value: "xai", @@ -254,11 +254,6 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ hint: "Uses GitHub device flow", }, { value: "gemini-api-key", label: "Google Gemini API key" }, - { - value: "google-antigravity", - label: "Google Antigravity OAuth", - hint: "Uses the bundled Antigravity auth plugin", - }, { value: "google-gemini-cli", label: "Google Gemini CLI OAuth", diff --git a/src/commands/auth-choice.apply.google-antigravity.ts b/src/commands/auth-choice.apply.google-antigravity.ts deleted file mode 100644 index 6011b74e060..00000000000 --- a/src/commands/auth-choice.apply.google-antigravity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; - -export async function applyAuthChoiceGoogleAntigravity( - params: ApplyAuthChoiceParams, -): Promise { - return await applyAuthChoicePluginProvider(params, { - authChoice: "google-antigravity", - pluginId: "google-antigravity-auth", - providerId: "google-antigravity", - methodId: "oauth", - label: "Google Antigravity", - }); -} diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index 7d9791f34dc..e6dfa9ed52a 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -6,7 +6,6 @@ import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.j import { applyAuthChoiceBytePlus } from "./auth-choice.apply.byteplus.js"; import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js"; import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js"; -import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js"; import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; @@ -44,7 +43,6 @@ export async function applyAuthChoice( applyAuthChoiceApiProviders, applyAuthChoiceMiniMax, applyAuthChoiceGitHubCopilot, - applyAuthChoiceGoogleAntigravity, applyAuthChoiceGoogleGeminiCli, applyAuthChoiceCopilotProxy, applyAuthChoiceQwenPortal, diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 5b3abd6d183..68e442044d5 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -18,7 +18,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "moonshot-api-key-cn": "moonshot", "kimi-code-api-key": "kimi-coding", "gemini-api-key": "google", - "google-antigravity": "google-antigravity", "google-gemini-cli": "google-gemini-cli", "mistral-api-key": "mistral", "zai-api-key": "zai", diff --git a/src/commands/models.auth.provider-resolution.test.ts b/src/commands/models.auth.provider-resolution.test.ts index 11cc6d934ac..f03a99bb4cb 100644 --- a/src/commands/models.auth.provider-resolution.test.ts +++ b/src/commands/models.auth.provider-resolution.test.ts @@ -13,24 +13,24 @@ function makeProvider(params: { id: string; label?: string; aliases?: string[] } describe("resolveRequestedLoginProviderOrThrow", () => { it("returns null when no provider was requested", () => { - const providers = [makeProvider({ id: "google-antigravity" })]; + const providers = [makeProvider({ id: "google-gemini-cli" })]; const result = resolveRequestedLoginProviderOrThrow(providers, undefined); expect(result).toBeNull(); }); it("resolves requested provider by id", () => { const providers = [ - makeProvider({ id: "google-antigravity" }), makeProvider({ id: "google-gemini-cli" }), + makeProvider({ id: "qwen-portal" }), ]; - const result = resolveRequestedLoginProviderOrThrow(providers, "google-antigravity"); - expect(result?.id).toBe("google-antigravity"); + const result = resolveRequestedLoginProviderOrThrow(providers, "google-gemini-cli"); + expect(result?.id).toBe("google-gemini-cli"); }); it("resolves requested provider by alias", () => { - const providers = [makeProvider({ id: "google-antigravity", aliases: ["antigravity"] })]; - const result = resolveRequestedLoginProviderOrThrow(providers, "antigravity"); - expect(result?.id).toBe("google-antigravity"); + const providers = [makeProvider({ id: "google-gemini-cli", aliases: ["gemini-cli"] })]; + const result = resolveRequestedLoginProviderOrThrow(providers, "gemini-cli"); + expect(result?.id).toBe("google-gemini-cli"); }); it("throws when requested provider is not loaded", () => { diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 8e9df0035b4..da64561de3f 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -200,30 +200,6 @@ describe("models list/status", () => { return JSON.parse(String(runtime.log.mock.calls[0]?.[0])); } - async function runAvailabilityFallbackCase(params: { - setup?: () => void; - expectedErrorDetail: string; - }) { - configureGoogleAntigravityModel("claude-opus-4-6-thinking"); - enableGoogleAntigravityAuthProfile(); - const runtime = makeRuntime(); - - modelRegistryState.models = [ - makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"), - ]; - modelRegistryState.available = []; - params.setup?.(); - await modelsListCommand({ json: true }, runtime); - - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); - expect(runtime.error.mock.calls[0]?.[0]).toContain(params.expectedErrorDetail); - const payload = parseJsonLog(runtime); - expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); - expect(payload.models[0]?.missing).toBe(false); - expect(payload.models[0]?.available).toBe(true); - } - async function expectZaiProviderFilter(provider: string) { setDefaultZaiRegistry(); const runtime = makeRuntime(); @@ -242,66 +218,6 @@ describe("models list/status", () => { modelRegistryState.available = available ? [ZAI_MODEL, OPENAI_MODEL] : []; } - function setupGoogleAntigravityTemplateCase(params: { - configuredModelId: string; - templateId: string; - templateName: string; - available?: boolean; - }) { - configureGoogleAntigravityModel(params.configuredModelId); - const template = makeGoogleAntigravityTemplate(params.templateId, params.templateName); - modelRegistryState.models = [template]; - modelRegistryState.available = params.available ? [template] : []; - return template; - } - - async function runGoogleAntigravityListCase(params: { - configuredModelId: string; - templateId: string; - templateName: string; - available?: boolean; - withAuthProfile?: boolean; - }) { - setupGoogleAntigravityTemplateCase(params); - if (params.withAuthProfile) { - enableGoogleAntigravityAuthProfile(); - } - const runtime = makeRuntime(); - await modelsListCommand({ json: true }, runtime); - return parseJsonLog(runtime); - } - - const GOOGLE_ANTIGRAVITY_OPUS_46_CASES = [ - { - name: "thinking", - configuredModelId: "claude-opus-4-6-thinking", - templateId: "claude-opus-4-5-thinking", - templateName: "Claude Opus 4.5 Thinking", - expectedKey: "google-antigravity/claude-opus-4-6-thinking", - }, - { - name: "non-thinking", - configuredModelId: "claude-opus-4-6", - templateId: "claude-opus-4-5", - templateName: "Claude Opus 4.5", - expectedKey: "google-antigravity/claude-opus-4-6", - }, - ] as const; - - function expectAntigravityModel( - payload: Record, - params: { key: string; available: boolean; includesTags?: boolean }, - ) { - const model = (payload.models as Array>)[0] ?? {}; - expect(model.key).toBe(params.key); - expect(model.missing).toBe(false); - expect(model.available).toBe(params.available); - if (params.includesTags) { - expect(model.tags).toContain("default"); - expect(model.tags).toContain("configured"); - } - } - beforeAll(async () => { ({ modelsListCommand } = await import("./models/list.list-command.js")); ({ loadModelRegistry, toModelRow } = await import("./models/list.registry.js")); @@ -357,177 +273,6 @@ describe("models list/status", () => { expect(payload.models[0]?.available).toBe(false); }); - it.each(GOOGLE_ANTIGRAVITY_OPUS_46_CASES)( - "models list resolves antigravity opus 4.6 $name from 4.5 template", - async ({ configuredModelId, templateId, templateName, expectedKey }) => { - const payload = await runGoogleAntigravityListCase({ - configuredModelId, - templateId, - templateName, - }); - expectAntigravityModel(payload, { - key: expectedKey, - available: false, - includesTags: true, - }); - }, - ); - - it.each(GOOGLE_ANTIGRAVITY_OPUS_46_CASES)( - "models list marks synthesized antigravity opus 4.6 $name as available when template is available", - async ({ configuredModelId, templateId, templateName, expectedKey }) => { - const payload = await runGoogleAntigravityListCase({ - configuredModelId, - templateId, - templateName, - available: true, - }); - expectAntigravityModel(payload, { - key: expectedKey, - available: true, - }); - }, - ); - - it.each([ - { - name: "high", - configuredModelId: "gemini-3-1-pro-high", - templateId: "gemini-3-pro-high", - templateName: "Gemini 3 Pro High", - expectedKey: "google-antigravity/gemini-3-1-pro-high", - }, - { - name: "low", - configuredModelId: "gemini-3-1-pro-low", - templateId: "gemini-3-pro-low", - templateName: "Gemini 3 Pro Low", - expectedKey: "google-antigravity/gemini-3-1-pro-low", - }, - ] as const)( - "models list resolves antigravity gemini 3.1 $name from gemini 3 template", - async ({ configuredModelId, templateId, templateName, expectedKey }) => { - const payload = await runGoogleAntigravityListCase({ - configuredModelId, - templateId, - templateName, - }); - expectAntigravityModel(payload, { - key: expectedKey, - available: false, - includesTags: true, - }); - }, - ); - - it.each([ - { - name: "high", - configuredModelId: "gemini-3-1-pro-high", - templateId: "gemini-3-pro-high", - templateName: "Gemini 3 Pro High", - expectedKey: "google-antigravity/gemini-3-1-pro-high", - }, - { - name: "low", - configuredModelId: "gemini-3-1-pro-low", - templateId: "gemini-3-pro-low", - templateName: "Gemini 3 Pro Low", - expectedKey: "google-antigravity/gemini-3-1-pro-low", - }, - ] as const)( - "models list marks synthesized antigravity gemini 3.1 $name as available when template is available", - async ({ configuredModelId, templateId, templateName, expectedKey }) => { - const payload = await runGoogleAntigravityListCase({ - configuredModelId, - templateId, - templateName, - available: true, - }); - expectAntigravityModel(payload, { - key: expectedKey, - available: true, - }); - }, - ); - - it.each([ - { - name: "high", - configuredModelId: "gemini-3.1-pro-high", - templateId: "gemini-3-pro-high", - templateName: "Gemini 3 Pro High", - expectedKey: "google-antigravity/gemini-3.1-pro-high", - }, - { - name: "low", - configuredModelId: "gemini-3.1-pro-low", - templateId: "gemini-3-pro-low", - templateName: "Gemini 3 Pro Low", - expectedKey: "google-antigravity/gemini-3.1-pro-low", - }, - ] as const)( - "models list marks dot-notation antigravity gemini 3.1 $name as available when template is available", - async ({ configuredModelId, templateId, templateName, expectedKey }) => { - const payload = await runGoogleAntigravityListCase({ - configuredModelId, - templateId, - templateName, - available: true, - }); - expectAntigravityModel(payload, { - key: expectedKey, - available: true, - }); - }, - ); - - it("models list prefers registry availability over provider auth heuristics", async () => { - const payload = await runGoogleAntigravityListCase({ - configuredModelId: "claude-opus-4-6-thinking", - templateId: "claude-opus-4-5-thinking", - templateName: "Claude Opus 4.5 Thinking", - withAuthProfile: true, - }); - expectAntigravityModel(payload, { - key: "google-antigravity/claude-opus-4-6-thinking", - available: false, - }); - listProfilesForProvider.mockReturnValue([]); - }); - - it("models list falls back to auth heuristics when registry availability is unavailable", async () => { - await runAvailabilityFallbackCase({ - setup: () => { - modelRegistryState.getAvailableError = Object.assign( - new Error("availability unsupported: getAvailable failed"), - { code: "MODEL_AVAILABILITY_UNAVAILABLE" }, - ); - }, - expectedErrorDetail: "getAvailable failed", - }); - }); - - it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => { - await runAvailabilityFallbackCase({ - setup: () => { - modelRegistryState.available = { bad: true } as unknown as Array>; - }, - expectedErrorDetail: "non-array value", - }); - }); - - it("models list falls back to auth heuristics when getAvailable throws", async () => { - await runAvailabilityFallbackCase({ - setup: () => { - modelRegistryState.getAvailableError = new Error( - "availability unsupported: getAvailable failed", - ); - }, - expectedErrorDetail: "availability unsupported: getAvailable failed", - }); - }); - it("models list does not treat availability-unavailable code as discovery fallback", async () => { configureGoogleAntigravityModel("claude-opus-4-6-thinking"); modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), { diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index b0daded3db7..23cef29485c 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -7,11 +7,6 @@ import { resolveAwsSdkEnvVarName, resolveEnvApiKey, } from "../../agents/model-auth.js"; -import { - ANTIGRAVITY_GEMINI_31_FORWARD_COMPAT_CANDIDATES, - ANTIGRAVITY_OPUS_46_FORWARD_COMPAT_CANDIDATES, - resolveForwardCompatModel, -} from "../../agents/model-forward-compat.js"; import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; import { ensurePiAuthJsonFromAuthProfiles } from "../../agents/pi-auth-json.js"; import type { ModelRegistry } from "../../agents/pi-model-discovery.js"; @@ -106,23 +101,13 @@ export async function loadModelRegistry(cfg: OpenClawConfig) { await ensurePiAuthJsonFromAuthProfiles(agentDir); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); - const appended = appendAntigravityForwardCompatModels(registry.getAll(), registry); - const models = appended.models; - const synthesizedForwardCompat = appended.synthesizedForwardCompat; + const models = registry.getAll(); let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; try { const availableModels = loadAvailableModels(registry); availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id))); - for (const synthesized of synthesizedForwardCompat) { - if (hasAvailableTemplate(availableKeys, synthesized.templatePrefixes)) { - availableKeys.add(synthesized.key); - for (const aliasKey of synthesized.availabilityAliasKeys) { - availableKeys.add(aliasKey); - } - } - } } catch (err) { if (!shouldFallbackToAuthHeuristics(err)) { throw err; @@ -138,60 +123,6 @@ export async function loadModelRegistry(cfg: OpenClawConfig) { return { registry, models, availableKeys, availabilityErrorMessage }; } -type SynthesizedForwardCompat = { - key: string; - templatePrefixes: readonly string[]; - availabilityAliasKeys: readonly string[]; -}; - -function appendAntigravityForwardCompatModels( - models: Model[], - modelRegistry: ModelRegistry, -): { models: Model[]; synthesizedForwardCompat: SynthesizedForwardCompat[] } { - const nextModels = [...models]; - const synthesizedForwardCompat: SynthesizedForwardCompat[] = []; - const candidates = [ - ...ANTIGRAVITY_OPUS_46_FORWARD_COMPAT_CANDIDATES, - ...ANTIGRAVITY_GEMINI_31_FORWARD_COMPAT_CANDIDATES, - ]; - - for (const candidate of candidates) { - const key = modelKey("google-antigravity", candidate.id); - const hasForwardCompat = nextModels.some((model) => modelKey(model.provider, model.id) === key); - if (hasForwardCompat) { - continue; - } - - const fallback = resolveForwardCompatModel("google-antigravity", candidate.id, modelRegistry); - if (!fallback) { - continue; - } - - nextModels.push(fallback); - synthesizedForwardCompat.push({ - key, - templatePrefixes: candidate.templatePrefixes, - availabilityAliasKeys: candidate.availabilityAliasIds.map((id) => - modelKey("google-antigravity", id), - ), - }); - } - - return { models: nextModels, synthesizedForwardCompat }; -} - -function hasAvailableTemplate( - availableKeys: Set, - templatePrefixes: readonly string[], -): boolean { - for (const key of availableKeys) { - if (templatePrefixes.some((prefix) => key.startsWith(prefix))) { - return true; - } - } - return false; -} - export function toModelRow(params: { model?: Model; key: string; diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 96bee13fce7..bb3bdb471d8 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -26,7 +26,6 @@ export type AuthChoice = | "codex-cli" | "apiKey" | "gemini-api-key" - | "google-antigravity" | "google-gemini-cli" | "zai-api-key" | "zai-coding-global" diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index a0979b537d0..7f5779a1818 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -92,8 +92,8 @@ describe("applyPluginAutoEnable", () => { config: { auth: { profiles: { - "google-antigravity:default": { - provider: "google-antigravity", + "google-gemini-cli:default": { + provider: "google-gemini-cli", mode: "oauth", }, }, @@ -102,7 +102,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["google-gemini-cli-auth"]?.enabled).toBe(true); }); it("skips when plugins are globally disabled", () => { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 50fb9dac90a..63657e3ea21 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -31,7 +31,6 @@ const CHANNEL_PLUGIN_IDS = Array.from( ); const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ - { pluginId: "google-antigravity-auth", providerId: "google-antigravity" }, { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 80068d3d8f5..3dccd2bf1be 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -216,17 +216,17 @@ describe("resolveProviderAuths key normalization", () => { it("keeps raw google token when token payload is not JSON", async () => { await withSuiteHome(async (home) => { await writeAuthProfiles(home, { - "google-antigravity:default": { + "google-gemini-cli:default": { type: "token", - provider: "google-antigravity", + provider: "google-gemini-cli", token: "plain-google-token", }, }); const auths = await resolveProviderAuths({ - providers: ["google-antigravity"], + providers: ["google-gemini-cli"], }); - expect(auths).toEqual([{ provider: "google-antigravity", token: "plain-google-token" }]); + expect(auths).toEqual([{ provider: "google-gemini-cli", token: "plain-google-token" }]); }, {}); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 85551bdfc46..ff63c1570f1 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -158,7 +158,7 @@ async function resolveOAuthToken(params: { }); if (resolved) { let token = resolved.apiKey; - if (params.provider === "google-gemini-cli" || params.provider === "google-antigravity") { + if (params.provider === "google-gemini-cli") { const parsed = parseGoogleToken(resolved.apiKey); token = parsed?.token ?? resolved.apiKey; } @@ -188,7 +188,6 @@ function resolveOAuthProviders(agentDir?: string): UsageProviderId[] { "anthropic", "github-copilot", "google-gemini-cli", - "google-antigravity", "openai-codex", ] satisfies UsageProviderId[]; const isOAuthLikeCredential = (id: string) => { diff --git a/src/infra/provider-usage.fetch.antigravity.test.ts b/src/infra/provider-usage.fetch.antigravity.test.ts deleted file mode 100644 index 728d6b74229..00000000000 --- a/src/infra/provider-usage.fetch.antigravity.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; -import { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js"; - -const getRequestBody = (init?: Parameters[1]) => - typeof init?.body === "string" ? init.body : undefined; - -type EndpointHandler = (init?: Parameters[1]) => Promise | Response; - -function createEndpointFetch(spec: { - loadCodeAssist?: EndpointHandler; - fetchAvailableModels?: EndpointHandler; -}) { - return createProviderUsageFetch(async (url, init) => { - if (url.includes("loadCodeAssist")) { - return (await spec.loadCodeAssist?.(init)) ?? makeResponse(404, "not found"); - } - if (url.includes("fetchAvailableModels")) { - return (await spec.fetchAvailableModels?.(init)) ?? makeResponse(404, "not found"); - } - return makeResponse(404, "not found"); - }); -} - -async function runUsage(mockFetch: ReturnType) { - return fetchAntigravityUsage("token-123", 5000, mockFetch as unknown as typeof fetch); -} - -function findWindow(snapshot: Awaited>, label: string) { - return snapshot.windows.find((window) => window.label === label); -} - -function expectTokenExpired(snapshot: Awaited>) { - expect(snapshot.error).toBe("Token expired"); - expect(snapshot.windows).toHaveLength(0); -} - -function expectSingleWindow( - snapshot: Awaited>, - label: string, -) { - expect(snapshot.windows).toHaveLength(1); - expect(snapshot.windows[0]?.label).toBe(label); - return snapshot.windows[0]; -} - -describe("fetchAntigravityUsage", () => { - it("returns 3 windows when both endpoints succeed", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: 750, - planInfo: { monthlyPromptCredits: 1000 }, - planType: "Standard", - currentTier: { id: "tier1", name: "Standard Tier" }, - }), - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-pro-1.5": { - quotaInfo: { - remainingFraction: 0.6, - resetTime: "2026-01-08T00:00:00Z", - isExhausted: false, - }, - }, - "gemini-flash-2.0": { - quotaInfo: { - remainingFraction: 0.8, - resetTime: "2026-01-08T00:00:00Z", - isExhausted: false, - }, - }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - - expect(snapshot.provider).toBe("google-antigravity"); - expect(snapshot.displayName).toBe("Antigravity"); - expect(snapshot.windows).toHaveLength(3); - expect(snapshot.plan).toBe("Standard Tier"); - expect(snapshot.error).toBeUndefined(); - - const creditsWindow = findWindow(snapshot, "Credits"); - expect(creditsWindow?.usedPercent).toBe(25); // (1000 - 750) / 1000 * 100 - - const proWindow = findWindow(snapshot, "gemini-pro-1.5"); - expect(proWindow?.usedPercent).toBe(40); // (1 - 0.6) * 100 - expect(proWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime()); - - const flashWindow = findWindow(snapshot, "gemini-flash-2.0"); - expect(flashWindow?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100 - expect(flashWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime()); - - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it("returns Credits only when loadCodeAssist succeeds but fetchAvailableModels fails", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: 250, - planInfo: { monthlyPromptCredits: 1000 }, - currentTier: { name: "Free" }, - }), - fetchAvailableModels: () => makeResponse(403, { error: { message: "Permission denied" } }), - }); - - const snapshot = await runUsage(mockFetch); - - expect(snapshot.provider).toBe("google-antigravity"); - expect(snapshot.windows).toHaveLength(1); - expect(snapshot.plan).toBe("Free"); - expect(snapshot.error).toBeUndefined(); - - const creditsWindow = snapshot.windows[0]; - expect(creditsWindow?.label).toBe("Credits"); - expect(creditsWindow?.usedPercent).toBe(75); // (1000 - 250) / 1000 * 100 - - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it("returns model IDs when fetchAvailableModels succeeds but loadCodeAssist fails", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(500, "Internal server error"), - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-pro-1.5": { - quotaInfo: { remainingFraction: 0.5, resetTime: "2026-01-08T00:00:00Z" }, - }, - "gemini-flash-2.0": { - quotaInfo: { remainingFraction: 0.7, resetTime: "2026-01-08T00:00:00Z" }, - }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - - expect(snapshot.provider).toBe("google-antigravity"); - expect(snapshot.windows).toHaveLength(2); - expect(snapshot.error).toBeUndefined(); - - const proWindow = findWindow(snapshot, "gemini-pro-1.5"); - expect(proWindow?.usedPercent).toBe(50); // (1 - 0.5) * 100 - - const flashWindow = findWindow(snapshot, "gemini-flash-2.0"); - expect(flashWindow?.usedPercent).toBeCloseTo(30, 1); // (1 - 0.7) * 100 - - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it.each([ - { - name: "uses cloudaicompanionProject string as project id", - project: "projects/alpha", - expectedBody: JSON.stringify({ project: "projects/alpha" }), - }, - { - name: "uses cloudaicompanionProject object id when present", - project: { id: "projects/beta" }, - expectedBody: JSON.stringify({ project: "projects/beta" }), - }, - ])("project payload: $name", async ({ project, expectedBody }) => { - let capturedBody: string | undefined; - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: 900, - planInfo: { monthlyPromptCredits: 1000 }, - cloudaicompanionProject: project, - }), - fetchAvailableModels: (init) => { - capturedBody = getRequestBody(init); - return makeResponse(200, { models: {} }); - }, - }); - - await runUsage(mockFetch); - expect(capturedBody).toBe(expectedBody); - }); - - it("returns error snapshot when both endpoints fail", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(403, { error: { message: "Access denied" } }), - fetchAvailableModels: () => makeResponse(403, "Forbidden"), - }); - - const snapshot = await runUsage(mockFetch); - - expect(snapshot.provider).toBe("google-antigravity"); - expect(snapshot.windows).toHaveLength(0); - expect(snapshot.error).toBe("Access denied"); - - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it("returns Token expired when fetchAvailableModels returns 401 and no windows", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(500, "Boom"), - fetchAvailableModels: () => makeResponse(401, { error: { message: "Unauthorized" } }), - }); - - const snapshot = await runUsage(mockFetch); - expectTokenExpired(snapshot); - }); - - it.each([ - { - name: "extracts plan info from currentTier.name", - loadCodeAssist: { - availablePromptCredits: 500, - planInfo: { monthlyPromptCredits: 1000 }, - planType: "Basic", - currentTier: { id: "tier2", name: "Premium Tier" }, - }, - expectedPlan: "Premium Tier", - }, - { - name: "falls back to planType when currentTier.name is missing", - loadCodeAssist: { - availablePromptCredits: 500, - planInfo: { monthlyPromptCredits: 1000 }, - planType: "Basic Plan", - }, - expectedPlan: "Basic Plan", - }, - ])("plan label: $name", async ({ loadCodeAssist, expectedPlan }) => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(200, loadCodeAssist), - fetchAvailableModels: () => makeResponse(500, "Error"), - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.plan).toBe(expectedPlan); - }); - - it("includes reset times in model windows", async () => { - const resetTime = "2026-01-10T12:00:00Z"; - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(500, "Error"), - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-pro-experimental": { - quotaInfo: { remainingFraction: 0.3, resetTime }, - }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-experimental"); - expect(proWindow?.resetAt).toBe(new Date(resetTime).getTime()); - }); - - it("parses string numbers correctly", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: "600", - planInfo: { monthlyPromptCredits: "1000" }, - }), - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-flash-lite": { - quotaInfo: { remainingFraction: "0.9" }, - }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.windows).toHaveLength(2); - - const creditsWindow = snapshot.windows.find((w) => w.label === "Credits"); - expect(creditsWindow?.usedPercent).toBe(40); // (1000 - 600) / 1000 * 100 - - const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-lite"); - expect(flashWindow?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100 - }); - - it("skips internal models", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: 500, - planInfo: { monthlyPromptCredits: 1000 }, - cloudaicompanionProject: "projects/internal", - }), - fetchAvailableModels: () => - makeResponse(200, { - models: { - chat_hidden: { quotaInfo: { remainingFraction: 0.1 } }, - tab_hidden: { quotaInfo: { remainingFraction: 0.2 } }, - "gemini-pro-1.5": { quotaInfo: { remainingFraction: 0.7 } }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.windows.map((w) => w.label)).toEqual(["Credits", "gemini-pro-1.5"]); - }); - - it("sorts models by usage and shows individual model IDs", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(500, "Error"), - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-pro-1.0": { quotaInfo: { remainingFraction: 0.8 } }, - "gemini-pro-1.5": { quotaInfo: { remainingFraction: 0.3 } }, - "gemini-flash-1.5": { quotaInfo: { remainingFraction: 0.6 } }, - "gemini-flash-2.0": { quotaInfo: { remainingFraction: 0.9 } }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.windows).toHaveLength(4); - expect(snapshot.windows[0]?.label).toBe("gemini-pro-1.5"); - expect(snapshot.windows[0]?.usedPercent).toBe(70); // (1 - 0.3) * 100 - expect(snapshot.windows[1]?.label).toBe("gemini-flash-1.5"); - expect(snapshot.windows[1]?.usedPercent).toBe(40); // (1 - 0.6) * 100 - expect(snapshot.windows[2]?.label).toBe("gemini-pro-1.0"); - expect(snapshot.windows[2]?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100 - expect(snapshot.windows[3]?.label).toBe("gemini-flash-2.0"); - expect(snapshot.windows[3]?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100 - }); - - it("returns Token expired error on 401 from loadCodeAssist", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(401, { error: { message: "Unauthorized" } }), - }); - - const snapshot = await runUsage(mockFetch); - expectTokenExpired(snapshot); - expect(mockFetch).toHaveBeenCalledTimes(1); // Should stop early on 401 - }); - - it("handles empty models object gracefully", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: 800, - planInfo: { monthlyPromptCredits: 1000 }, - }), - fetchAvailableModels: () => makeResponse(200, { models: {} }), - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.windows).toHaveLength(1); - const creditsWindow = snapshot.windows[0]; - expect(creditsWindow?.label).toBe("Credits"); - expect(creditsWindow?.usedPercent).toBe(20); - }); - - it("handles missing or invalid model quota payloads", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(500, "Error"), - fetchAvailableModels: () => - makeResponse(200, { - models: { - no_quota: {}, - missing_fraction: { quotaInfo: {} }, - invalid_fraction: { quotaInfo: { remainingFraction: "oops" } }, - valid_model: { quotaInfo: { remainingFraction: 0.25 } }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.windows).toEqual([{ label: "valid_model", usedPercent: 75 }]); - }); - - it("handles non-object models payload gracefully", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: 900, - planInfo: { monthlyPromptCredits: 1000 }, - }), - fetchAvailableModels: () => makeResponse(200, { models: null }), - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.windows).toEqual([{ label: "Credits", usedPercent: 10 }]); - }); - - it("handles missing credits fields gracefully", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(200, { planType: "Free" }), - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-flash-experimental": { - quotaInfo: { remainingFraction: 0.5 }, - }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - const flashWindow = expectSingleWindow(snapshot, "gemini-flash-experimental"); - expect(flashWindow?.usedPercent).toBe(50); - expect(snapshot.plan).toBe("Free"); - }); - - it("handles invalid reset time gracefully", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => makeResponse(500, "Error"), - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-pro-test": { - quotaInfo: { remainingFraction: 0.4, resetTime: "invalid-date" }, - }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-test"); - expect(proWindow?.usedPercent).toBe(60); - expect(proWindow?.resetAt).toBeUndefined(); - }); - - it("handles loadCodeAssist network errors with graceful degradation", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => { - throw new Error("Network failure"); - }, - fetchAvailableModels: () => - makeResponse(200, { - models: { - "gemini-flash-stable": { - quotaInfo: { remainingFraction: 0.85 }, - }, - }, - }), - }); - - const snapshot = await runUsage(mockFetch); - const flashWindow = expectSingleWindow(snapshot, "gemini-flash-stable"); - expect(flashWindow?.usedPercent).toBeCloseTo(15, 1); - expect(snapshot.error).toBeUndefined(); - }); - - it("handles fetchAvailableModels network errors with graceful degradation", async () => { - const mockFetch = createEndpointFetch({ - loadCodeAssist: () => - makeResponse(200, { - availablePromptCredits: 300, - planInfo: { monthlyPromptCredits: 1000 }, - }), - fetchAvailableModels: () => { - throw new Error("Network failure"); - }, - }); - - const snapshot = await runUsage(mockFetch); - expect(snapshot.windows).toEqual([{ label: "Credits", usedPercent: 70 }]); - expect(snapshot.error).toBeUndefined(); - }); -}); diff --git a/src/infra/provider-usage.fetch.antigravity.ts b/src/infra/provider-usage.fetch.antigravity.ts deleted file mode 100644 index ce21f77b798..00000000000 --- a/src/infra/provider-usage.fetch.antigravity.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { logDebug } from "../logger.js"; -import { fetchJson, parseFiniteNumber } from "./provider-usage.fetch.shared.js"; -import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; - -type LoadCodeAssistResponse = { - availablePromptCredits?: number | string; - planInfo?: { monthlyPromptCredits?: number | string }; - planType?: string; - currentTier?: { id?: string; name?: string }; - cloudaicompanionProject?: string | { id?: string }; -}; - -type FetchAvailableModelsResponse = { - models?: Record< - string, - { - displayName?: string; - quotaInfo?: { - remainingFraction?: number | string; - resetTime?: string; - isExhausted?: boolean; - }; - } - >; -}; - -type ModelQuota = { - remainingFraction: number; - resetTime?: number; -}; - -type CreditsInfo = { - available: number; - monthly: number; -}; - -const BASE_URL = "https://cloudcode-pa.googleapis.com"; -const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist"; -const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; - -const METADATA = { - ideType: "ANTIGRAVITY", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", -}; - -function parseNumber(value: number | string | undefined): number | undefined { - return parseFiniteNumber(value); -} - -function parseEpochMs(isoString: string | undefined): number | undefined { - if (!isoString?.trim()) { - return undefined; - } - try { - const ms = Date.parse(isoString); - if (Number.isFinite(ms)) { - return ms; - } - } catch { - // ignore parse errors - } - return undefined; -} - -async function parseErrorMessage(res: Response): Promise { - try { - const data = (await res.json()) as { error?: { message?: string } }; - const message = data?.error?.message?.trim(); - if (message) { - return message; - } - } catch { - // ignore parse errors - } - return `HTTP ${res.status}`; -} - -function extractCredits(data: LoadCodeAssistResponse): CreditsInfo | undefined { - const available = parseNumber(data.availablePromptCredits); - const monthly = parseNumber(data.planInfo?.monthlyPromptCredits); - if (available === undefined || monthly === undefined || monthly <= 0) { - return undefined; - } - return { available, monthly }; -} - -function extractPlanInfo(data: LoadCodeAssistResponse): string | undefined { - const tierName = data.currentTier?.name?.trim(); - if (tierName) { - return tierName; - } - const planType = data.planType?.trim(); - if (planType) { - return planType; - } - return undefined; -} - -function extractProjectId(data: LoadCodeAssistResponse): string | undefined { - const project = data.cloudaicompanionProject; - if (!project) { - return undefined; - } - if (typeof project === "string") { - return project.trim() ? project : undefined; - } - const projectId = typeof project.id === "string" ? project.id.trim() : undefined; - return projectId || undefined; -} - -function extractModelQuotas(data: FetchAvailableModelsResponse): Map { - const result = new Map(); - if (!data.models || typeof data.models !== "object") { - return result; - } - - for (const [modelId, modelInfo] of Object.entries(data.models)) { - const quotaInfo = modelInfo.quotaInfo; - if (!quotaInfo) { - continue; - } - - const remainingFraction = parseNumber(quotaInfo.remainingFraction); - if (remainingFraction === undefined) { - continue; - } - - const resetTime = parseEpochMs(quotaInfo.resetTime); - result.set(modelId, { remainingFraction, resetTime }); - } - - return result; -} - -function buildUsageWindows(opts: { - credits?: CreditsInfo; - modelQuotas?: Map; -}): UsageWindow[] { - const windows: UsageWindow[] = []; - - // Credits window (overall) - if (opts.credits) { - const { available, monthly } = opts.credits; - const used = monthly - available; - const usedPercent = clampPercent((used / monthly) * 100); - windows.push({ label: "Credits", usedPercent }); - } - - // Individual model windows - if (opts.modelQuotas && opts.modelQuotas.size > 0) { - const modelWindows: UsageWindow[] = []; - - for (const [modelId, quota] of opts.modelQuotas) { - const lowerModelId = modelId.toLowerCase(); - - // Skip internal models - if (lowerModelId.includes("chat_") || lowerModelId.includes("tab_")) { - continue; - } - - const usedPercent = clampPercent((1 - quota.remainingFraction) * 100); - const window: UsageWindow = { label: modelId, usedPercent }; - if (quota.resetTime) { - window.resetAt = quota.resetTime; - } - modelWindows.push(window); - } - - // Sort by usage (highest first) and take top 10 - modelWindows.sort((a, b) => b.usedPercent - a.usedPercent); - const topModels = modelWindows.slice(0, 10); - logDebug( - `[antigravity] Built ${topModels.length} model windows from ${opts.modelQuotas.size} total models`, - ); - for (const w of topModels) { - logDebug( - `[antigravity] ${w.label}: ${w.usedPercent.toFixed(1)}% used${w.resetAt ? ` (resets at ${new Date(w.resetAt).toISOString()})` : ""}`, - ); - } - windows.push(...topModels); - } - - return windows; -} - -export async function fetchAntigravityUsage( - token: string, - timeoutMs: number, - fetchFn: typeof fetch, -): Promise { - const headers: Record = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": "antigravity", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - }; - - let credits: CreditsInfo | undefined; - let modelQuotas: Map | undefined; - let planInfo: string | undefined; - let lastError: string | undefined; - let projectId: string | undefined; - - // Fetch loadCodeAssist (credits + plan info) - try { - const res = await fetchJson( - `${BASE_URL}${LOAD_CODE_ASSIST_PATH}`, - { method: "POST", headers, body: JSON.stringify({ metadata: METADATA }) }, - timeoutMs, - fetchFn, - ); - - if (res.ok) { - const data = (await res.json()) as LoadCodeAssistResponse; - - // Extract project ID for subsequent calls - projectId = extractProjectId(data); - - credits = extractCredits(data); - planInfo = extractPlanInfo(data); - logDebug( - `[antigravity] Credits: ${credits ? `${credits.available}/${credits.monthly}` : "none"}${planInfo ? ` (plan: ${planInfo})` : ""}`, - ); - } else { - lastError = await parseErrorMessage(res); - // Fatal auth errors - stop early - if (res.status === 401) { - return { - provider: "google-antigravity", - displayName: PROVIDER_LABELS["google-antigravity"], - windows: [], - error: "Token expired", - }; - } - } - } catch { - lastError = "Network error"; - } - - // Fetch fetchAvailableModels (model quotas) - if (!projectId) { - logDebug("[antigravity] Missing project id; requesting available models without project"); - } - try { - const body = JSON.stringify(projectId ? { project: projectId } : {}); - const res = await fetchJson( - `${BASE_URL}${FETCH_AVAILABLE_MODELS_PATH}`, - { method: "POST", headers, body }, - timeoutMs, - fetchFn, - ); - - if (res.ok) { - const data = (await res.json()) as FetchAvailableModelsResponse; - modelQuotas = extractModelQuotas(data); - logDebug(`[antigravity] Extracted ${modelQuotas.size} model quotas from API`); - for (const [modelId, quota] of modelQuotas) { - logDebug( - `[antigravity] ${modelId}: ${(quota.remainingFraction * 100).toFixed(1)}% remaining${quota.resetTime ? ` (resets ${new Date(quota.resetTime).toISOString()})` : ""}`, - ); - } - } else { - const err = await parseErrorMessage(res); - if (res.status === 401) { - lastError = "Token expired"; - } else if (!lastError) { - lastError = err; - } - } - } catch { - if (!lastError) { - lastError = "Network error"; - } - } - - // Build windows from available data - const windows = buildUsageWindows({ credits, modelQuotas }); - - // Return error only if we got nothing - if (windows.length === 0 && lastError) { - logDebug(`[antigravity] Returning error snapshot: ${lastError}`); - return { - provider: "google-antigravity", - displayName: PROVIDER_LABELS["google-antigravity"], - windows: [], - error: lastError, - }; - } - - const snapshot: ProviderUsageSnapshot = { - provider: "google-antigravity", - displayName: PROVIDER_LABELS["google-antigravity"], - windows, - plan: planInfo, - }; - - logDebug( - `[antigravity] Returning snapshot with ${windows.length} windows${planInfo ? ` (plan: ${planInfo})` : ""}`, - ); - logDebug(`[antigravity] Snapshot: ${JSON.stringify(snapshot, null, 2)}`); - - return snapshot; -} diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index 07039655463..e0bcd60c94b 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,4 +1,3 @@ -export { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js"; export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index d4975dc0a06..b62cfec728f 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -1,7 +1,6 @@ import { resolveFetch } from "./fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; import { - fetchAntigravityUsage, fetchClaudeUsage, fetchCodexUsage, fetchCopilotUsage, @@ -58,8 +57,6 @@ export async function loadProviderUsageSummary( return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn); case "github-copilot": return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn); - case "google-antigravity": - return await fetchAntigravityUsage(auth.token, timeoutMs, fetchFn); case "google-gemini-cli": return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider); case "openai-codex": diff --git a/src/infra/provider-usage.shared.test.ts b/src/infra/provider-usage.shared.test.ts index 270e4cc65c4..3de021235be 100644 --- a/src/infra/provider-usage.shared.test.ts +++ b/src/infra/provider-usage.shared.test.ts @@ -4,7 +4,7 @@ import { clampPercent, resolveUsageProviderId, withTimeout } from "./provider-us describe("provider-usage.shared", () => { it("normalizes supported usage provider ids", () => { expect(resolveUsageProviderId("z-ai")).toBe("zai"); - expect(resolveUsageProviderId(" GOOGLE-ANTIGRAVITY ")).toBe("google-antigravity"); + expect(resolveUsageProviderId(" GOOGLE-GEMINI-CLI ")).toBe("google-gemini-cli"); expect(resolveUsageProviderId("unknown-provider")).toBeUndefined(); expect(resolveUsageProviderId()).toBeUndefined(); }); diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 763eca4e8ae..6fa823db630 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -7,7 +7,6 @@ export const PROVIDER_LABELS: Record = { anthropic: "Claude", "github-copilot": "Copilot", "google-gemini-cli": "Gemini", - "google-antigravity": "Antigravity", minimax: "MiniMax", "openai-codex": "Codex", xiaomi: "Xiaomi", @@ -18,7 +17,6 @@ export const usageProviders: UsageProviderId[] = [ "anthropic", "github-copilot", "google-gemini-cli", - "google-antigravity", "minimax", "openai-codex", "xiaomi", diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 17ce3754c32..86c8213a8c2 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -338,7 +338,7 @@ describe("provider usage loading", () => { }); }); - it("loads snapshots for copilot antigravity gemini codex and xiaomi", async () => { + it("loads snapshots for copilot gemini codex and xiaomi", async () => { const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("api.github.com/copilot_internal/user")) { return makeResponse(200, { @@ -346,14 +346,6 @@ describe("provider usage loading", () => { copilot_plan: "Copilot Pro", }); } - if (url.includes("cloudcode-pa.googleapis.com/v1internal:loadCodeAssist")) { - return makeResponse(200, { - availablePromptCredits: 80, - planInfo: { monthlyPromptCredits: 100 }, - currentTier: { name: "Antigravity Pro" }, - cloudaicompanionProject: "projects/demo", - }); - } if (url.includes("cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels")) { return makeResponse(200, { models: { @@ -380,7 +372,6 @@ describe("provider usage loading", () => { const summary = await loadUsageWithAuth( [ { provider: "github-copilot", token: "copilot-token" }, - { provider: "google-antigravity", token: "antigravity-token" }, { provider: "google-gemini-cli", token: "gemini-token" }, { provider: "openai-codex", token: "codex-token", accountId: "acc-1" }, { provider: "xiaomi", token: "xiaomi-token" }, @@ -390,7 +381,6 @@ describe("provider usage loading", () => { expect(summary.providers.map((provider) => provider.provider)).toEqual([ "github-copilot", - "google-antigravity", "google-gemini-cli", "openai-codex", "xiaomi", @@ -398,10 +388,6 @@ describe("provider usage loading", () => { expect( summary.providers.find((provider) => provider.provider === "github-copilot")?.windows, ).toEqual([{ label: "Chat", usedPercent: 20 }]); - expect( - summary.providers.find((provider) => provider.provider === "google-antigravity")?.windows - .length, - ).toBeGreaterThan(0); expect( summary.providers.find((provider) => provider.provider === "google-gemini-cli")?.windows[0] ?.label, diff --git a/src/infra/provider-usage.types.ts b/src/infra/provider-usage.types.ts index 0a4637a7d47..af5e2e93c8b 100644 --- a/src/infra/provider-usage.types.ts +++ b/src/infra/provider-usage.types.ts @@ -21,7 +21,6 @@ export type UsageProviderId = | "anthropic" | "github-copilot" | "google-gemini-cli" - | "google-antigravity" | "minimax" | "openai-codex" | "xiaomi" diff --git a/src/plugins/enable.test.ts b/src/plugins/enable.test.ts index 5bdac4d1851..0934992b830 100644 --- a/src/plugins/enable.test.ts +++ b/src/plugins/enable.test.ts @@ -5,9 +5,9 @@ import { enablePluginInConfig } from "./enable.js"; describe("enablePluginInConfig", () => { it("enables a plugin entry", () => { const cfg: OpenClawConfig = {}; - const result = enablePluginInConfig(cfg, "google-antigravity-auth"); + const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); expect(result.enabled).toBe(true); - expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["google-gemini-cli-auth"]?.enabled).toBe(true); }); it("adds plugin to allowlist when allowlist is configured", () => { @@ -16,18 +16,18 @@ describe("enablePluginInConfig", () => { allow: ["memory-core"], }, }; - const result = enablePluginInConfig(cfg, "google-antigravity-auth"); + const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); expect(result.enabled).toBe(true); - expect(result.config.plugins?.allow).toEqual(["memory-core", "google-antigravity-auth"]); + expect(result.config.plugins?.allow).toEqual(["memory-core", "google-gemini-cli-auth"]); }); it("refuses enable when plugin is denylisted", () => { const cfg: OpenClawConfig = { plugins: { - deny: ["google-antigravity-auth"], + deny: ["google-gemini-cli-auth"], }, }; - const result = enablePluginInConfig(cfg, "google-antigravity-auth"); + const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); expect(result.enabled).toBe(false); expect(result.reason).toBe("blocked by denylist"); }); diff --git a/src/utils/provider-utils.ts b/src/utils/provider-utils.ts index a5544277201..211c515dc16 100644 --- a/src/utils/provider-utils.ts +++ b/src/utils/provider-utils.ts @@ -22,11 +22,6 @@ export function isReasoningTagProvider(provider: string | undefined | null): boo return true; } - // Handle google-antigravity and its model variations (e.g. google-antigravity/gemini-3) - if (normalized.includes("google-antigravity")) { - return true; - } - // Handle Minimax (M2.1 is chatty/reasoning-like) if (normalized.includes("minimax")) { return true; diff --git a/src/utils/utils-misc.test.ts b/src/utils/utils-misc.test.ts index 602db3f6f57..b7128ad2141 100644 --- a/src/utils/utils-misc.test.ts +++ b/src/utils/utils-misc.test.ts @@ -64,12 +64,6 @@ describe("isReasoningTagProvider", () => { value: "google-generative-ai", expected: true, }, - { name: "returns true for google-antigravity", value: "google-antigravity", expected: true }, - { - name: "returns true for google-antigravity model suffixes", - value: "google-antigravity/gemini-3", - expected: true, - }, { name: "returns true for minimax", value: "minimax", expected: true }, { name: "returns true for minimax-cn", value: "minimax-cn", expected: true }, { name: "returns false for null", value: null, expected: false }, From a53062ae3be7c1665a87e48ac6c7ad1cdaafdd8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 04:20:31 +0000 Subject: [PATCH 008/661] refactor(test): deduplicate isolated agent cron test helpers --- .../isolated-agent.delivery.test-helpers.ts | 29 +++++++++++++++++++ ...agent.direct-delivery-forum-topics.test.ts | 27 ++--------------- ...p-recipient-besteffortdeliver-true.test.ts | 28 +----------------- 3 files changed, 32 insertions(+), 52 deletions(-) create mode 100644 src/cron/isolated-agent.delivery.test-helpers.ts diff --git a/src/cron/isolated-agent.delivery.test-helpers.ts b/src/cron/isolated-agent.delivery.test-helpers.ts new file mode 100644 index 00000000000..be3bf03136c --- /dev/null +++ b/src/cron/isolated-agent.delivery.test-helpers.ts @@ -0,0 +1,29 @@ +import { vi } from "vitest"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import type { CliDeps } from "../cli/deps.js"; + +export function createCliDeps(overrides: Partial = {}): CliDeps { + return { + sendMessageSlack: vi.fn(), + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + ...overrides, + }; +} + +export function mockAgentPayloads( + payloads: Array>, + extra: Partial>> = {}, +): void { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads, + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + ...extra, + }); +} diff --git a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts index eda441b2001..a96ffdeb754 100644 --- a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts +++ b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts @@ -1,8 +1,7 @@ import "./isolated-agent.mocks.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { beforeEach, describe, expect, it } from "vitest"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; -import type { CliDeps } from "../cli/deps.js"; +import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, @@ -12,28 +11,6 @@ import { } from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; -function createCliDeps(overrides: Partial = {}): CliDeps { - return { - sendMessageSlack: vi.fn(), - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - ...overrides, - }; -} - -function mockAgentPayloads(payloads: Array>) { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads, - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); -} - describe("runCronIsolatedAgentTurn forum topic delivery", () => { beforeEach(() => { setupIsolatedAgentTurnMocks(); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 065e5aaa3c8..3e0a30d34f4 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -1,9 +1,9 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; +import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, @@ -13,32 +13,6 @@ import { } from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; -function createCliDeps(overrides: Partial = {}): CliDeps { - return { - sendMessageSlack: vi.fn(), - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - ...overrides, - }; -} - -function mockAgentPayloads( - payloads: Array>, - extra: Partial>> = {}, -): void { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads, - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - ...extra, - }); -} - async function runTelegramAnnounceTurn(params: { home: string; storePath: string; From 384a161bbc2eb482ae83aab57bdc6a9431fd2ae6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 04:24:45 +0000 Subject: [PATCH 009/661] test: consolidate media auto-detect coverage --- scripts/test-parallel.mjs | 1 - src/media-understanding/apply.test.ts | 144 ++++++++++++++++- test/media-understanding.auto.test.ts | 223 -------------------------- 3 files changed, 143 insertions(+), 225 deletions(-) delete mode 100644 test/media-understanding.auto.test.ts diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index bed23a431fd..5abfdf0fa11 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -43,7 +43,6 @@ const unitIsolatedFilesRaw = [ "src/agents/subagent-announce.format.test.ts", "src/infra/archive.test.ts", "src/cli/daemon-cli.coverage.test.ts", - "test/media-understanding.auto.test.ts", // Model normalization test imports config/model discovery stack; keep off unit-fast critical path. "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", // Auth profile rotation suite is retry-heavy and high-variance under vmForks contention. diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index c798cfb2876..3f627806506 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -6,6 +6,8 @@ import type { MsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { fetchRemoteMedia } from "../media/fetch.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { clearMediaUnderstandingBinaryCacheForTests } from "./runner.js"; vi.mock("../agents/model-auth.js", () => ({ resolveApiKeyForProvider: vi.fn(async () => ({ @@ -115,12 +117,38 @@ async function createTempMediaFile(params: { fileName: string; content: Buffer | return mediaPath; } +async function createMockExecutable(dir: string, name: string) { + const executablePath = path.join(dir, name); + await fs.writeFile(executablePath, "echo mocked\n", { mode: 0o755 }); + return executablePath; +} + +async function withMediaAutoDetectEnv( + env: Record, + run: () => Promise, +): Promise { + return await withEnvAsync( + { + SHERPA_ONNX_MODEL_DIR: undefined, + WHISPER_CPP_MODEL: undefined, + OPENAI_API_KEY: undefined, + GROQ_API_KEY: undefined, + DEEPGRAM_API_KEY: undefined, + GEMINI_API_KEY: undefined, + OPENCLAW_AGENT_DIR: undefined, + PI_CODING_AGENT_DIR: undefined, + ...env, + }, + run, + ); +} + async function createAudioCtx(params?: { body?: string; fileName?: string; mediaType?: string; content?: Buffer | string; -}) { +}): Promise { const mediaPath = await createTempMediaFile({ fileName: params?.fileName ?? "note.ogg", content: params?.content ?? Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]), @@ -179,6 +207,7 @@ describe("applyMediaUnderstanding", () => { contentType: "audio/ogg", fileName: "note.ogg", }); + clearMediaUnderstandingBinaryCacheForTests(); }); afterAll(async () => { @@ -357,6 +386,119 @@ describe("applyMediaUnderstanding", () => { expect(ctx.Body).toBe("[Audio]\nTranscript:\ncli transcript"); }); + it("auto-detects sherpa for audio when binary and model files are available", async () => { + const binDir = await createTempMediaDir(); + const modelDir = await createTempMediaDir(); + await createMockExecutable(binDir, "sherpa-onnx-offline"); + await fs.writeFile(path.join(modelDir, "tokens.txt"), "a"); + await fs.writeFile(path.join(modelDir, "encoder.onnx"), "a"); + await fs.writeFile(path.join(modelDir, "decoder.onnx"), "a"); + await fs.writeFile(path.join(modelDir, "joiner.onnx"), "a"); + + const ctx = await createAudioCtx({ + fileName: "sample.wav", + mediaType: "audio/wav", + content: "audio", + }); + const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; + + const execModule = await import("../process/exec.js"); + const mockedRunExec = vi.mocked(execModule.runExec); + mockedRunExec.mockResolvedValueOnce({ + stdout: '{"text":"sherpa ok"}', + stderr: "", + }); + + await withMediaAutoDetectEnv( + { + PATH: binDir, + SHERPA_ONNX_MODEL_DIR: modelDir, + }, + async () => { + const result = await applyMediaUnderstanding({ ctx, cfg }); + expect(result.appliedAudio).toBe(true); + }, + ); + + expect(ctx.Transcript).toBe("sherpa ok"); + expect(mockedRunExec).toHaveBeenCalledWith( + "sherpa-onnx-offline", + expect.any(Array), + expect.any(Object), + ); + }); + + it("auto-detects whisper-cli when sherpa is unavailable", async () => { + const binDir = await createTempMediaDir(); + const modelDir = await createTempMediaDir(); + await createMockExecutable(binDir, "whisper-cli"); + const modelPath = path.join(modelDir, "tiny.bin"); + await fs.writeFile(modelPath, "model"); + + const ctx = await createAudioCtx({ + fileName: "sample.wav", + mediaType: "audio/wav", + content: "audio", + }); + const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; + + const execModule = await import("../process/exec.js"); + const mockedRunExec = vi.mocked(execModule.runExec); + mockedRunExec.mockResolvedValueOnce({ + stdout: "whisper cpp ok\n", + stderr: "", + }); + + await withMediaAutoDetectEnv( + { + PATH: binDir, + WHISPER_CPP_MODEL: modelPath, + }, + async () => { + const result = await applyMediaUnderstanding({ ctx, cfg }); + expect(result.appliedAudio).toBe(true); + }, + ); + + expect(ctx.Transcript).toBe("whisper cpp ok"); + expect(mockedRunExec).toHaveBeenCalledWith( + "whisper-cli", + expect.any(Array), + expect.any(Object), + ); + }); + + it("skips audio auto-detect when no supported binaries or provider keys are available", async () => { + const emptyBinDir = await createTempMediaDir(); + const isolatedAgentDir = await createTempMediaDir(); + const ctx = await createAudioCtx({ + fileName: "sample.wav", + mediaType: "audio/wav", + content: "audio", + }); + const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; + + const execModule = await import("../process/exec.js"); + const mockedRunExec = vi.mocked(execModule.runExec); + mockedRunExec.mockReset(); + + await withMediaAutoDetectEnv( + { + PATH: emptyBinDir, + OPENCLAW_AGENT_DIR: isolatedAgentDir, + PI_CODING_AGENT_DIR: isolatedAgentDir, + }, + async () => { + const result = await applyMediaUnderstanding({ ctx, cfg }); + expect(result.appliedAudio).toBe(false); + }, + ); + + expect(ctx.Transcript).toBeUndefined(); + expect(ctx.Body).toBe(""); + expect(mockedRunExec).not.toHaveBeenCalled(); + }); + it("uses CLI image understanding and preserves caption for commands", async () => { const imagePath = await createTempMediaFile({ fileName: "photo.jpg", diff --git a/test/media-understanding.auto.test.ts b/test/media-understanding.auto.test.ts deleted file mode 100644 index 99358115dfe..00000000000 --- a/test/media-understanding.auto.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { MsgContext } from "../src/auto-reply/templating.js"; -import type { OpenClawConfig } from "../src/config/config.js"; -import { resolvePreferredOpenClawTmpDir } from "../src/infra/tmp-openclaw-dir.js"; -import { applyMediaUnderstanding } from "../src/media-understanding/apply.js"; -import { clearMediaUnderstandingBinaryCacheForTests } from "../src/media-understanding/runner.js"; - -const makeTempDir = async (prefix: string) => { - const baseDir = resolvePreferredOpenClawTmpDir(); - await fs.mkdir(baseDir, { recursive: true }); - return await fs.mkdtemp(path.join(baseDir, prefix)); -}; - -const writeExecutable = async (dir: string, name: string, content: string) => { - const filePath = path.join(dir, name); - await fs.writeFile(filePath, content, { mode: 0o755 }); - return filePath; -}; - -const makeTempMedia = async (ext: string) => { - const dir = await makeTempDir("openclaw-media-e2e-"); - const filePath = path.join(dir, `sample${ext}`); - await fs.writeFile(filePath, "audio"); - return { dir, filePath }; -}; - -const envSnapshot = () => ({ - PATH: process.env.PATH, - SHERPA_ONNX_MODEL_DIR: process.env.SHERPA_ONNX_MODEL_DIR, - WHISPER_CPP_MODEL: process.env.WHISPER_CPP_MODEL, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - GROQ_API_KEY: process.env.GROQ_API_KEY, - DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY, - GEMINI_API_KEY: process.env.GEMINI_API_KEY, - OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR, - PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, -}); - -const restoreEnv = (snapshot: ReturnType) => { - const restoreEnvVar = (key: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreEnvVar("PATH", snapshot.PATH); - restoreEnvVar("SHERPA_ONNX_MODEL_DIR", snapshot.SHERPA_ONNX_MODEL_DIR); - restoreEnvVar("WHISPER_CPP_MODEL", snapshot.WHISPER_CPP_MODEL); - restoreEnvVar("OPENAI_API_KEY", snapshot.OPENAI_API_KEY); - restoreEnvVar("GROQ_API_KEY", snapshot.GROQ_API_KEY); - restoreEnvVar("DEEPGRAM_API_KEY", snapshot.DEEPGRAM_API_KEY); - restoreEnvVar("GEMINI_API_KEY", snapshot.GEMINI_API_KEY); - restoreEnvVar("OPENCLAW_AGENT_DIR", snapshot.OPENCLAW_AGENT_DIR); - restoreEnvVar("PI_CODING_AGENT_DIR", snapshot.PI_CODING_AGENT_DIR); -}; - -const withEnvSnapshot = async (run: () => Promise): Promise => { - const snapshot = envSnapshot(); - try { - return await run(); - } finally { - restoreEnv(snapshot); - } -}; - -const createTrackedTempDir = async (tempPaths: string[], prefix: string) => { - const dir = await makeTempDir(prefix); - tempPaths.push(dir); - return dir; -}; - -const createTrackedTempMedia = async (tempPaths: string[], ext: string) => { - const media = await makeTempMedia(ext); - tempPaths.push(media.dir); - return media.filePath; -}; - -describe("media understanding auto-detect (e2e)", () => { - let tempPaths: string[] = []; - - beforeEach(() => { - clearMediaUnderstandingBinaryCacheForTests(); - }); - - afterEach(async () => { - for (const p of tempPaths) { - await fs.rm(p, { recursive: true, force: true }).catch(() => {}); - } - tempPaths = []; - }); - - it.skipIf(process.platform === "win32")("uses sherpa-onnx-offline when available", async () => { - await withEnvSnapshot(async () => { - const binDir = await createTrackedTempDir(tempPaths, "openclaw-bin-sherpa-"); - const modelDir = await createTrackedTempDir(tempPaths, "openclaw-sherpa-model-"); - - await fs.writeFile(path.join(modelDir, "tokens.txt"), "a"); - await fs.writeFile(path.join(modelDir, "encoder.onnx"), "a"); - await fs.writeFile(path.join(modelDir, "decoder.onnx"), "a"); - await fs.writeFile(path.join(modelDir, "joiner.onnx"), "a"); - - await writeExecutable( - binDir, - "sherpa-onnx-offline", - `#!/usr/bin/env bash\necho "{\\"text\\":\\"sherpa ok\\"}"\n`, - ); - - process.env.PATH = `${binDir}:/usr/bin:/bin`; - process.env.SHERPA_ONNX_MODEL_DIR = modelDir; - - const filePath = await createTrackedTempMedia(tempPaths, ".wav"); - - const ctx: MsgContext = { - Body: "", - MediaPath: filePath, - MediaType: "audio/wav", - }; - const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; - - await applyMediaUnderstanding({ ctx, cfg }); - - expect(ctx.Transcript).toBe("sherpa ok"); - }); - }); - - it.skipIf(process.platform === "win32")("uses whisper-cli when sherpa is missing", async () => { - await withEnvSnapshot(async () => { - const binDir = await createTrackedTempDir(tempPaths, "openclaw-bin-whispercpp-"); - const modelDir = await createTrackedTempDir(tempPaths, "openclaw-whispercpp-model-"); - - const modelPath = path.join(modelDir, "tiny.bin"); - await fs.writeFile(modelPath, "model"); - - await writeExecutable( - binDir, - "whisper-cli", - "#!/usr/bin/env bash\n" + - 'out=""\n' + - 'prev=""\n' + - 'for arg in "$@"; do\n' + - ' if [ "$prev" = "-of" ]; then out="$arg"; break; fi\n' + - ' prev="$arg"\n' + - "done\n" + - 'if [ -n "$out" ]; then echo \'whisper cpp ok\' > "${out}.txt"; fi\n', - ); - - process.env.PATH = `${binDir}:/usr/bin:/bin`; - process.env.WHISPER_CPP_MODEL = modelPath; - - const filePath = await createTrackedTempMedia(tempPaths, ".wav"); - - const ctx: MsgContext = { - Body: "", - MediaPath: filePath, - MediaType: "audio/wav", - }; - const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; - - await applyMediaUnderstanding({ ctx, cfg }); - - expect(ctx.Transcript).toBe("whisper cpp ok"); - }); - }); - - it.skipIf(process.platform === "win32")("uses gemini CLI for images when available", async () => { - await withEnvSnapshot(async () => { - const binDir = await createTrackedTempDir(tempPaths, "openclaw-bin-gemini-"); - - await writeExecutable( - binDir, - "gemini", - `#!/usr/bin/env bash\necho '{"response":"gemini ok"}'\n`, - ); - - process.env.PATH = `${binDir}:/usr/bin:/bin`; - - const filePath = await createTrackedTempMedia(tempPaths, ".png"); - - const ctx: MsgContext = { - Body: "", - MediaPath: filePath, - MediaType: "image/png", - }; - const cfg: OpenClawConfig = { tools: { media: { image: {} } } }; - - await applyMediaUnderstanding({ ctx, cfg }); - - expect(ctx.Body).toContain("gemini ok"); - }); - }); - - it("skips auto-detect when no supported binaries are available", async () => { - await withEnvSnapshot(async () => { - const emptyBinDir = await createTrackedTempDir(tempPaths, "openclaw-bin-empty-"); - const isolatedAgentDir = await createTrackedTempDir(tempPaths, "openclaw-agent-empty-"); - process.env.PATH = emptyBinDir; - delete process.env.SHERPA_ONNX_MODEL_DIR; - delete process.env.WHISPER_CPP_MODEL; - delete process.env.OPENAI_API_KEY; - delete process.env.GROQ_API_KEY; - delete process.env.DEEPGRAM_API_KEY; - delete process.env.GEMINI_API_KEY; - process.env.OPENCLAW_AGENT_DIR = isolatedAgentDir; - process.env.PI_CODING_AGENT_DIR = isolatedAgentDir; - - const filePath = await createTrackedTempMedia(tempPaths, ".wav"); - const ctx: MsgContext = { - Body: "", - MediaPath: filePath, - MediaType: "audio/wav", - }; - const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; - - await applyMediaUnderstanding({ ctx, cfg }); - - expect(ctx.Transcript).toBeUndefined(); - expect(ctx.Body).toBe(""); - }); - }); -}); From a6a2a9276ec51e5d314e9e38850716f7005c8f0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 04:24:52 +0000 Subject: [PATCH 010/661] test: reduce exec timer test runtime --- src/process/exec.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 8f8ae5d2787..349f3f8c16d 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -60,12 +60,12 @@ describe("runCommandWithTimeout", () => { "clearInterval(ticker);", "process.exit(0);", "}", - "}, 180);", + "}, 60);", ].join(" "), ], { timeoutMs: 5_000, - noOutputTimeoutMs: 500, + noOutputTimeoutMs: 250, }, ); From 86a8b65e9d7e76f95ccae09d7942b5cd074aa8c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 04:44:42 +0000 Subject: [PATCH 011/661] test: consolidate redundant suites and speed up timers --- src/agents/live-model-filter.test.ts | 14 --- src/agents/model-catalog.recovery.test.ts | 21 ---- src/agents/model-compat.test.ts | 13 ++ src/agents/pi-tools-agent-config.test.ts | 1 - ...ch.firecrawl-api-key-normalization.test.ts | 61 --------- src/agents/tools/web-tools.fetch.test.ts | 38 +++++- src/process/child-process-bridge.test.ts | 108 ---------------- src/process/exec.test.ts | 116 +++++++++++++++++- src/slack/format.test.ts | 11 ++ src/slack/monitor/mrkdwn.test.ts | 12 -- 10 files changed, 171 insertions(+), 224 deletions(-) delete mode 100644 src/agents/live-model-filter.test.ts delete mode 100644 src/agents/model-catalog.recovery.test.ts delete mode 100644 src/agents/tools/web-fetch.firecrawl-api-key-normalization.test.ts delete mode 100644 src/process/child-process-bridge.test.ts delete mode 100644 src/slack/monitor/mrkdwn.test.ts diff --git a/src/agents/live-model-filter.test.ts b/src/agents/live-model-filter.test.ts deleted file mode 100644 index d0b2bca8edb..00000000000 --- a/src/agents/live-model-filter.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isModernModelRef } from "./live-model-filter.js"; - -describe("isModernModelRef", () => { - it("excludes opencode minimax variants from modern selection", () => { - expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.1" })).toBe(false); - expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); - }); - - it("keeps non-minimax opencode modern models", () => { - expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true); - expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); - }); -}); diff --git a/src/agents/model-catalog.recovery.test.ts b/src/agents/model-catalog.recovery.test.ts deleted file mode 100644 index 4a37e34910d..00000000000 --- a/src/agents/model-catalog.recovery.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadModelCatalog } from "./model-catalog.js"; -import { - installModelCatalogTestHooks, - mockCatalogImportFailThenRecover, -} from "./model-catalog.test-harness.js"; - -describe("loadModelCatalog e2e smoke", () => { - installModelCatalogTestHooks(); - - it("recovers after an import failure on the next load", async () => { - mockCatalogImportFailThenRecover(); - - const cfg = {} as OpenClawConfig; - expect(await loadModelCatalog({ config: cfg })).toEqual([]); - expect(await loadModelCatalog({ config: cfg })).toEqual([ - { id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }, - ]); - }); -}); diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 95b4a0eb25e..d6f3066aea8 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -1,5 +1,6 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { isModernModelRef } from "./live-model-filter.js"; import { normalizeModelCompat } from "./model-compat.js"; const baseModel = (): Model => @@ -46,3 +47,15 @@ describe("normalizeModelCompat", () => { ).toBe(false); }); }); + +describe("isModernModelRef", () => { + it("excludes opencode minimax variants from modern selection", () => { + expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.1" })).toBe(false); + expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); + }); + + it("keeps non-minimax opencode modern models", () => { + expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true); + expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); + }); +}); diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 96cac05019b..868f7bcdc22 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -616,7 +616,6 @@ describe("Agent-specific tool filtering", () => { const result = await execTool!.execute("call-implicit-sandbox-default", { command: "echo done", - yieldMs: 10, }); const details = result?.details as { status?: string } | undefined; expect(details?.status).toBe("completed"); diff --git a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.test.ts b/src/agents/tools/web-fetch.firecrawl-api-key-normalization.test.ts deleted file mode 100644 index dd477c2b08f..00000000000 --- a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; - -vi.mock("../../infra/net/fetch-guard.js", () => { - return { - fetchWithSsrFGuard: vi.fn(async () => { - throw new Error("network down"); - }), - }; -}); - -describe("web_fetch firecrawl apiKey normalization", () => { - const priorFetch = global.fetch; - - afterEach(() => { - global.fetch = priorFetch; - vi.restoreAllMocks(); - }); - - it("strips embedded CR/LF before sending Authorization header", async () => { - const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : ""; - expect(url).toContain("/v2/scrape"); - - const auth = (init?.headers as Record | undefined)?.Authorization; - expect(auth).toBe("Bearer firecrawl-test-key"); - - return new Response( - JSON.stringify({ - success: true, - data: { markdown: "ok", metadata: { title: "t" } }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - global.fetch = withFetchPreconnect(fetchSpy); - - const { createWebFetchTool } = await import("./web-tools.js"); - const tool = createWebFetchTool({ - config: { - tools: { - web: { - fetch: { - cacheTtlMinutes: 0, - firecrawl: { apiKey: "firecrawl-test-\r\nkey" }, - readability: false, - }, - }, - }, - }, - }); - - const result = await tool?.execute?.("call", { - url: "https://example.com", - extractMode: "text", - }); - expect(result?.details).toMatchObject({ extractor: "firecrawl" }); - expect(fetchSpy).toHaveBeenCalled(); - }); -}); diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index bea4e7762db..3d65120b5f6 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -91,8 +91,12 @@ function requestUrl(input: RequestInfo | URL): string { return ""; } -function installMockFetch(impl: (input: RequestInfo | URL) => Promise) { - const mockFetch = vi.fn(async (input: RequestInfo | URL) => await impl(input)); +function installMockFetch( + impl: (input: RequestInfo | URL, init?: RequestInit) => Promise, +) { + const mockFetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => await impl(input, init), + ); global.fetch = withFetchPreconnect(mockFetch); return mockFetch; } @@ -253,6 +257,36 @@ describe("web_fetch extraction fallbacks", () => { expect(details.text).toContain("firecrawl content"); }); + it("normalizes firecrawl Authorization header values", async () => { + const fetchSpy = installMockFetch((input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.includes("api.firecrawl.dev/v2/scrape")) { + return Promise.resolve(firecrawlResponse("firecrawl normalized")) as Promise; + } + return Promise.resolve( + htmlResponse("", url), + ) as Promise; + }); + + const tool = createFetchTool({ + firecrawl: { apiKey: "firecrawl-test-\r\nkey" }, + }); + + const result = await tool?.execute?.("call", { + url: "https://example.com/firecrawl", + extractMode: "text", + }); + + expect(result?.details).toMatchObject({ extractor: "firecrawl" }); + const firecrawlCall = fetchSpy.mock.calls.find((call) => + requestUrl(call[0]).includes("/v2/scrape"), + ); + expect(firecrawlCall).toBeTruthy(); + const init = firecrawlCall?.[1]; + const authHeader = new Headers(init?.headers).get("Authorization"); + expect(authHeader).toBe("Bearer firecrawl-test-key"); + }); + it("throws when readability is disabled and firecrawl is unavailable", async () => { installMockFetch( (input: RequestInfo | URL) => diff --git a/src/process/child-process-bridge.test.ts b/src/process/child-process-bridge.test.ts deleted file mode 100644 index 04ef5715c2e..00000000000 --- a/src/process/child-process-bridge.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; -import process from "node:process"; -import { afterEach, describe, expect, it } from "vitest"; -import { attachChildProcessBridge } from "./child-process-bridge.js"; - -const CHILD_READY_TIMEOUT_MS = 10_000; -const CHILD_EXIT_TIMEOUT_MS = 10_000; - -function waitForLine( - stream: NodeJS.ReadableStream, - timeoutMs = CHILD_READY_TIMEOUT_MS, -): Promise { - return new Promise((resolve, reject) => { - let buffer = ""; - - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("timeout waiting for line")); - }, timeoutMs); - - const onData = (chunk: Buffer | string): void => { - buffer += chunk.toString(); - const idx = buffer.indexOf("\n"); - if (idx >= 0) { - const line = buffer.slice(0, idx).trim(); - cleanup(); - resolve(line); - } - }; - - const onError = (err: unknown): void => { - cleanup(); - reject(err); - }; - - const cleanup = (): void => { - clearTimeout(timeout); - stream.off("data", onData); - stream.off("error", onError); - }; - - stream.on("data", onData); - stream.on("error", onError); - }); -} - -describe("attachChildProcessBridge", () => { - const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = []; - const detachments: Array<() => void> = []; - - afterEach(() => { - for (const detach of detachments) { - try { - detach(); - } catch { - // ignore - } - } - detachments.length = 0; - for (const child of children) { - try { - child.kill("SIGKILL"); - } catch { - // ignore - } - } - children.length = 0; - }); - - it("forwards SIGTERM to the wrapped child", async () => { - const childPath = path.resolve(process.cwd(), "test/fixtures/child-process-bridge/child.js"); - - const beforeSigterm = new Set(process.listeners("SIGTERM")); - const child = spawn(process.execPath, [childPath], { - stdio: ["ignore", "pipe", "inherit"], - env: process.env, - }); - const { detach } = attachChildProcessBridge(child); - detachments.push(detach); - children.push(child); - const afterSigterm = process.listeners("SIGTERM"); - const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener)); - - if (!child.stdout) { - throw new Error("expected stdout"); - } - const ready = await waitForLine(child.stdout); - expect(ready).toBe("ready"); - - // Simulate systemd sending SIGTERM to the parent process. - if (!addedSigterm) { - throw new Error("expected SIGTERM listener"); - } - addedSigterm("SIGTERM"); - - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error("timeout waiting for child exit")), - CHILD_EXIT_TIMEOUT_MS, - ); - child.once("exit", () => { - clearTimeout(timeout); - resolve(); - }); - }); - }, 8_000); -}); diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 349f3f8c16d..a3bfef87cb0 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -1,7 +1,52 @@ -import { describe, expect, it } from "vitest"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { afterEach, describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; +import { attachChildProcessBridge } from "./child-process-bridge.js"; import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; +const CHILD_READY_TIMEOUT_MS = 4_000; +const CHILD_EXIT_TIMEOUT_MS = 4_000; + +function waitForLine( + stream: NodeJS.ReadableStream, + timeoutMs = CHILD_READY_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("timeout waiting for line")); + }, timeoutMs); + + const onData = (chunk: Buffer | string): void => { + buffer += chunk.toString(); + const idx = buffer.indexOf("\n"); + if (idx >= 0) { + const line = buffer.slice(0, idx).trim(); + cleanup(); + resolve(line); + } + }; + + const onError = (err: unknown): void => { + cleanup(); + reject(err); + }; + + const cleanup = (): void => { + clearTimeout(timeout); + stream.off("data", onData); + stream.off("error", onError); + }; + + stream.on("data", onData); + stream.on("error", onError); + }); +} + describe("runCommandWithTimeout", () => { it("never enables shell execution (Windows cmd.exe injection hardening)", () => { expect( @@ -56,16 +101,16 @@ describe("runCommandWithTimeout", () => { "let count = 0;", 'const ticker = setInterval(() => { process.stdout.write(".");', "count += 1;", - "if (count === 4) {", + "if (count === 2) {", "clearInterval(ticker);", "process.exit(0);", "}", - "}, 60);", + "}, 40);", ].join(" "), ], { timeoutMs: 5_000, - noOutputTimeoutMs: 250, + noOutputTimeoutMs: 500, }, ); @@ -73,7 +118,7 @@ describe("runCommandWithTimeout", () => { expect(result.code ?? 0).toBe(0); expect(result.termination).toBe("exit"); expect(result.noOutputTimedOut).toBe(false); - expect(result.stdout.length).toBeGreaterThanOrEqual(5); + expect(result.stdout.length).toBeGreaterThanOrEqual(3); }); it("reports global timeout termination when overall timeout elapses", async () => { @@ -89,3 +134,64 @@ describe("runCommandWithTimeout", () => { expect(result.code).not.toBe(0); }); }); + +describe("attachChildProcessBridge", () => { + const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = []; + const detachments: Array<() => void> = []; + + afterEach(() => { + for (const detach of detachments) { + try { + detach(); + } catch { + // ignore + } + } + detachments.length = 0; + for (const child of children) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } + children.length = 0; + }); + + it("forwards SIGTERM to the wrapped child", async () => { + const childPath = path.resolve(process.cwd(), "test/fixtures/child-process-bridge/child.js"); + + const beforeSigterm = new Set(process.listeners("SIGTERM")); + const child = spawn(process.execPath, [childPath], { + stdio: ["ignore", "pipe", "inherit"], + env: process.env, + }); + const { detach } = attachChildProcessBridge(child); + detachments.push(detach); + children.push(child); + const afterSigterm = process.listeners("SIGTERM"); + const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener)); + + if (!child.stdout) { + throw new Error("expected stdout"); + } + const ready = await waitForLine(child.stdout); + expect(ready).toBe("ready"); + + if (!addedSigterm) { + throw new Error("expected SIGTERM listener"); + } + addedSigterm("SIGTERM"); + + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("timeout waiting for child exit")), + CHILD_EXIT_TIMEOUT_MS, + ); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + }); +}); diff --git a/src/slack/format.test.ts b/src/slack/format.test.ts index 2b44c63a4c1..bb2003e2cd4 100644 --- a/src/slack/format.test.ts +++ b/src/slack/format.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { markdownToSlackMrkdwn } from "./format.js"; +import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; describe("markdownToSlackMrkdwn", () => { it("handles core markdown formatting conversions", () => { @@ -57,3 +58,13 @@ describe("markdownToSlackMrkdwn", () => { ); }); }); + +describe("escapeSlackMrkdwn", () => { + it("returns plain text unchanged", () => { + expect(escapeSlackMrkdwn("heartbeat status ok")).toBe("heartbeat status ok"); + }); + + it("escapes slack and mrkdwn control characters", () => { + expect(escapeSlackMrkdwn("mode_*`~<&>\\")).toBe("mode\\_\\*\\`\\~<&>\\\\"); + }); +}); diff --git a/src/slack/monitor/mrkdwn.test.ts b/src/slack/monitor/mrkdwn.test.ts deleted file mode 100644 index 5efba875a57..00000000000 --- a/src/slack/monitor/mrkdwn.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { escapeSlackMrkdwn } from "./mrkdwn.js"; - -describe("escapeSlackMrkdwn", () => { - it("returns plain text unchanged", () => { - expect(escapeSlackMrkdwn("heartbeat status ok")).toBe("heartbeat status ok"); - }); - - it("escapes slack and mrkdwn control characters", () => { - expect(escapeSlackMrkdwn("mode_*`~<&>\\")).toBe("mode\\_\\*\\`\\~<&>\\\\"); - }); -}); From 48f327c2069b1c0c4f1c0efa4f406f3ca008d15a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 04:55:43 +0000 Subject: [PATCH 012/661] test: consolidate redundant suites and speed attachment tests --- extensions/msteams/src/attachments.test.ts | 28 ++------- src/agents/live-auth-keys.test.ts | 35 ----------- src/agents/model-compat.test.ts | 57 ++++++++++++++++++ src/agents/model-fallback.test.ts | 34 +++++++++++ src/agents/model-forward-compat.test.ts | 59 ------------------- .../tools/web-fetch.response-limit.test.ts | 34 ----------- src/agents/tools/web-tools.fetch.test.ts | 23 ++++++++ 7 files changed, 118 insertions(+), 152 deletions(-) delete mode 100644 src/agents/live-auth-keys.test.ts delete mode 100644 src/agents/model-forward-compat.test.ts delete mode 100644 src/agents/tools/web-fetch.response-limit.test.ts diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 66ea8b9babd..9590727e407 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,5 +1,9 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { downloadMSTeamsAttachments } from "./attachments/download.js"; +import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js"; +import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js"; +import { buildMSTeamsMediaPayload } from "./attachments/payload.js"; import { setMSTeamsRuntime } from "./runtime.js"; /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ @@ -49,10 +53,6 @@ const runtimeStub = { } as unknown as PluginRuntime; describe("msteams attachments", () => { - const load = async () => { - return await import("./attachments.js"); - }; - beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); @@ -62,13 +62,11 @@ describe("msteams attachments", () => { describe("buildMSTeamsAttachmentPlaceholder", () => { it("returns empty string when no attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe(""); expect(buildMSTeamsAttachmentPlaceholder([])).toBe(""); }); it("returns image placeholder for image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "image/png", contentUrl: "https://x/img.png" }, @@ -83,7 +81,6 @@ describe("msteams attachments", () => { }); it("treats Teams file.download.info image attachments as images", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { @@ -95,7 +92,6 @@ describe("msteams attachments", () => { }); it("returns document placeholder for non-image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, @@ -110,7 +106,6 @@ describe("msteams attachments", () => { }); it("counts inline images in text/html attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { @@ -132,7 +127,6 @@ describe("msteams attachments", () => { describe("downloadMSTeamsAttachments", () => { it("downloads and stores image contentUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, @@ -155,7 +149,6 @@ describe("msteams attachments", () => { }); it("supports Teams file.download.info downloadUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, @@ -181,7 +174,6 @@ describe("msteams attachments", () => { }); it("downloads non-image file attachments (PDF)", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("pdf"), { status: 200, @@ -209,7 +201,6 @@ describe("msteams attachments", () => { }); it("downloads inline image URLs from html attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, @@ -235,7 +226,6 @@ describe("msteams attachments", () => { }); it("stores inline data:image base64 payloads", async () => { - const { downloadMSTeamsAttachments } = await load(); const base64 = Buffer.from("png").toString("base64"); const media = await downloadMSTeamsAttachments({ attachments: [ @@ -253,7 +243,6 @@ describe("msteams attachments", () => { }); it("retries with auth when the first request is unauthorized", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); const hasAuth = Boolean(headers.get("Authorization")); @@ -281,7 +270,6 @@ describe("msteams attachments", () => { }); it("skips auth retries when the host is not in auth allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); @@ -313,7 +301,6 @@ describe("msteams attachments", () => { }); it("skips urls outside the allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); const media = await downloadMSTeamsAttachments({ attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], @@ -329,7 +316,6 @@ describe("msteams attachments", () => { describe("buildMSTeamsGraphMessageUrls", () => { it("builds channel message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", conversationId: "19:thread@thread.tacv2", @@ -340,7 +326,6 @@ describe("msteams attachments", () => { }); it("builds channel reply urls when replyToId is present", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", messageId: "reply-id", @@ -353,7 +338,6 @@ describe("msteams attachments", () => { }); it("builds chat message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "groupChat", conversationId: "19:chat@thread.v2", @@ -365,7 +349,6 @@ describe("msteams attachments", () => { describe("downloadMSTeamsGraphMedia", () => { it("downloads hostedContents images", async () => { - const { downloadMSTeamsGraphMedia } = await load(); const base64 = Buffer.from("png").toString("base64"); const fetchMock = vi.fn(async (url: string) => { if (url.endsWith("/hostedContents")) { @@ -401,7 +384,6 @@ describe("msteams attachments", () => { }); it("merges SharePoint reference attachments with hosted content", async () => { - const { downloadMSTeamsGraphMedia } = await load(); const hostedBase64 = Buffer.from("png").toString("base64"); const shareUrl = "https://contoso.sharepoint.com/site/file"; const fetchMock = vi.fn(async (url: string) => { @@ -469,7 +451,6 @@ describe("msteams attachments", () => { }); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { - const { downloadMSTeamsGraphMedia } = await load(); const shareUrl = "https://contoso.sharepoint.com/site/file"; const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { @@ -553,7 +534,6 @@ describe("msteams attachments", () => { describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { - const { buildMSTeamsMediaPayload } = await load(); const payload = buildMSTeamsMediaPayload([ { path: "/tmp/a.png", contentType: "image/png" }, { path: "/tmp/b.png", contentType: "image/png" }, diff --git a/src/agents/live-auth-keys.test.ts b/src/agents/live-auth-keys.test.ts deleted file mode 100644 index 4c889598276..00000000000 --- a/src/agents/live-auth-keys.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isAnthropicBillingError } from "./live-auth-keys.js"; - -describe("isAnthropicBillingError", () => { - it("does not false-positive on plain 'a 402' prose", () => { - const samples = [ - "Use a 402 stainless bolt", - "Book a 402 room", - "There is a 402 near me", - "The building at 402 Main Street", - ]; - - for (const sample of samples) { - expect(isAnthropicBillingError(sample)).toBe(false); - } - }); - - it("matches real 402 billing payload contexts including JSON keys", () => { - const samples = [ - "HTTP 402 Payment Required", - "status: 402", - "error code 402", - '{"status":402,"type":"error"}', - '{"code":402,"message":"payment required"}', - '{"error":{"code":402,"message":"billing hard limit reached"}}', - "got a 402 from the API", - "returned 402", - "received a 402 response", - ]; - - for (const sample of samples) { - expect(isAnthropicBillingError(sample)).toBe(true); - } - }); -}); diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index d6f3066aea8..071f9cc9276 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -2,6 +2,8 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; import { isModernModelRef } from "./live-model-filter.js"; import { normalizeModelCompat } from "./model-compat.js"; +import { resolveForwardCompatModel } from "./model-forward-compat.js"; +import type { ModelRegistry } from "./pi-model-discovery.js"; const baseModel = (): Model => ({ @@ -17,6 +19,28 @@ const baseModel = (): Model => maxTokens: 1024, }) as Model; +function createTemplateModel(provider: string, id: string): Model { + return { + id, + name: id, + provider, + api: "anthropic-messages", + input: ["text"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + } as Model; +} + +function createRegistry(models: Record>): ModelRegistry { + return { + find(provider: string, modelId: string) { + return models[`${provider}/${modelId}`] ?? null; + }, + } as ModelRegistry; +} + describe("normalizeModelCompat", () => { it("forces supportsDeveloperRole off for z.ai models", () => { const model = baseModel(); @@ -59,3 +83,36 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); }); }); + +describe("resolveForwardCompatModel", () => { + it("resolves anthropic opus 4.6 via 4.5 template", () => { + const registry = createRegistry({ + "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), + }); + const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry); + expect(model?.id).toBe("claude-opus-4-6"); + expect(model?.name).toBe("claude-opus-4-6"); + expect(model?.provider).toBe("anthropic"); + }); + + it("resolves anthropic sonnet 4.6 dot variant with suffix", () => { + const registry = createRegistry({ + "anthropic/claude-sonnet-4.5-20260219": createTemplateModel( + "anthropic", + "claude-sonnet-4.5-20260219", + ), + }); + const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry); + expect(model?.id).toBe("claude-sonnet-4.6-20260219"); + expect(model?.name).toBe("claude-sonnet-4.6-20260219"); + expect(model?.provider).toBe("anthropic"); + }); + + it("does not resolve anthropic 4.6 fallback for other providers", () => { + const registry = createRegistry({ + "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), + }); + const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry); + expect(model).toBeUndefined(); + }); +}); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index add5560ea24..75fca258ef6 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { saveAuthProfileStore } from "./auth-profiles.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; +import { isAnthropicBillingError } from "./live-auth-keys.js"; import { runWithModelFallback } from "./model-fallback.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; @@ -656,3 +657,36 @@ describe("runWithModelFallback", () => { expect(result.model).toBe("gpt-4.1-mini"); }); }); + +describe("isAnthropicBillingError", () => { + it("does not false-positive on plain 'a 402' prose", () => { + const samples = [ + "Use a 402 stainless bolt", + "Book a 402 room", + "There is a 402 near me", + "The building at 402 Main Street", + ]; + + for (const sample of samples) { + expect(isAnthropicBillingError(sample)).toBe(false); + } + }); + + it("matches real 402 billing payload contexts including JSON keys", () => { + const samples = [ + "HTTP 402 Payment Required", + "status: 402", + "error code 402", + '{"status":402,"type":"error"}', + '{"code":402,"message":"payment required"}', + '{"error":{"code":402,"message":"billing hard limit reached"}}', + "got a 402 from the API", + "returned 402", + "received a 402 response", + ]; + + for (const sample of samples) { + expect(isAnthropicBillingError(sample)).toBe(true); + } + }); +}); diff --git a/src/agents/model-forward-compat.test.ts b/src/agents/model-forward-compat.test.ts deleted file mode 100644 index b2017213e0b..00000000000 --- a/src/agents/model-forward-compat.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; -import { describe, expect, it } from "vitest"; -import { resolveForwardCompatModel } from "./model-forward-compat.js"; -import type { ModelRegistry } from "./pi-model-discovery.js"; - -function createTemplateModel(provider: string, id: string): Model { - return { - id, - name: id, - provider, - api: "anthropic-messages", - input: ["text"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - } as Model; -} - -function createRegistry(models: Record>): ModelRegistry { - return { - find(provider: string, modelId: string) { - return models[`${provider}/${modelId}`] ?? null; - }, - } as ModelRegistry; -} - -describe("agents/model-forward-compat", () => { - it("resolves anthropic opus 4.6 via 4.5 template", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry); - expect(model?.id).toBe("claude-opus-4-6"); - expect(model?.name).toBe("claude-opus-4-6"); - expect(model?.provider).toBe("anthropic"); - }); - - it("resolves anthropic sonnet 4.6 dot variant with suffix", () => { - const registry = createRegistry({ - "anthropic/claude-sonnet-4.5-20260219": createTemplateModel( - "anthropic", - "claude-sonnet-4.5-20260219", - ), - }); - const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry); - expect(model?.id).toBe("claude-sonnet-4.6-20260219"); - expect(model?.name).toBe("claude-sonnet-4.6-20260219"); - expect(model?.provider).toBe("anthropic"); - }); - - it("does not resolve anthropic 4.6 fallback for other providers", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry); - expect(model).toBeUndefined(); - }); -}); diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts deleted file mode 100644 index 9b246b8a67b..00000000000 --- a/src/agents/tools/web-fetch.response-limit.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; -import { - createBaseWebFetchToolConfig, - installWebFetchSsrfHarness, -} from "./web-fetch.test-harness.js"; -import "./web-fetch.test-mocks.js"; -import { createWebFetchTool } from "./web-tools.js"; - -const baseToolConfig = createBaseWebFetchToolConfig({ maxResponseBytes: 1024 }); -installWebFetchSsrfHarness(); - -describe("web_fetch response size limits", () => { - it("caps response bytes and does not hang on endless streams", async () => { - const chunk = new TextEncoder().encode("
hi
"); - const stream = new ReadableStream({ - pull(controller) { - controller.enqueue(chunk); - }, - }); - const response = new Response(stream, { - status: 200, - headers: { "content-type": "text/html; charset=utf-8" }, - }); - - const fetchSpy = vi.fn().mockResolvedValue(response); - global.fetch = withFetchPreconnect(fetchSpy); - - const tool = createWebFetchTool(baseToolConfig); - const result = await tool?.execute?.("call", { url: "https://example.com/stream" }); - const details = result?.details as { warning?: string } | undefined; - expect(details?.warning).toContain("Response body truncated"); - }); -}); diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index 3d65120b5f6..df82a2ae204 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -233,6 +233,29 @@ describe("web_fetch extraction fallbacks", () => { expect(details.truncated).toBe(true); }); + it("caps response bytes and does not hang on endless streams", async () => { + const chunk = new TextEncoder().encode("
hi
"); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + }); + const response = new Response(stream, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(response); + global.fetch = withFetchPreconnect(fetchSpy); + + const tool = createFetchTool({ + maxResponseBytes: 1024, + firecrawl: { enabled: false }, + }); + const result = await tool?.execute?.("call", { url: "https://example.com/stream" }); + const details = result?.details as { warning?: string } | undefined; + expect(details?.warning).toContain("Response body truncated"); + }); + // NOTE: Test for wrapping url/finalUrl/warning fields requires DNS mocking. // The sanitization of these fields is verified by external-content.test.ts tests. From 610863e7330181aeea2a092410a664cac0bb83f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 05:03:15 +0000 Subject: [PATCH 013/661] test: speed up long-running async suites --- .../bash-tools.exec.background-abort.test.ts | 6 +-- ...subagents.sessions-spawn.lifecycle.test.ts | 53 ++++++++++--------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/agents/bash-tools.exec.background-abort.test.ts b/src/agents/bash-tools.exec.background-abort.test.ts index 0e312e64687..71faf23b690 100644 --- a/src/agents/bash-tools.exec.background-abort.test.ts +++ b/src/agents/bash-tools.exec.background-abort.test.ts @@ -8,11 +8,11 @@ import { createExecTool } from "./bash-tools.exec.js"; import { killProcessTree } from "./shell-utils.js"; const BACKGROUND_HOLD_CMD = 'node -e "setTimeout(() => {}, 5000)"'; -const ABORT_SETTLE_MS = process.platform === "win32" ? 200 : 40; +const ABORT_SETTLE_MS = process.platform === "win32" ? 200 : 25; const ABORT_WAIT_TIMEOUT_MS = process.platform === "win32" ? 1_500 : 240; const POLL_INTERVAL_MS = 15; const FINISHED_WAIT_TIMEOUT_MS = process.platform === "win32" ? 8_000 : 600; -const BACKGROUND_TIMEOUT_SEC = process.platform === "win32" ? 0.2 : 0.08; +const BACKGROUND_TIMEOUT_SEC = process.platform === "win32" ? 0.2 : 0.05; const TEST_EXEC_DEFAULTS = { security: "full" as const, ask: "off" as const, @@ -151,7 +151,7 @@ test("background exec without explicit timeout ignores default timeout", async ( const result = await tool.execute("toolcall", { command: BACKGROUND_HOLD_CMD, background: true }); expect(result.details.status).toBe("running"); const sessionId = (result.details as { sessionId: string }).sessionId; - const waitMs = Math.max(ABORT_SETTLE_MS + 120, BACKGROUND_TIMEOUT_SEC * 1000 + 120); + const waitMs = Math.max(ABORT_SETTLE_MS + 80, BACKGROUND_TIMEOUT_SEC * 1000 + 80); const startedAt = Date.now(); await expect diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 041684af6b1..5a883c7c6c4 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -10,6 +10,12 @@ import { } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; +const fastModeEnv = vi.hoisted(() => { + const previous = process.env.OPENCLAW_TEST_FAST; + process.env.OPENCLAW_TEST_FAST = "1"; + return { previous }; +}); + vi.mock("./pi-embedded.js", () => ({ isEmbeddedPiRunActive: () => false, isEmbeddedPiRunStreaming: () => false, @@ -17,6 +23,10 @@ vi.mock("./pi-embedded.js", () => ({ waitForEmbeddedPiRunEnd: async () => true, })); +vi.mock("./tools/agent-step.js", () => ({ + readLatestAssistantReply: async () => "done", +})); + const callGatewayMock = getCallGatewayMock(); const RUN_TIMEOUT_SECONDS = 1; @@ -93,13 +103,7 @@ async function emitLifecycleEndAndFlush(params: { } describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { - let previousFastTestEnv: string | undefined; - beforeEach(() => { - if (previousFastTestEnv === undefined) { - previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; - } - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); resetSessionsSpawnConfigOverride(); setSessionsSpawnConfigOverride({ session: { @@ -117,11 +121,11 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }); afterAll(() => { - if (previousFastTestEnv === undefined) { + if (fastModeEnv.previous === undefined) { delete process.env.OPENCLAW_TEST_FAST; return; } - process.env.OPENCLAW_TEST_FAST = previousFastTestEnv; + process.env.OPENCLAW_TEST_FAST = fastModeEnv.previous; }); it("sessions_spawn runs cleanup flow after subagent completion", async () => { @@ -151,19 +155,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { if (!child.runId) { throw new Error("missing child runId"); } - emitAgentEvent({ - runId: child.runId, - stream: "lifecycle", - data: { - phase: "end", - startedAt: 1000, - endedAt: 2000, - }, - }); - - await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); - await waitFor(() => patchCalls.some((call) => call.label === "my-task")); - await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2); + await waitFor( + () => + ctx.waitCalls.some((call) => call.runId === child.runId) && + patchCalls.some((call) => call.label === "my-task") && + ctx.calls.filter((call) => call.method === "agent").length >= 2, + ); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); @@ -216,8 +213,9 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { endedAt: 2345, }); - await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); - await waitFor(() => Boolean(deletedKey)); + await waitFor( + () => ctx.calls.filter((call) => call.method === "agent").length >= 2 && Boolean(deletedKey), + ); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); @@ -277,9 +275,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { if (!child.runId) { throw new Error("missing child runId"); } - await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); - await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); - await waitFor(() => Boolean(deletedKey)); + await waitFor( + () => + ctx.waitCalls.some((call) => call.runId === child.runId) && + ctx.calls.filter((call) => call.method === "agent").length >= 2 && + Boolean(deletedKey), + ); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); From 77c3b142a96631b1be411fb7032f61d2d74d6f5e Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:05:42 -0600 Subject: [PATCH 014/661] Web UI: add full cron edit parity, all-jobs run history, and compact filters (openclaw#24155) thanks @Takhoffman Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/web/control-ui.md | 5 +- src/cron/run-log.ts | 219 ++- src/cron/service.ts | 4 + src/cron/service/ops.ts | 99 +- src/gateway/protocol/cron-validators.test.ts | 49 + src/gateway/protocol/schema/cron.ts | 83 +- src/gateway/server-methods/cron.ts | 81 +- src/gateway/server.cron.test.ts | 11 + test/ui.presenter-next-run.test.ts | 17 + ui/src/styles/components.css | 435 ++++++ ui/src/ui/app-defaults.ts | 8 + ui/src/ui/app-render.ts | 149 +- ui/src/ui/app-settings.ts | 20 +- ui/src/ui/app-view-state.ts | 31 + ui/src/ui/app.ts | 24 + ui/src/ui/controllers/cron.test.ts | 409 ++++- ui/src/ui/controllers/cron.ts | 584 +++++++- ui/src/ui/presenter.ts | 3 +- ui/src/ui/types.ts | 46 +- ui/src/ui/ui-types.ts | 8 + ui/src/ui/views/cron.test.ts | 435 +++++- ui/src/ui/views/cron.ts | 1392 ++++++++++++++---- 23 files changed, 3769 insertions(+), 344 deletions(-) create mode 100644 test/ui.presenter-next-run.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d07e9868b63..79a16b88f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. - Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. - Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. - CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9ff05572ca0..ebaad5aef90 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device --role `. See - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) -- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) +- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) - Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) @@ -85,6 +85,9 @@ Cron jobs panel notes: - Channel/target fields appear when announce is selected. - Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. - For main-session jobs, webhook and none delivery modes are available. +- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, + agent model/thinking overrides, and best-effort delivery toggles. +- Form validation is inline with field-level errors; invalid values disable the save button until fixed. - Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. - Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index 3dd5c279091..426c4279a21 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -19,6 +19,35 @@ export type CronRunLogEntry = { nextRunAtMs?: number; } & CronRunTelemetry; +export type CronRunLogSortDir = "asc" | "desc"; +export type CronRunLogStatusFilter = "all" | "ok" | "error" | "skipped"; + +export type ReadCronRunLogPageOptions = { + limit?: number; + offset?: number; + jobId?: string; + status?: CronRunLogStatusFilter; + statuses?: CronRunStatus[]; + deliveryStatus?: CronDeliveryStatus; + deliveryStatuses?: CronDeliveryStatus[]; + query?: string; + sortDir?: CronRunLogSortDir; +}; + +export type CronRunLogPageResult = { + entries: CronRunLogEntry[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; + nextOffset: number | null; +}; + +type ReadCronRunLogAllPageOptions = Omit & { + storePath: string; + jobNameById?: Record; +}; + function assertSafeCronRunLogJobId(jobId: string): string { const trimmed = jobId.trim(); if (!trimmed) { @@ -98,14 +127,78 @@ export async function readCronRunLogEntries( opts?: { limit?: number; jobId?: string }, ): Promise { const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200))); + const page = await readCronRunLogEntriesPage(filePath, { + jobId: opts?.jobId, + limit, + offset: 0, + status: "all", + sortDir: "desc", + }); + return page.entries.toReversed(); +} + +function normalizeRunStatusFilter(status?: string): CronRunLogStatusFilter { + if (status === "ok" || status === "error" || status === "skipped" || status === "all") { + return status; + } + return "all"; +} + +function normalizeRunStatuses(opts?: { + statuses?: CronRunStatus[]; + status?: CronRunLogStatusFilter; +}): CronRunStatus[] | null { + if (Array.isArray(opts?.statuses) && opts.statuses.length > 0) { + const filtered = opts.statuses.filter( + (status): status is CronRunStatus => + status === "ok" || status === "error" || status === "skipped", + ); + if (filtered.length > 0) { + return Array.from(new Set(filtered)); + } + } + const status = normalizeRunStatusFilter(opts?.status); + if (status === "all") { + return null; + } + return [status]; +} + +function normalizeDeliveryStatuses(opts?: { + deliveryStatuses?: CronDeliveryStatus[]; + deliveryStatus?: CronDeliveryStatus; +}): CronDeliveryStatus[] | null { + if (Array.isArray(opts?.deliveryStatuses) && opts.deliveryStatuses.length > 0) { + const filtered = opts.deliveryStatuses.filter( + (status): status is CronDeliveryStatus => + status === "delivered" || + status === "not-delivered" || + status === "unknown" || + status === "not-requested", + ); + if (filtered.length > 0) { + return Array.from(new Set(filtered)); + } + } + if ( + opts?.deliveryStatus === "delivered" || + opts?.deliveryStatus === "not-delivered" || + opts?.deliveryStatus === "unknown" || + opts?.deliveryStatus === "not-requested" + ) { + return [opts.deliveryStatus]; + } + return null; +} + +function parseAllRunLogEntries(raw: string, opts?: { jobId?: string }): CronRunLogEntry[] { const jobId = opts?.jobId?.trim() || undefined; - const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => ""); if (!raw.trim()) { return []; } const parsed: CronRunLogEntry[] = []; const lines = raw.split("\n"); - for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) { + for (let i = 0; i < lines.length; i++) { const line = lines[i]?.trim(); if (!line) { continue; @@ -182,5 +275,125 @@ export async function readCronRunLogEntries( // ignore invalid lines } } - return parsed.toReversed(); + return parsed; +} + +export async function readCronRunLogEntriesPage( + filePath: string, + opts?: ReadCronRunLogPageOptions, +): Promise { + const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? 50))); + const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => ""); + const statuses = normalizeRunStatuses(opts); + const deliveryStatuses = normalizeDeliveryStatuses(opts); + const query = opts?.query?.trim().toLowerCase() ?? ""; + const sortDir: CronRunLogSortDir = opts?.sortDir === "asc" ? "asc" : "desc"; + const all = parseAllRunLogEntries(raw, { jobId: opts?.jobId }); + const filtered = all.filter((entry) => { + if (statuses && (!entry.status || !statuses.includes(entry.status))) { + return false; + } + if (deliveryStatuses) { + const deliveryStatus = entry.deliveryStatus ?? "not-requested"; + if (!deliveryStatuses.includes(deliveryStatus)) { + return false; + } + } + if (!query) { + return true; + } + const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId].join(" ").toLowerCase(); + return haystack.includes(query); + }); + const sorted = + sortDir === "asc" + ? filtered.toSorted((a, b) => a.ts - b.ts) + : filtered.toSorted((a, b) => b.ts - a.ts); + const total = sorted.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0))); + const entries = sorted.slice(offset, offset + limit); + const nextOffset = offset + entries.length; + return { + entries, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + }; +} + +export async function readCronRunLogEntriesPageAll( + opts: ReadCronRunLogAllPageOptions, +): Promise { + const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 50))); + const statuses = normalizeRunStatuses(opts); + const deliveryStatuses = normalizeDeliveryStatuses(opts); + const query = opts.query?.trim().toLowerCase() ?? ""; + const sortDir: CronRunLogSortDir = opts.sortDir === "asc" ? "asc" : "desc"; + const runsDir = path.resolve(path.dirname(path.resolve(opts.storePath)), "runs"); + const files = await fs.readdir(runsDir, { withFileTypes: true }).catch(() => []); + const jsonlFiles = files + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => path.join(runsDir, entry.name)); + if (jsonlFiles.length === 0) { + return { + entries: [], + total: 0, + offset: 0, + limit, + hasMore: false, + nextOffset: null, + }; + } + const chunks = await Promise.all( + jsonlFiles.map(async (filePath) => { + const raw = await fs.readFile(filePath, "utf-8").catch(() => ""); + return parseAllRunLogEntries(raw); + }), + ); + const all = chunks.flat(); + const filtered = all.filter((entry) => { + if (statuses && (!entry.status || !statuses.includes(entry.status))) { + return false; + } + if (deliveryStatuses) { + const deliveryStatus = entry.deliveryStatus ?? "not-requested"; + if (!deliveryStatuses.includes(deliveryStatus)) { + return false; + } + } + if (!query) { + return true; + } + const jobName = opts.jobNameById?.[entry.jobId] ?? ""; + const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId, jobName] + .join(" ") + .toLowerCase(); + return haystack.includes(query); + }); + const sorted = + sortDir === "asc" + ? filtered.toSorted((a, b) => a.ts - b.ts) + : filtered.toSorted((a, b) => b.ts - a.ts); + const total = sorted.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts.offset ?? 0))); + const entries = sorted.slice(offset, offset + limit); + if (opts.jobNameById) { + for (const entry of entries) { + const jobName = opts.jobNameById[entry.jobId]; + if (jobName) { + (entry as CronRunLogEntry & { jobName?: string }).jobName = jobName; + } + } + } + const nextOffset = offset + entries.length; + return { + entries, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + }; } diff --git a/src/cron/service.ts b/src/cron/service.ts index 50d5f40b6e2..7ccc1cc59e0 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -26,6 +26,10 @@ export class CronService { return await ops.list(this.state, opts); } + async listPage(opts?: ops.CronListPageOptions) { + return await ops.listPage(this.state, opts); + } + async add(input: CronJobCreate) { return await ops.add(this.state, input); } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 68789790207..ca2f8d1a946 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -1,4 +1,4 @@ -import type { CronJobCreate, CronJobPatch } from "../types.js"; +import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js"; import { applyJobPatch, computeJobNextRunAtMs, @@ -22,6 +22,29 @@ import { wake, } from "./timer.js"; +type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; +type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; +type CronSortDir = "asc" | "desc"; + +export type CronListPageOptions = { + includeDisabled?: boolean; + limit?: number; + offset?: number; + query?: string; + enabled?: CronJobsEnabledFilter; + sortBy?: CronJobsSortBy; + sortDir?: CronSortDir; +}; + +export type CronListPageResult = { + jobs: ReturnType; + total: number; + offset: number; + limit: number; + hasMore: boolean; + nextOffset: number | null; +}; + async function ensureLoadedForRead(state: CronServiceState) { await ensureLoaded(state, { skipRecompute: true }); if (!state.store) { @@ -101,6 +124,80 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b }); } +function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter { + if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") { + return opts.enabled; + } + return opts?.includeDisabled ? "all" : "enabled"; +} + +function sortJobs(jobs: CronJob[], sortBy: CronJobsSortBy, sortDir: CronSortDir) { + const dir = sortDir === "desc" ? -1 : 1; + return jobs.toSorted((a, b) => { + let cmp = 0; + if (sortBy === "name") { + cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + } else if (sortBy === "updatedAtMs") { + cmp = a.updatedAtMs - b.updatedAtMs; + } else { + const aNext = a.state.nextRunAtMs; + const bNext = b.state.nextRunAtMs; + if (typeof aNext === "number" && typeof bNext === "number") { + cmp = aNext - bNext; + } else if (typeof aNext === "number") { + cmp = -1; + } else if (typeof bNext === "number") { + cmp = 1; + } else { + cmp = 0; + } + } + if (cmp !== 0) { + return cmp * dir; + } + return a.id.localeCompare(b.id); + }); +} + +export async function listPage(state: CronServiceState, opts?: CronListPageOptions) { + return await locked(state, async () => { + await ensureLoadedForRead(state); + const query = opts?.query?.trim().toLowerCase() ?? ""; + const enabledFilter = resolveEnabledFilter(opts); + const sortBy = opts?.sortBy ?? "nextRunAtMs"; + const sortDir = opts?.sortDir ?? "asc"; + const source = state.store?.jobs ?? []; + const filtered = source.filter((job) => { + if (enabledFilter === "enabled" && !job.enabled) { + return false; + } + if (enabledFilter === "disabled" && job.enabled) { + return false; + } + if (!query) { + return true; + } + const haystack = [job.name, job.description ?? "", job.agentId ?? ""].join(" ").toLowerCase(); + return haystack.includes(query); + }); + const sorted = sortJobs(filtered, sortBy, sortDir); + const total = sorted.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0))); + const defaultLimit = total === 0 ? 50 : total; + const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit))); + const jobs = sorted.slice(offset, offset + limit); + const nextOffset = offset + jobs.length; + return { + jobs, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + } satisfies CronListPageResult; + }); +} + export async function add(state: CronServiceState, input: CronJobCreate) { return await locked(state, async () => { warnIfDisabled(state, "add"); diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts index e3e9de03e13..33df9d478e9 100644 --- a/src/gateway/protocol/cron-validators.test.ts +++ b/src/gateway/protocol/cron-validators.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { validateCronAddParams, + validateCronListParams, validateCronRemoveParams, validateCronRunParams, validateCronRunsParams, @@ -40,6 +41,21 @@ describe("cron protocol validators", () => { expect(validateCronRunParams({ jobId: "job-2", mode: "due" })).toBe(true); }); + it("accepts list paging/filter/sort params", () => { + expect( + validateCronListParams({ + includeDisabled: true, + limit: 50, + offset: 0, + query: "daily", + enabled: "all", + sortBy: "nextRunAtMs", + sortDir: "asc", + }), + ).toBe(true); + expect(validateCronListParams({ offset: -1 })).toBe(false); + }); + it("enforces runs limit minimum for id and jobId selectors", () => { expect(validateCronRunsParams({ id: "job-1", limit: 1 })).toBe(true); expect(validateCronRunsParams({ jobId: "job-2", limit: 1 })).toBe(true); @@ -53,4 +69,37 @@ describe("cron protocol validators", () => { expect(validateCronRunsParams({ jobId: "..\\job-2" })).toBe(false); expect(validateCronRunsParams({ jobId: "nested\\job-2" })).toBe(false); }); + + it("accepts runs paging/filter/sort params", () => { + expect( + validateCronRunsParams({ + id: "job-1", + limit: 50, + offset: 0, + status: "error", + query: "timeout", + sortDir: "desc", + }), + ).toBe(true); + expect(validateCronRunsParams({ id: "job-1", offset: -1 })).toBe(false); + }); + + it("accepts all-scope runs with multi-select filters", () => { + expect( + validateCronRunsParams({ + scope: "all", + limit: 25, + statuses: ["ok", "error"], + deliveryStatuses: ["delivered", "not-requested"], + query: "fail", + sortDir: "desc", + }), + ).toBe(true); + expect( + validateCronRunsParams({ + scope: "job", + statuses: [], + }), + ).toBe(false); + }); }); diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 6f74ff24ea9..dae3b340d7e 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -26,6 +26,28 @@ const CronRunStatusSchema = Type.Union([ Type.Literal("error"), Type.Literal("skipped"), ]); +const CronSortDirSchema = Type.Union([Type.Literal("asc"), Type.Literal("desc")]); +const CronJobsEnabledFilterSchema = Type.Union([ + Type.Literal("all"), + Type.Literal("enabled"), + Type.Literal("disabled"), +]); +const CronJobsSortBySchema = Type.Union([ + Type.Literal("nextRunAtMs"), + Type.Literal("updatedAtMs"), + Type.Literal("name"), +]); +const CronRunsStatusFilterSchema = Type.Union([ + Type.Literal("all"), + Type.Literal("ok"), + Type.Literal("error"), + Type.Literal("skipped"), +]); +const CronRunsStatusValueSchema = Type.Union([ + Type.Literal("ok"), + Type.Literal("error"), + Type.Literal("skipped"), +]); const CronDeliveryStatusSchema = Type.Union([ Type.Literal("delivered"), Type.Literal("not-delivered"), @@ -65,25 +87,6 @@ const CronRunLogJobIdSchema = Type.String({ pattern: "^[^/\\\\]+$", }); -function cronRunsIdOrJobIdParams(extraFields: Record) { - return Type.Union([ - Type.Object( - { - id: CronRunLogJobIdSchema, - ...extraFields, - }, - { additionalProperties: false }, - ), - Type.Object( - { - jobId: CronRunLogJobIdSchema, - ...extraFields, - }, - { additionalProperties: false }, - ), - ]); -} - export const CronScheduleSchema = Type.Union([ Type.Object( { @@ -223,6 +226,12 @@ export const CronJobSchema = Type.Object( export const CronListParamsSchema = Type.Object( { includeDisabled: Type.Optional(Type.Boolean()), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })), + offset: Type.Optional(Type.Integer({ minimum: 0 })), + query: Type.Optional(Type.String()), + enabled: Type.Optional(CronJobsEnabledFilterSchema), + sortBy: Type.Optional(CronJobsSortBySchema), + sortDir: Type.Optional(CronSortDirSchema), }, { additionalProperties: false }, ); @@ -266,9 +275,24 @@ export const CronRunParamsSchema = cronIdOrJobIdParams({ mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])), }); -export const CronRunsParamsSchema = cronRunsIdOrJobIdParams({ - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })), -}); +export const CronRunsParamsSchema = Type.Object( + { + scope: Type.Optional(Type.Union([Type.Literal("job"), Type.Literal("all")])), + id: Type.Optional(CronRunLogJobIdSchema), + jobId: Type.Optional(CronRunLogJobIdSchema), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })), + offset: Type.Optional(Type.Integer({ minimum: 0 })), + statuses: Type.Optional(Type.Array(CronRunsStatusValueSchema, { minItems: 1, maxItems: 3 })), + status: Type.Optional(CronRunsStatusFilterSchema), + deliveryStatuses: Type.Optional( + Type.Array(CronDeliveryStatusSchema, { minItems: 1, maxItems: 4 }), + ), + deliveryStatus: Type.Optional(CronDeliveryStatusSchema), + query: Type.Optional(Type.String()), + sortDir: Type.Optional(CronSortDirSchema), + }, + { additionalProperties: false }, +); export const CronRunLogEntrySchema = Type.Object( { @@ -286,6 +310,21 @@ export const CronRunLogEntrySchema = Type.Object( runAtMs: Type.Optional(Type.Integer({ minimum: 0 })), durationMs: Type.Optional(Type.Integer({ minimum: 0 })), nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + model: Type.Optional(Type.String()), + provider: Type.Optional(Type.String()), + usage: Type.Optional( + Type.Object( + { + input_tokens: Type.Optional(Type.Number()), + output_tokens: Type.Optional(Type.Number()), + total_tokens: Type.Optional(Type.Number()), + cache_read_tokens: Type.Optional(Type.Number()), + cache_write_tokens: Type.Optional(Type.Number()), + }, + { additionalProperties: false }, + ), + ), + jobName: Type.Optional(Type.String()), }, { additionalProperties: false }, ); diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 0f73184c47c..dd6bfc42e77 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -1,5 +1,9 @@ import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; -import { readCronRunLogEntries, resolveCronRunLogPath } from "../../cron/run-log.js"; +import { + readCronRunLogEntriesPage, + readCronRunLogEntriesPageAll, + resolveCronRunLogPath, +} from "../../cron/run-log.js"; import type { CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; import { @@ -49,11 +53,25 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const p = params as { includeDisabled?: boolean }; - const jobs = await context.cron.list({ + const p = params as { + includeDisabled?: boolean; + limit?: number; + offset?: number; + query?: string; + enabled?: "all" | "enabled" | "disabled"; + sortBy?: "nextRunAtMs" | "updatedAtMs" | "name"; + sortDir?: "asc" | "desc"; + }; + const page = await context.cron.listPage({ includeDisabled: p.includeDisabled, + limit: p.limit, + offset: p.offset, + query: p.query, + enabled: p.enabled, + sortBy: p.sortBy, + sortDir: p.sortDir, }); - respond(true, { jobs }, undefined); + respond(true, page, undefined); }, "cron.status": async ({ params, respond, context }) => { if (!validateCronStatusParams(params)) { @@ -204,9 +222,23 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const p = params as { id?: string; jobId?: string; limit?: number }; + const p = params as { + scope?: "job" | "all"; + id?: string; + jobId?: string; + limit?: number; + offset?: number; + statuses?: Array<"ok" | "error" | "skipped">; + status?: "all" | "ok" | "error" | "skipped"; + deliveryStatuses?: Array<"delivered" | "not-delivered" | "unknown" | "not-requested">; + deliveryStatus?: "delivered" | "not-delivered" | "unknown" | "not-requested"; + query?: string; + sortDir?: "asc" | "desc"; + }; + const explicitScope = p.scope; const jobId = p.id ?? p.jobId; - if (!jobId) { + const scope: "job" | "all" = explicitScope ?? (jobId ? "job" : "all"); + if (scope === "job" && !jobId) { respond( false, undefined, @@ -214,11 +246,33 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } + if (scope === "all") { + const jobs = await context.cron.list({ includeDisabled: true }); + const jobNameById = Object.fromEntries( + jobs + .filter((job) => typeof job.id === "string" && typeof job.name === "string") + .map((job) => [job.id, job.name]), + ); + const page = await readCronRunLogEntriesPageAll({ + storePath: context.cronStorePath, + limit: p.limit, + offset: p.offset, + statuses: p.statuses, + status: p.status, + deliveryStatuses: p.deliveryStatuses, + deliveryStatus: p.deliveryStatus, + query: p.query, + sortDir: p.sortDir, + jobNameById, + }); + respond(true, page, undefined); + return; + } let logPath: string; try { logPath = resolveCronRunLogPath({ storePath: context.cronStorePath, - jobId, + jobId: jobId as string, }); } catch { respond( @@ -228,10 +282,17 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const entries = await readCronRunLogEntries(logPath, { + const page = await readCronRunLogEntriesPage(logPath, { limit: p.limit, - jobId, + offset: p.offset, + jobId: jobId as string, + statuses: p.statuses, + status: p.status, + deliveryStatuses: p.deliveryStatuses, + deliveryStatus: p.deliveryStatus, + query: p.query, + sortDir: p.sortDir, }); - respond(true, { entries }, undefined); + respond(true, page, undefined); }, }; diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index ed924f7920e..94d6afbae5e 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -424,6 +424,17 @@ describe("gateway server cron", () => { expect((entries as Array<{ deliveryStatus?: unknown }>).at(-1)?.deliveryStatus).toBe( "not-requested", ); + const allRunsRes = await rpcReq(ws, "cron.runs", { + scope: "all", + limit: 50, + statuses: ["ok"], + }); + expect(allRunsRes.ok).toBe(true); + const allEntries = (allRunsRes.payload as { entries?: unknown } | null)?.entries; + expect(Array.isArray(allEntries)).toBe(true); + expect( + (allEntries as Array<{ jobId?: unknown }>).some((entry) => entry.jobId === jobId), + ).toBe(true); const statusRes = await rpcReq(ws, "cron.status", {}); expect(statusRes.ok).toBe(true); diff --git a/test/ui.presenter-next-run.test.ts b/test/ui.presenter-next-run.test.ts new file mode 100644 index 00000000000..12c2ed4d80d --- /dev/null +++ b/test/ui.presenter-next-run.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { formatNextRun } from "../ui/src/ui/presenter.ts"; + +describe("formatNextRun", () => { + it("returns n/a for nullish values", () => { + expect(formatNextRun(null)).toBe("n/a"); + expect(formatNextRun(undefined)).toBe("n/a"); + }); + + it("includes weekday and relative time", () => { + const ts = Date.UTC(2026, 1, 23, 15, 0, 0); + const out = formatNextRun(ts); + expect(out).toMatch(/^[A-Za-z]{3}, /); + expect(out).toContain("("); + expect(out).toContain(")"); + }); +}); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 09b89d9c270..428f5f9a9d5 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -526,6 +526,441 @@ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } +/* =========================================== + Cron Form + =========================================== */ + +.cron-summary-strip { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px 18px; + padding: 14px 16px; +} + +.cron-summary-strip__left { + display: grid; + gap: 8px 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + flex: 1 1 auto; + min-width: 0; +} + +.cron-summary-item { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + padding: 10px 12px; + min-height: 62px; + display: grid; + gap: 6px; +} + +.cron-summary-item--wide { + grid-column: span 1; +} + +.cron-summary-label { + color: var(--muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.cron-summary-value { + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + line-height: 1.3; + display: flex; + align-items: center; + gap: 8px; +} + +.cron-summary-strip__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; +} + +.cron-workspace { + margin-top: 16px; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); + gap: 16px; + align-items: start; +} + +.cron-workspace-main { + display: grid; + gap: 16px; +} + +.cron-workspace-form { + position: sticky; + top: 74px; +} + +.cron-form { + margin-top: 16px; + display: grid; + gap: 14px; +} + +.cron-form-section { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 14px; + background: var(--bg-elevated); + display: grid; + gap: 12px; +} + +.cron-form-section__title { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--text-strong); +} + +.cron-form-section__sub { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.cron-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 16px; +} + +.cron-help { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + margin-top: 2px; +} + +.cron-error { + color: var(--danger-color); +} + +.cron-required-legend { + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.cron-required-marker { + color: var(--danger-color); + font-weight: 700; + margin-left: 3px; +} + +.cron-required-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.field input[aria-invalid="true"], +.field textarea[aria-invalid="true"], +.field select[aria-invalid="true"] { + border-color: var(--danger); + box-shadow: + inset 0 1px 0 var(--card-highlight), + 0 0 0 1px rgba(239, 68, 68, 0.2); +} + +.cron-form-status { + margin-top: 4px; + border: 1px solid var(--danger-subtle); + background: var(--danger-subtle); + border-radius: var(--radius-md); + padding: 10px 12px; +} + +.cron-form-status__title { + color: var(--text-strong); + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.cron-form-status__list { + margin: 8px 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 6px; +} + +.cron-form-status__link { + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + font-size: 12px; + line-height: 1.4; + padding: 0; + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; +} + +.cron-form-status__link:hover { + color: var(--text-strong); +} + +.cron-span-2 { + grid-column: 1 / -1; +} + +.cron-checkbox { + align-items: center; + grid-template-columns: 16px minmax(0, 1fr); + column-gap: 10px; +} + +.cron-checkbox input[type="checkbox"] { + margin: 2px 0 0; + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.cron-checkbox .field-checkbox__label { + color: var(--text-strong); + font-size: 13px; + font-weight: 500; +} + +.cron-checkbox .cron-help { + grid-column: 2; +} + +.cron-checkbox-inline { + align-content: start; + align-items: start; + padding-top: 28px; +} + +.cron-advanced { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + background: var(--bg-elevated); + display: grid; + gap: 10px; +} + +.cron-advanced__summary { + cursor: pointer; + color: var(--muted); + font-size: 13px; + font-weight: 500; +} + +.cron-stagger-group { + display: grid; + grid-template-columns: minmax(0, 1fr) 180px; + gap: 14px 16px; + align-items: start; +} + +.cron-form-actions { + margin-top: 14px; + justify-content: flex-start; + align-items: center; + gap: 10px 14px; + flex-wrap: wrap; +} + +.cron-submit-reason { + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.cron-filter-search { + flex: 1 1 320px; + min-width: 280px; +} + +.cron-workspace .filters .field { + min-width: 160px; +} + +.cron-run-filters { + margin-top: 12px; + display: grid; + gap: 12px; +} + +.cron-run-filters__row { + display: grid; + gap: 12px; +} + +.cron-run-filters__row--primary { + grid-template-columns: minmax(160px, 220px) minmax(240px, 1fr) minmax(160px, 220px); +} + +.cron-run-filters__row--secondary { + grid-template-columns: repeat(2, minmax(220px, 1fr)); +} + +.cron-run-filter-search { + min-width: 0; +} + +.cron-filter-dropdown { + min-width: 0; +} + +.cron-filter-dropdown__details { + position: relative; +} + +.cron-filter-dropdown__details > summary { + list-style: none; +} + +.cron-filter-dropdown__details > summary::-webkit-details-marker { + display: none; +} + +.cron-filter-dropdown__trigger { + width: 100%; + justify-content: space-between; + text-align: left; +} + +.cron-filter-dropdown__panel { + position: absolute; + z-index: 30; + top: calc(100% + 8px); + left: 0; + width: min(360px, calc(100vw - 48px)); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + padding: 10px; + display: grid; + gap: 10px; + box-shadow: var(--shadow-card); +} + +.cron-filter-dropdown__list { + display: grid; + gap: 6px; +} + +.cron-filter-dropdown__option { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + gap: 8px; + align-items: center; + color: var(--text); + font-size: 13px; +} + +.cron-filter-dropdown__option input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); +} + +.cron-run-entry { + align-items: start; +} + +.cron-run-entry__meta { + text-align: right; + min-width: 220px; +} + +.cron-run-entry__summary { + white-space: pre-wrap; + line-height: 1.45; +} + +@media (max-width: 1100px) { + .cron-summary-strip { + flex-direction: column; + } + + .cron-summary-strip__left { + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .cron-summary-strip__actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } + + .cron-workspace { + grid-template-columns: 1fr; + } + + .cron-workspace-form { + position: static; + order: -1; + } + + .cron-form-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .cron-span-2 { + grid-column: auto; + } + + .cron-checkbox-inline { + padding-top: 0; + } + + .cron-stagger-group { + grid-template-columns: 1fr; + gap: 12px; + } + + .cron-filter-search { + min-width: 0; + flex: 1 1 100%; + } + + .cron-run-filters__row--primary, + .cron-run-filters__row--secondary { + grid-template-columns: 1fr; + } + + .cron-filter-dropdown__panel { + width: 100%; + max-width: none; + position: static; + margin-top: 8px; + } + + .cron-run-entry__meta { + min-width: 0; + text-align: left; + } +} + :root[data-theme="light"] .field input, :root[data-theme="light"] .field textarea, :root[data-theme="light"] .field select { diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index 89bdaf11d1b..ba8edc45106 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -14,19 +14,27 @@ export const DEFAULT_CRON_FORM: CronFormState = { name: "", description: "", agentId: "", + clearAgent: false, enabled: true, + deleteAfterRun: true, scheduleKind: "every", scheduleAt: "", everyAmount: "30", everyUnit: "minutes", cronExpr: "0 7 * * *", cronTz: "", + scheduleExact: false, + staggerAmount: "", + staggerUnit: "seconds", sessionTarget: "isolated", wakeMode: "now", payloadKind: "agentTurn", payloadText: "", + payloadModel: "", + payloadThinking: "", deliveryMode: "announce", deliveryChannel: "last", deliveryTo: "", + deliveryBestEffort: false, timeoutSeconds: "", }; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8e441e9dcdc..56b266fb17b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -21,11 +21,21 @@ import { } from "./controllers/config.ts"; import { loadCronRuns, + loadMoreCronJobs, + loadMoreCronRuns, + reloadCronJobs, toggleCronJob, runCronJob, removeCronJob, addCronJob, + startCronEdit, + startCronClone, + cancelCronEdit, + validateCronForm, + hasCronFormErrors, normalizeCronFormState, + updateCronJobsFilter, + updateCronRunsFilter, } from "./controllers/cron.ts"; import { loadDebug, callDebugMethod } from "./controllers/debug.ts"; import { @@ -71,6 +81,43 @@ import { renderSkills } from "./views/skills.ts"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; +const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; +const CRON_TIMEZONE_SUGGESTIONS = [ + "UTC", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "Europe/London", + "Europe/Berlin", + "Asia/Tokyo", +]; + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value.trim()); +} + +function normalizeSuggestionValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function uniquePreserveOrder(values: string[]): string[] { + const seen = new Set(); + const output: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized) { + continue; + } + const key = normalized.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; @@ -106,6 +153,56 @@ export function renderApp(state: AppViewState) { state.agentsList?.defaultId ?? state.agentsList?.agents?.[0]?.id ?? null; + const cronAgentSuggestions = Array.from( + new Set( + [ + ...(state.agentsList?.agents?.map((entry) => entry.id.trim()) ?? []), + ...state.cronJobs + .map((job) => (typeof job.agentId === "string" ? job.agentId.trim() : "")) + .filter(Boolean), + ].filter(Boolean), + ), + ).toSorted((a, b) => a.localeCompare(b)); + const cronModelSuggestions = Array.from( + new Set( + [ + ...state.cronModelSuggestions, + ...state.cronJobs + .map((job) => { + if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") { + return ""; + } + return job.payload.model.trim(); + }) + .filter(Boolean), + ].filter(Boolean), + ), + ).toSorted((a, b) => a.localeCompare(b)); + const selectedDeliveryChannel = + state.cronForm.deliveryChannel && state.cronForm.deliveryChannel.trim() + ? state.cronForm.deliveryChannel.trim() + : "last"; + const jobToSuggestions = state.cronJobs + .map((job) => normalizeSuggestionValue(job.delivery?.to)) + .filter(Boolean); + const accountToSuggestions = ( + selectedDeliveryChannel === "last" + ? Object.values(state.channelsSnapshot?.channelAccounts ?? {}).flat() + : (state.channelsSnapshot?.channelAccounts?.[selectedDeliveryChannel] ?? []) + ) + .flatMap((account) => [ + normalizeSuggestionValue(account.accountId), + normalizeSuggestionValue(account.name), + ]) + .filter(Boolean); + const rawDeliveryToSuggestions = uniquePreserveOrder([ + ...jobToSuggestions, + ...accountToSuggestions, + ]); + const deliveryToSuggestions = + state.cronForm.deliveryMode === "webhook" + ? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value)) + : rawDeliveryToSuggestions; return html`
@@ -327,11 +424,21 @@ export function renderApp(state: AppViewState) { ? renderCron({ basePath: state.basePath, loading: state.cronLoading, + jobsLoadingMore: state.cronJobsLoadingMore, status: state.cronStatus, jobs: state.cronJobs, + jobsTotal: state.cronJobsTotal, + jobsHasMore: state.cronJobsHasMore, + jobsQuery: state.cronJobsQuery, + jobsEnabledFilter: state.cronJobsEnabledFilter, + jobsSortBy: state.cronJobsSortBy, + jobsSortDir: state.cronJobsSortDir, error: state.cronError, busy: state.cronBusy, form: state.cronForm, + fieldErrors: state.cronFieldErrors, + canSubmit: !hasCronFormErrors(state.cronFieldErrors), + editingJobId: state.cronEditingJobId, channels: state.channelsSnapshot?.channelMeta?.length ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) : (state.channelsSnapshot?.channelOrder ?? []), @@ -339,14 +446,50 @@ export function renderApp(state: AppViewState) { channelMeta: state.channelsSnapshot?.channelMeta ?? [], runsJobId: state.cronRunsJobId, runs: state.cronRuns, - onFormChange: (patch) => - (state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch })), + runsTotal: state.cronRunsTotal, + runsHasMore: state.cronRunsHasMore, + runsLoadingMore: state.cronRunsLoadingMore, + runsScope: state.cronRunsScope, + runsStatuses: state.cronRunsStatuses, + runsDeliveryStatuses: state.cronRunsDeliveryStatuses, + runsStatusFilter: state.cronRunsStatusFilter, + runsQuery: state.cronRunsQuery, + runsSortDir: state.cronRunsSortDir, + agentSuggestions: cronAgentSuggestions, + modelSuggestions: cronModelSuggestions, + thinkingSuggestions: CRON_THINKING_SUGGESTIONS, + timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS, + deliveryToSuggestions, + onFormChange: (patch) => { + state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch }); + state.cronFieldErrors = validateCronForm(state.cronForm); + }, onRefresh: () => state.loadCron(), onAdd: () => addCronJob(state), + onEdit: (job) => startCronEdit(state, job), + onClone: (job) => startCronClone(state, job), + onCancelEdit: () => cancelCronEdit(state), onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onRun: (job) => runCronJob(state, job), onRemove: (job) => removeCronJob(state, job), - onLoadRuns: (jobId) => loadCronRuns(state, jobId), + onLoadRuns: async (jobId) => { + updateCronRunsFilter(state, { cronRunsScope: "job" }); + await loadCronRuns(state, jobId); + }, + onLoadMoreJobs: () => loadMoreCronJobs(state), + onJobsFiltersChange: async (patch) => { + updateCronJobsFilter(state, patch); + await reloadCronJobs(state); + }, + onLoadMoreRuns: () => loadMoreCronRuns(state), + onRunsFiltersChange: async (patch) => { + updateCronRunsFilter(state, patch); + if (state.cronRunsScope === "all") { + await loadCronRuns(state, null); + return; + } + await loadCronRuns(state, state.cronRunsJobId); + }, }) : nothing } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 7415e468e0b..5828a13ea9b 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -12,7 +12,12 @@ import { loadAgentSkills } from "./controllers/agent-skills.ts"; import { loadAgents } from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadConfig, loadConfigSchema } from "./controllers/config.ts"; -import { loadCronJobs, loadCronStatus } from "./controllers/cron.ts"; +import { + loadCronJobs, + loadCronModelSuggestions, + loadCronRuns, + loadCronStatus, +} from "./controllers/cron.ts"; import { loadDebug } from "./controllers/debug.ts"; import { loadDevices } from "./controllers/devices.ts"; import { loadExecApprovals } from "./controllers/exec-approvals.ts"; @@ -421,9 +426,18 @@ export async function loadChannelsTab(host: SettingsHost) { } export async function loadCron(host: SettingsHost) { + const cronHost = host as unknown as OpenClawApp; await Promise.all([ loadChannels(host as unknown as OpenClawApp, false), - loadCronStatus(host as unknown as OpenClawApp), - loadCronJobs(host as unknown as OpenClawApp), + loadCronStatus(cronHost), + loadCronJobs(cronHost), + loadCronModelSuggestions(cronHost), ]); + if (cronHost.cronRunsScope === "all") { + await loadCronRuns(cronHost, null); + return; + } + if (cronHost.cronRunsJobId) { + await loadCronRuns(cronHost, cronHost.cronRunsJobId); + } } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index e8fcad4de74..1dcc0abeec6 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -1,5 +1,6 @@ import type { EventLogEntry } from "./app-events.ts"; import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts"; +import type { CronFieldErrors } from "./controllers/cron.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; @@ -17,6 +18,13 @@ import type { ConfigSnapshot, ConfigUiHints, CronJob, + CronJobsEnabledFilter, + CronJobsSortBy, + CronDeliveryStatus, + CronRunScope, + CronSortDir, + CronRunsStatusValue, + CronRunsStatusFilter, CronRunLogEntry, CronStatus, HealthSnapshot, @@ -187,12 +195,35 @@ export type AppViewState = { usageLogFilterHasTools: boolean; usageLogFilterQuery: string; cronLoading: boolean; + cronJobsLoadingMore: boolean; cronJobs: CronJob[]; + cronJobsTotal: number; + cronJobsHasMore: boolean; + cronJobsNextOffset: number | null; + cronJobsLimit: number; + cronJobsQuery: string; + cronJobsEnabledFilter: CronJobsEnabledFilter; + cronJobsSortBy: CronJobsSortBy; + cronJobsSortDir: CronSortDir; cronStatus: CronStatus | null; cronError: string | null; cronForm: CronFormState; + cronFieldErrors: CronFieldErrors; + cronEditingJobId: string | null; cronRunsJobId: string | null; + cronRunsLoadingMore: boolean; cronRuns: CronRunLogEntry[]; + cronRunsTotal: number; + cronRunsHasMore: boolean; + cronRunsNextOffset: number | null; + cronRunsLimit: number; + cronRunsScope: CronRunScope; + cronRunsStatuses: CronRunsStatusValue[]; + cronRunsDeliveryStatuses: CronDeliveryStatus[]; + cronRunsStatusFilter: CronRunsStatusFilter; + cronRunsQuery: string; + cronRunsSortDir: CronSortDir; + cronModelSuggestions: string[]; cronBusy: boolean; skillsLoading: boolean; skillsReport: SkillStatusReport | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7d2cbd0e343..ae3e5e507e2 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -53,6 +53,7 @@ import { import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; +import type { CronFieldErrors } from "./controllers/cron.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; @@ -297,12 +298,35 @@ export class OpenClawApp extends LitElement { usageQueryDebounceTimer: number | null = null; @state() cronLoading = false; + @state() cronJobsLoadingMore = false; @state() cronJobs: CronJob[] = []; + @state() cronJobsTotal = 0; + @state() cronJobsHasMore = false; + @state() cronJobsNextOffset: number | null = null; + @state() cronJobsLimit = 50; + @state() cronJobsQuery = ""; + @state() cronJobsEnabledFilter: import("./types.js").CronJobsEnabledFilter = "all"; + @state() cronJobsSortBy: import("./types.js").CronJobsSortBy = "nextRunAtMs"; + @state() cronJobsSortDir: import("./types.js").CronSortDir = "asc"; @state() cronStatus: CronStatus | null = null; @state() cronError: string | null = null; @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM }; + @state() cronFieldErrors: CronFieldErrors = {}; + @state() cronEditingJobId: string | null = null; @state() cronRunsJobId: string | null = null; + @state() cronRunsLoadingMore = false; @state() cronRuns: CronRunLogEntry[] = []; + @state() cronRunsTotal = 0; + @state() cronRunsHasMore = false; + @state() cronRunsNextOffset: number | null = null; + @state() cronRunsLimit = 50; + @state() cronRunsScope: import("./types.js").CronRunScope = "all"; + @state() cronRunsStatuses: import("./types.js").CronRunsStatusValue[] = []; + @state() cronRunsDeliveryStatuses: import("./types.js").CronDeliveryStatus[] = []; + @state() cronRunsStatusFilter: import("./types.js").CronRunsStatusFilter = "all"; + @state() cronRunsQuery = ""; + @state() cronRunsSortDir: import("./types.js").CronSortDir = "desc"; + @state() cronModelSuggestions: string[] = []; @state() cronBusy = false; @state() updateAvailable: import("./types.js").UpdateAvailable | null = null; diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 66d05286a3b..ee2bab887cd 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -1,18 +1,51 @@ import { describe, expect, it, vi } from "vitest"; import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; -import { addCronJob, normalizeCronFormState, type CronState } from "./cron.ts"; +import { + addCronJob, + cancelCronEdit, + loadCronJobsPage, + loadCronRuns, + loadMoreCronRuns, + normalizeCronFormState, + startCronEdit, + startCronClone, + validateCronForm, + type CronState, +} from "./cron.ts"; function createState(overrides: Partial = {}): CronState { return { client: null, connected: true, cronLoading: false, + cronJobsLoadingMore: false, cronJobs: [], + cronJobsTotal: 0, + cronJobsHasMore: false, + cronJobsNextOffset: null, + cronJobsLimit: 50, + cronJobsQuery: "", + cronJobsEnabledFilter: "all", + cronJobsSortBy: "nextRunAtMs", + cronJobsSortDir: "asc", cronStatus: null, cronError: null, cronForm: { ...DEFAULT_CRON_FORM }, + cronFieldErrors: {}, + cronEditingJobId: null, cronRunsJobId: null, + cronRunsLoadingMore: false, cronRuns: [], + cronRunsTotal: 0, + cronRunsHasMore: false, + cronRunsNextOffset: null, + cronRunsLimit: 50, + cronRunsScope: "all", + cronRunsStatuses: [], + cronRunsDeliveryStatuses: [], + cronRunsStatusFilter: "all", + cronRunsQuery: "", + cronRunsSortDir: "desc", cronBusy: false, ...overrides, }; @@ -127,4 +160,378 @@ describe("cron controller", () => { expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toBeUndefined(); expect(state.cronForm.deliveryMode).toBe("none"); }); + + it("submits cron.update when editing an existing job", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.update") { + return { id: "job-1" }; + } + if (method === "cron.list") { + return { jobs: [{ id: "job-1" }] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 1, nextWakeAtMs: null }; + } + return {}; + }); + + const state = createState({ + client: { + request, + } as unknown as CronState["client"], + cronEditingJobId: "job-1", + cronForm: { + ...DEFAULT_CRON_FORM, + name: "edited job", + description: "", + clearAgent: true, + deleteAfterRun: false, + scheduleKind: "cron", + cronExpr: "0 8 * * *", + scheduleExact: true, + payloadKind: "systemEvent", + payloadText: "updated", + deliveryMode: "none", + }, + }); + + await addCronJob(state); + + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(updateCall).toBeDefined(); + expect(updateCall?.[1]).toMatchObject({ + id: "job-1", + patch: { + name: "edited job", + description: "", + agentId: null, + deleteAfterRun: false, + schedule: { kind: "cron", expr: "0 8 * * *", staggerMs: 0 }, + payload: { kind: "systemEvent", text: "updated" }, + }, + }); + expect(state.cronEditingJobId).toBeNull(); + }); + + it("maps a cron job into editable form fields", () => { + const state = createState(); + const job = { + id: "job-9", + name: "Weekly report", + description: "desc", + enabled: false, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "every" as const, everyMs: 7_200_000 }, + sessionTarget: "isolated" as const, + wakeMode: "next-heartbeat" as const, + payload: { kind: "agentTurn" as const, message: "ship it", timeoutSeconds: 45 }, + delivery: { mode: "announce" as const, channel: "telegram", to: "123" }, + state: {}, + }; + + startCronEdit(state, job); + + expect(state.cronEditingJobId).toBe("job-9"); + expect(state.cronRunsJobId).toBe("job-9"); + expect(state.cronForm.name).toBe("Weekly report"); + expect(state.cronForm.enabled).toBe(false); + expect(state.cronForm.scheduleKind).toBe("every"); + expect(state.cronForm.everyAmount).toBe("2"); + expect(state.cronForm.everyUnit).toBe("hours"); + expect(state.cronForm.payloadKind).toBe("agentTurn"); + expect(state.cronForm.payloadText).toBe("ship it"); + expect(state.cronForm.timeoutSeconds).toBe("45"); + expect(state.cronForm.deliveryMode).toBe("announce"); + expect(state.cronForm.deliveryChannel).toBe("telegram"); + expect(state.cronForm.deliveryTo).toBe("123"); + }); + + it("includes model/thinking/stagger/bestEffort in cron.update patch", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.update") { + return { id: "job-2" }; + } + if (method === "cron.list") { + return { jobs: [{ id: "job-2" }] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 1, nextWakeAtMs: null }; + } + return {}; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronEditingJobId: "job-2", + cronForm: { + ...DEFAULT_CRON_FORM, + name: "advanced edit", + scheduleKind: "cron", + cronExpr: "0 9 * * *", + staggerAmount: "30", + staggerUnit: "seconds", + payloadKind: "agentTurn", + payloadText: "run it", + payloadModel: "opus", + payloadThinking: "low", + deliveryMode: "announce", + deliveryBestEffort: true, + }, + }); + + await addCronJob(state); + + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(updateCall).toBeDefined(); + expect(updateCall?.[1]).toMatchObject({ + id: "job-2", + patch: { + schedule: { kind: "cron", expr: "0 9 * * *", staggerMs: 30_000 }, + payload: { + kind: "agentTurn", + message: "run it", + model: "opus", + thinking: "low", + }, + delivery: { mode: "announce", bestEffort: true }, + }, + }); + }); + + it("maps cron stagger, model, thinking, and best effort into form", () => { + const state = createState(); + const job = { + id: "job-10", + name: "Advanced job", + enabled: true, + deleteAfterRun: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "UTC", staggerMs: 60_000 }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + payload: { + kind: "agentTurn" as const, + message: "hi", + model: "opus", + thinking: "high", + }, + delivery: { mode: "announce" as const, bestEffort: true }, + state: {}, + }; + startCronEdit(state, job); + + expect(state.cronForm.deleteAfterRun).toBe(true); + expect(state.cronForm.scheduleKind).toBe("cron"); + expect(state.cronForm.scheduleExact).toBe(false); + expect(state.cronForm.staggerAmount).toBe("1"); + expect(state.cronForm.staggerUnit).toBe("minutes"); + expect(state.cronForm.payloadModel).toBe("opus"); + expect(state.cronForm.payloadThinking).toBe("high"); + expect(state.cronForm.deliveryBestEffort).toBe(true); + }); + + it("validates key cron form errors", () => { + const errors = validateCronForm({ + ...DEFAULT_CRON_FORM, + name: "", + scheduleKind: "cron", + cronExpr: "", + payloadKind: "agentTurn", + payloadText: "", + timeoutSeconds: "0", + deliveryMode: "webhook", + deliveryTo: "ftp://bad", + }); + expect(errors.name).toBeDefined(); + expect(errors.cronExpr).toBeDefined(); + expect(errors.payloadText).toBeDefined(); + expect(errors.timeoutSeconds).toBe("If set, timeout must be greater than 0 seconds."); + expect(errors.deliveryTo).toBeDefined(); + }); + + it("blocks add/update submit when validation errors exist", async () => { + const request = vi.fn(async () => ({})); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronForm: { + ...DEFAULT_CRON_FORM, + name: "", + payloadText: "", + }, + }); + await addCronJob(state); + expect(request).not.toHaveBeenCalled(); + expect(state.cronFieldErrors.name).toBeDefined(); + expect(state.cronFieldErrors.payloadText).toBeDefined(); + }); + + it("canceling edit resets form to defaults and clears edit mode", () => { + const state = createState(); + const job = { + id: "job-cancel", + name: "Editable", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron" as const, expr: "0 6 * * *" }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + payload: { kind: "agentTurn" as const, message: "run" }, + delivery: { mode: "announce" as const, to: "123" }, + state: {}, + }; + startCronEdit(state, job); + state.cronForm.name = "changed"; + state.cronFieldErrors = { name: "Name is required." }; + + cancelCronEdit(state); + + expect(state.cronEditingJobId).toBeNull(); + expect(state.cronForm).toEqual({ ...DEFAULT_CRON_FORM }); + expect(state.cronFieldErrors).toEqual(validateCronForm(DEFAULT_CRON_FORM)); + }); + + it("cloning a job switches to create mode and applies copy naming", () => { + const state = createState({ + cronJobs: [ + { + id: "job-1", + name: "Daily ping", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + state: {}, + }, + ], + cronEditingJobId: "job-1", + }); + + const sourceJob = state.cronJobs[0]; + expect(sourceJob).toBeDefined(); + if (!sourceJob) { + return; + } + startCronClone(state, sourceJob); + + expect(state.cronEditingJobId).toBeNull(); + expect(state.cronRunsJobId).toBe("job-1"); + expect(state.cronForm.name).toBe("Daily ping copy"); + expect(state.cronForm.payloadText).toBe("ping"); + }); + + it("submits cron.add after cloning", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.add") { + return { id: "job-new" }; + } + if (method === "cron.list") { + return { jobs: [] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 0, nextWakeAtMs: null }; + } + return {}; + }); + const sourceJob = { + id: "job-1", + name: "Daily ping", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron" as const, expr: "0 9 * * *" }, + sessionTarget: "main" as const, + wakeMode: "next-heartbeat" as const, + payload: { kind: "systemEvent" as const, text: "ping" }, + state: {}, + }; + const state = createState({ + client: { request } as unknown as CronState["client"], + cronJobs: [sourceJob], + cronEditingJobId: "job-1", + }); + + startCronClone(state, sourceJob); + await addCronJob(state); + + const addCall = request.mock.calls.find(([method]) => method === "cron.add"); + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(addCall).toBeDefined(); + expect(updateCall).toBeUndefined(); + expect((addCall?.[1] as { name?: string } | undefined)?.name).toBe("Daily ping copy"); + }); + + it("loads paged jobs with query/filter/sort params", async () => { + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method === "cron.list") { + expect(payload).toMatchObject({ + limit: 50, + offset: 0, + query: "daily", + enabled: "enabled", + sortBy: "updatedAtMs", + sortDir: "desc", + }); + return { + jobs: [{ id: "job-1", name: "Daily", enabled: true }], + total: 1, + hasMore: false, + nextOffset: null, + }; + } + return {}; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronJobsQuery: "daily", + cronJobsEnabledFilter: "enabled", + cronJobsSortBy: "updatedAtMs", + cronJobsSortDir: "desc", + }); + + await loadCronJobsPage(state); + + expect(state.cronJobs).toHaveLength(1); + expect(state.cronJobsTotal).toBe(1); + expect(state.cronJobsHasMore).toBe(false); + }); + + it("loads and appends paged run history", async () => { + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method !== "cron.runs") { + return {}; + } + const offset = (payload as { offset?: number } | undefined)?.offset ?? 0; + if (offset === 0) { + return { + entries: [{ ts: 2, jobId: "job-1", status: "ok", summary: "newest" }], + total: 2, + hasMore: true, + nextOffset: 1, + }; + } + return { + entries: [{ ts: 1, jobId: "job-1", status: "ok", summary: "older" }], + total: 2, + hasMore: false, + nextOffset: null, + }; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + }); + + await loadCronRuns(state, "job-1"); + expect(state.cronRuns).toHaveLength(1); + expect(state.cronRunsHasMore).toBe(true); + + await loadMoreCronRuns(state); + expect(state.cronRuns).toHaveLength(2); + expect(state.cronRuns[0]?.summary).toBe("newest"); + expect(state.cronRuns[1]?.summary).toBe("older"); + }); }); diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 1a69b9c3c12..99917cce741 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -1,21 +1,78 @@ +import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; +import type { + CronJob, + CronDeliveryStatus, + CronJobsEnabledFilter, + CronJobsListResult, + CronJobsSortBy, + CronRunScope, + CronRunLogEntry, + CronRunsResult, + CronRunsStatusFilter, + CronRunsStatusValue, + CronSortDir, + CronStatus, +} from "../types.ts"; +import { CRON_CHANNEL_LAST } from "../ui-types.ts"; import type { CronFormState } from "../ui-types.ts"; +export type CronFieldKey = + | "name" + | "scheduleAt" + | "everyAmount" + | "cronExpr" + | "staggerAmount" + | "payloadText" + | "payloadModel" + | "payloadThinking" + | "timeoutSeconds" + | "deliveryTo"; + +export type CronFieldErrors = Partial>; + export type CronState = { client: GatewayBrowserClient | null; connected: boolean; cronLoading: boolean; + cronJobsLoadingMore: boolean; cronJobs: CronJob[]; + cronJobsTotal: number; + cronJobsHasMore: boolean; + cronJobsNextOffset: number | null; + cronJobsLimit: number; + cronJobsQuery: string; + cronJobsEnabledFilter: CronJobsEnabledFilter; + cronJobsSortBy: CronJobsSortBy; + cronJobsSortDir: CronSortDir; cronStatus: CronStatus | null; cronError: string | null; cronForm: CronFormState; + cronFieldErrors: CronFieldErrors; + cronEditingJobId: string | null; cronRunsJobId: string | null; + cronRunsLoadingMore: boolean; cronRuns: CronRunLogEntry[]; + cronRunsTotal: number; + cronRunsHasMore: boolean; + cronRunsNextOffset: number | null; + cronRunsLimit: number; + cronRunsScope: CronRunScope; + cronRunsStatuses: CronRunsStatusValue[]; + cronRunsDeliveryStatuses: CronDeliveryStatus[]; + cronRunsStatusFilter: CronRunsStatusFilter; + cronRunsQuery: string; + cronRunsSortDir: CronSortDir; cronBusy: boolean; }; +export type CronModelSuggestionsState = { + client: GatewayBrowserClient | null; + connected: boolean; + cronModelSuggestions: string[]; +}; + export function supportsAnnounceDelivery( form: Pick, ) { @@ -35,6 +92,65 @@ export function normalizeCronFormState(form: CronFormState): CronFormState { }; } +export function validateCronForm(form: CronFormState): CronFieldErrors { + const errors: CronFieldErrors = {}; + if (!form.name.trim()) { + errors.name = "Name is required."; + } + if (form.scheduleKind === "at") { + const ms = Date.parse(form.scheduleAt); + if (!Number.isFinite(ms)) { + errors.scheduleAt = "Enter a valid date/time."; + } + } else if (form.scheduleKind === "every") { + const amount = toNumber(form.everyAmount, 0); + if (amount <= 0) { + errors.everyAmount = "Interval must be greater than 0."; + } + } else { + if (!form.cronExpr.trim()) { + errors.cronExpr = "Cron expression is required."; + } + if (!form.scheduleExact) { + const staggerAmount = form.staggerAmount.trim(); + if (staggerAmount) { + const stagger = toNumber(staggerAmount, 0); + if (stagger <= 0) { + errors.staggerAmount = "Stagger must be greater than 0."; + } + } + } + } + if (!form.payloadText.trim()) { + errors.payloadText = + form.payloadKind === "systemEvent" + ? "System text is required." + : "Agent message is required."; + } + if (form.payloadKind === "agentTurn") { + const timeoutRaw = form.timeoutSeconds.trim(); + if (timeoutRaw) { + const timeout = toNumber(timeoutRaw, 0); + if (timeout <= 0) { + errors.timeoutSeconds = "If set, timeout must be greater than 0 seconds."; + } + } + } + if (form.deliveryMode === "webhook") { + const target = form.deliveryTo.trim(); + if (!target) { + errors.deliveryTo = "Webhook URL is required."; + } else if (!/^https?:\/\//i.test(target)) { + errors.deliveryTo = "Webhook URL must start with http:// or https://."; + } + } + return errors; +} + +export function hasCronFormErrors(errors: CronFieldErrors): boolean { + return Object.keys(errors).length > 0; +} + export async function loadCronStatus(state: CronState) { if (!state.client || !state.connected) { return; @@ -47,27 +163,267 @@ export async function loadCronStatus(state: CronState) { } } -export async function loadCronJobs(state: CronState) { +export async function loadCronModelSuggestions(state: CronModelSuggestionsState) { if (!state.client || !state.connected) { return; } - if (state.cronLoading) { + try { + const res = await state.client.request("models.list", {}); + const models = (res as { models?: unknown[] } | null)?.models; + if (!Array.isArray(models)) { + state.cronModelSuggestions = []; + return; + } + const ids = models + .map((entry) => { + if (!entry || typeof entry !== "object") { + return ""; + } + const id = (entry as { id?: unknown }).id; + return typeof id === "string" ? id.trim() : ""; + }) + .filter(Boolean); + state.cronModelSuggestions = Array.from(new Set(ids)).toSorted((a, b) => a.localeCompare(b)); + } catch { + state.cronModelSuggestions = []; + } +} + +export async function loadCronJobs(state: CronState) { + return await loadCronJobsPage(state, { append: false }); +} + +function normalizeCronPageMeta(params: { + totalRaw: unknown; + limitRaw: unknown; + offsetRaw: unknown; + nextOffsetRaw: unknown; + hasMoreRaw: unknown; + pageCount: number; +}) { + const total = + typeof params.totalRaw === "number" && Number.isFinite(params.totalRaw) + ? Math.max(0, Math.floor(params.totalRaw)) + : params.pageCount; + const limit = + typeof params.limitRaw === "number" && Number.isFinite(params.limitRaw) + ? Math.max(1, Math.floor(params.limitRaw)) + : Math.max(1, params.pageCount); + const offset = + typeof params.offsetRaw === "number" && Number.isFinite(params.offsetRaw) + ? Math.max(0, Math.floor(params.offsetRaw)) + : 0; + const hasMore = + typeof params.hasMoreRaw === "boolean" + ? params.hasMoreRaw + : offset + params.pageCount < Math.max(total, offset + params.pageCount); + const nextOffset = + typeof params.nextOffsetRaw === "number" && Number.isFinite(params.nextOffsetRaw) + ? Math.max(0, Math.floor(params.nextOffsetRaw)) + : hasMore + ? offset + params.pageCount + : null; + return { total, limit, offset, hasMore, nextOffset }; +} + +export async function loadCronJobsPage(state: CronState, opts?: { append?: boolean }) { + if (!state.client || !state.connected) { return; } - state.cronLoading = true; + if (state.cronLoading || state.cronJobsLoadingMore) { + return; + } + const append = opts?.append === true; + if (append) { + if (!state.cronJobsHasMore) { + return; + } + state.cronJobsLoadingMore = true; + } else { + state.cronLoading = true; + } state.cronError = null; try { - const res = await state.client.request<{ jobs?: Array }>("cron.list", { - includeDisabled: true, + const offset = append ? Math.max(0, state.cronJobsNextOffset ?? state.cronJobs.length) : 0; + const res = await state.client.request("cron.list", { + includeDisabled: state.cronJobsEnabledFilter === "all", + limit: state.cronJobsLimit, + offset, + query: state.cronJobsQuery.trim() || undefined, + enabled: state.cronJobsEnabledFilter, + sortBy: state.cronJobsSortBy, + sortDir: state.cronJobsSortDir, }); - state.cronJobs = Array.isArray(res.jobs) ? res.jobs : []; + const jobs = Array.isArray(res.jobs) ? res.jobs : []; + state.cronJobs = append ? [...state.cronJobs, ...jobs] : jobs; + const meta = normalizeCronPageMeta({ + totalRaw: res.total, + limitRaw: res.limit, + offsetRaw: res.offset, + nextOffsetRaw: res.nextOffset, + hasMoreRaw: res.hasMore, + pageCount: jobs.length, + }); + state.cronJobsTotal = Math.max(meta.total, state.cronJobs.length); + state.cronJobsHasMore = meta.hasMore; + state.cronJobsNextOffset = meta.nextOffset; + if ( + state.cronEditingJobId && + !state.cronJobs.some((job) => job.id === state.cronEditingJobId) + ) { + clearCronEditState(state); + } } catch (err) { state.cronError = String(err); } finally { - state.cronLoading = false; + if (append) { + state.cronJobsLoadingMore = false; + } else { + state.cronLoading = false; + } } } +export async function loadMoreCronJobs(state: CronState) { + await loadCronJobsPage(state, { append: true }); +} + +export async function reloadCronJobs(state: CronState) { + await loadCronJobsPage(state, { append: false }); +} + +export function updateCronJobsFilter( + state: CronState, + patch: Partial< + Pick< + CronState, + "cronJobsQuery" | "cronJobsEnabledFilter" | "cronJobsSortBy" | "cronJobsSortDir" + > + >, +) { + if (typeof patch.cronJobsQuery === "string") { + state.cronJobsQuery = patch.cronJobsQuery; + } + if (patch.cronJobsEnabledFilter) { + state.cronJobsEnabledFilter = patch.cronJobsEnabledFilter; + } + if (patch.cronJobsSortBy) { + state.cronJobsSortBy = patch.cronJobsSortBy; + } + if (patch.cronJobsSortDir) { + state.cronJobsSortDir = patch.cronJobsSortDir; + } +} + +function clearCronEditState(state: CronState) { + state.cronEditingJobId = null; +} + +function resetCronFormToDefaults(state: CronState) { + state.cronForm = { ...DEFAULT_CRON_FORM }; + state.cronFieldErrors = validateCronForm(state.cronForm); +} + +function formatDateTimeLocal(input: string): string { + const ms = Date.parse(input); + if (!Number.isFinite(ms)) { + return ""; + } + const date = new Date(ms); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hour}:${minute}`; +} + +function parseEverySchedule(everyMs: number): Pick { + if (everyMs % 86_400_000 === 0) { + return { everyAmount: String(Math.max(1, everyMs / 86_400_000)), everyUnit: "days" }; + } + if (everyMs % 3_600_000 === 0) { + return { everyAmount: String(Math.max(1, everyMs / 3_600_000)), everyUnit: "hours" }; + } + const minutes = Math.max(1, Math.ceil(everyMs / 60_000)); + return { everyAmount: String(minutes), everyUnit: "minutes" }; +} + +function parseStaggerSchedule( + staggerMs?: number, +): Pick { + if (staggerMs === 0) { + return { scheduleExact: true, staggerAmount: "", staggerUnit: "seconds" }; + } + if (typeof staggerMs !== "number" || !Number.isFinite(staggerMs) || staggerMs < 0) { + return { scheduleExact: false, staggerAmount: "", staggerUnit: "seconds" }; + } + if (staggerMs % 60_000 === 0) { + return { + scheduleExact: false, + staggerAmount: String(Math.max(1, staggerMs / 60_000)), + staggerUnit: "minutes", + }; + } + return { + scheduleExact: false, + staggerAmount: String(Math.max(1, Math.ceil(staggerMs / 1_000))), + staggerUnit: "seconds", + }; +} + +function jobToForm(job: CronJob, prev: CronFormState): CronFormState { + const next: CronFormState = { + ...prev, + name: job.name, + description: job.description ?? "", + agentId: job.agentId ?? "", + clearAgent: false, + enabled: job.enabled, + deleteAfterRun: job.deleteAfterRun ?? false, + scheduleKind: job.schedule.kind, + scheduleAt: "", + everyAmount: prev.everyAmount, + everyUnit: prev.everyUnit, + cronExpr: prev.cronExpr, + cronTz: "", + scheduleExact: false, + staggerAmount: "", + staggerUnit: "seconds", + sessionTarget: job.sessionTarget, + wakeMode: job.wakeMode, + payloadKind: job.payload.kind, + payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message, + payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "", + payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "", + deliveryMode: job.delivery?.mode ?? "none", + deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST, + deliveryTo: job.delivery?.to ?? "", + deliveryBestEffort: job.delivery?.bestEffort ?? false, + timeoutSeconds: + job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number" + ? String(job.payload.timeoutSeconds) + : "", + }; + + if (job.schedule.kind === "at") { + next.scheduleAt = formatDateTimeLocal(job.schedule.at); + } else if (job.schedule.kind === "every") { + const parsed = parseEverySchedule(job.schedule.everyMs); + next.everyAmount = parsed.everyAmount; + next.everyUnit = parsed.everyUnit; + } else { + next.cronExpr = job.schedule.expr; + next.cronTz = job.schedule.tz ?? ""; + const staggerFields = parseStaggerSchedule(job.schedule.staggerMs); + next.scheduleExact = staggerFields.scheduleExact; + next.staggerAmount = staggerFields.staggerAmount; + next.staggerUnit = staggerFields.staggerUnit; + } + + return normalizeCronFormState(next); +} + export function buildCronSchedule(form: CronFormState) { if (form.scheduleKind === "at") { const ms = Date.parse(form.scheduleAt); @@ -89,7 +445,19 @@ export function buildCronSchedule(form: CronFormState) { if (!expr) { throw new Error("Cron expression required."); } - return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined }; + if (form.scheduleExact) { + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs: 0 }; + } + const staggerAmount = form.staggerAmount.trim(); + if (!staggerAmount) { + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined }; + } + const staggerValue = toNumber(staggerAmount, 0); + if (staggerValue <= 0) { + throw new Error("Invalid stagger amount."); + } + const staggerMs = form.staggerUnit === "minutes" ? staggerValue * 60_000 : staggerValue * 1_000; + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs }; } export function buildCronPayload(form: CronFormState) { @@ -107,8 +475,18 @@ export function buildCronPayload(form: CronFormState) { const payload: { kind: "agentTurn"; message: string; + model?: string; + thinking?: string; timeoutSeconds?: number; } = { kind: "agentTurn", message }; + const model = form.payloadModel.trim(); + if (model) { + payload.model = model; + } + const thinking = form.payloadThinking.trim(); + if (thinking) { + payload.thinking = thinking; + } const timeoutSeconds = toNumber(form.timeoutSeconds, 0); if (timeoutSeconds > 0) { payload.timeoutSeconds = timeoutSeconds; @@ -127,6 +505,11 @@ export async function addCronJob(state: CronState) { if (form !== state.cronForm) { state.cronForm = form; } + const fieldErrors = validateCronForm(form); + state.cronFieldErrors = fieldErrors; + if (hasCronFormErrors(fieldErrors)) { + return; + } const schedule = buildCronSchedule(form); const payload = buildCronPayload(form); @@ -140,14 +523,16 @@ export async function addCronJob(state: CronState) { ? form.deliveryChannel.trim() || "last" : undefined, to: form.deliveryTo.trim() || undefined, + bestEffort: form.deliveryBestEffort, } : undefined; - const agentId = form.agentId.trim(); + const agentId = form.clearAgent ? null : form.agentId.trim(); const job = { name: form.name.trim(), - description: form.description.trim() || undefined, - agentId: agentId || undefined, + description: form.description.trim(), + agentId: agentId === null ? null : agentId || undefined, enabled: form.enabled, + deleteAfterRun: form.deleteAfterRun, schedule, sessionTarget: form.sessionTarget, wakeMode: form.wakeMode, @@ -157,13 +542,16 @@ export async function addCronJob(state: CronState) { if (!job.name) { throw new Error("Name required."); } - await state.client.request("cron.add", job); - state.cronForm = { - ...state.cronForm, - name: "", - description: "", - payloadText: "", - }; + if (state.cronEditingJobId) { + await state.client.request("cron.update", { + id: state.cronEditingJobId, + patch: job, + }); + clearCronEditState(state); + } else { + await state.client.request("cron.add", job); + resetCronFormToDefaults(state); + } await loadCronJobs(state); await loadCronStatus(state); } catch (err) { @@ -198,7 +586,11 @@ export async function runCronJob(state: CronState, job: CronJob) { state.cronError = null; try { await state.client.request("cron.run", { id: job.id, mode: "force" }); - await loadCronRuns(state, job.id); + if (state.cronRunsScope === "all") { + await loadCronRuns(state, null); + } else { + await loadCronRuns(state, job.id); + } } catch (err) { state.cronError = String(err); } finally { @@ -214,9 +606,15 @@ export async function removeCronJob(state: CronState, job: CronJob) { state.cronError = null; try { await state.client.request("cron.remove", { id: job.id }); + if (state.cronEditingJobId === job.id) { + clearCronEditState(state); + } if (state.cronRunsJobId === job.id) { state.cronRunsJobId = null; state.cronRuns = []; + state.cronRunsTotal = 0; + state.cronRunsHasMore = false; + state.cronRunsNextOffset = null; } await loadCronJobs(state); await loadCronStatus(state); @@ -227,18 +625,152 @@ export async function removeCronJob(state: CronState, job: CronJob) { } } -export async function loadCronRuns(state: CronState, jobId: string) { +export async function loadCronRuns( + state: CronState, + jobId: string | null, + opts?: { append?: boolean }, +) { if (!state.client || !state.connected) { return; } + const scope = state.cronRunsScope; + const activeJobId = jobId ?? state.cronRunsJobId; + if (scope === "job" && !activeJobId) { + state.cronRuns = []; + state.cronRunsTotal = 0; + state.cronRunsHasMore = false; + state.cronRunsNextOffset = null; + return; + } + const append = opts?.append === true; + if (append && !state.cronRunsHasMore) { + return; + } try { - const res = await state.client.request<{ entries?: Array }>("cron.runs", { - id: jobId, - limit: 50, + if (append) { + state.cronRunsLoadingMore = true; + } + const offset = append ? Math.max(0, state.cronRunsNextOffset ?? state.cronRuns.length) : 0; + const res = await state.client.request("cron.runs", { + scope, + id: scope === "job" ? (activeJobId ?? undefined) : undefined, + limit: state.cronRunsLimit, + offset, + statuses: state.cronRunsStatuses.length > 0 ? state.cronRunsStatuses : undefined, + status: state.cronRunsStatusFilter, + deliveryStatuses: + state.cronRunsDeliveryStatuses.length > 0 ? state.cronRunsDeliveryStatuses : undefined, + query: state.cronRunsQuery.trim() || undefined, + sortDir: state.cronRunsSortDir, }); - state.cronRunsJobId = jobId; - state.cronRuns = Array.isArray(res.entries) ? res.entries : []; + const entries = Array.isArray(res.entries) ? res.entries : []; + state.cronRuns = + append && (scope === "all" || state.cronRunsJobId === activeJobId) + ? [...state.cronRuns, ...entries] + : entries; + if (scope === "job") { + state.cronRunsJobId = activeJobId ?? null; + } + const meta = normalizeCronPageMeta({ + totalRaw: res.total, + limitRaw: res.limit, + offsetRaw: res.offset, + nextOffsetRaw: res.nextOffset, + hasMoreRaw: res.hasMore, + pageCount: entries.length, + }); + state.cronRunsTotal = Math.max(meta.total, state.cronRuns.length); + state.cronRunsHasMore = meta.hasMore; + state.cronRunsNextOffset = meta.nextOffset; } catch (err) { state.cronError = String(err); + } finally { + if (append) { + state.cronRunsLoadingMore = false; + } } } + +export async function loadMoreCronRuns(state: CronState) { + if (state.cronRunsScope === "job" && !state.cronRunsJobId) { + return; + } + await loadCronRuns(state, state.cronRunsJobId, { append: true }); +} + +export function updateCronRunsFilter( + state: CronState, + patch: Partial< + Pick< + CronState, + | "cronRunsScope" + | "cronRunsStatuses" + | "cronRunsDeliveryStatuses" + | "cronRunsStatusFilter" + | "cronRunsQuery" + | "cronRunsSortDir" + > + >, +) { + if (patch.cronRunsScope) { + state.cronRunsScope = patch.cronRunsScope; + } + if (Array.isArray(patch.cronRunsStatuses)) { + state.cronRunsStatuses = patch.cronRunsStatuses; + state.cronRunsStatusFilter = + patch.cronRunsStatuses.length === 1 ? patch.cronRunsStatuses[0] : "all"; + } + if (Array.isArray(patch.cronRunsDeliveryStatuses)) { + state.cronRunsDeliveryStatuses = patch.cronRunsDeliveryStatuses; + } + if (patch.cronRunsStatusFilter) { + state.cronRunsStatusFilter = patch.cronRunsStatusFilter; + state.cronRunsStatuses = + patch.cronRunsStatusFilter === "all" ? [] : [patch.cronRunsStatusFilter]; + } + if (typeof patch.cronRunsQuery === "string") { + state.cronRunsQuery = patch.cronRunsQuery; + } + if (patch.cronRunsSortDir) { + state.cronRunsSortDir = patch.cronRunsSortDir; + } +} + +export function startCronEdit(state: CronState, job: CronJob) { + state.cronEditingJobId = job.id; + state.cronRunsJobId = job.id; + state.cronForm = jobToForm(job, state.cronForm); + state.cronFieldErrors = validateCronForm(state.cronForm); +} + +function buildCloneName(name: string, existingNames: Set) { + const base = name.trim() || "Job"; + const first = `${base} copy`; + if (!existingNames.has(first.toLowerCase())) { + return first; + } + let index = 2; + while (index < 1000) { + const next = `${base} copy ${index}`; + if (!existingNames.has(next.toLowerCase())) { + return next; + } + index += 1; + } + return `${base} copy ${Date.now()}`; +} + +export function startCronClone(state: CronState, job: CronJob) { + clearCronEditState(state); + state.cronRunsJobId = job.id; + const existingNames = new Set(state.cronJobs.map((entry) => entry.name.trim().toLowerCase())); + const cloned = jobToForm(job, state.cronForm); + cloned.name = buildCloneName(job.name, existingNames); + state.cronForm = cloned; + state.cronFieldErrors = validateCronForm(state.cronForm); +} + +export function cancelCronEdit(state: CronState) { + clearCronEditState(state); + resetCronFormToDefaults(state); +} diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index dbeaa687336..6f0fdc0ad4b 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -18,7 +18,8 @@ export function formatNextRun(ms?: number | null) { if (!ms) { return "n/a"; } - return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; + const weekday = new Date(ms).toLocaleDateString(undefined, { weekday: "short" }); + return `${weekday}, ${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; } export function formatSessionTokens(row: GatewaySessionRow) { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 4413c23a58e..012d1cc236d 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -440,7 +440,7 @@ export type { export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } - | { kind: "cron"; expr: string; tz?: string }; + | { kind: "cron"; expr: string; tz?: string; staggerMs?: number }; export type CronSessionTarget = "main" | "isolated"; export type CronWakeMode = "next-heartbeat" | "now"; @@ -450,6 +450,7 @@ export type CronPayload = | { kind: "agentTurn"; message: string; + model?: string; thinking?: string; timeoutSeconds?: number; }; @@ -493,17 +494,58 @@ export type CronStatus = { nextWakeAtMs?: number | null; }; +export type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; +export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; +export type CronSortDir = "asc" | "desc"; +export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped"; +export type CronRunsStatusValue = "ok" | "error" | "skipped"; +export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested"; +export type CronRunScope = "job" | "all"; + export type CronRunLogEntry = { ts: number; jobId: string; - status: "ok" | "error" | "skipped"; + jobName?: string; + status?: CronRunsStatusValue; durationMs?: number; error?: string; summary?: string; + deliveryStatus?: CronDeliveryStatus; + deliveryError?: string; + delivered?: boolean; + runAtMs?: number; + nextRunAtMs?: number; + model?: string; + provider?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + }; sessionId?: string; sessionKey?: string; }; +export type CronJobsListResult = { + jobs?: CronJob[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + +export type CronRunsResult = { + entries?: CronRunLogEntry[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + export type SkillsStatusConfigCheck = { path: string; satisfied: boolean; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 724f2a92009..f1087546c79 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -18,19 +18,27 @@ export type CronFormState = { name: string; description: string; agentId: string; + clearAgent: boolean; enabled: boolean; + deleteAfterRun: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; everyAmount: string; everyUnit: "minutes" | "hours" | "days"; cronExpr: string; cronTz: string; + scheduleExact: boolean; + staggerAmount: string; + staggerUnit: "seconds" | "minutes"; sessionTarget: "main" | "isolated"; wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; + payloadModel: string; + payloadThinking: string; deliveryMode: "none" | "announce" | "webhook"; deliveryChannel: string; deliveryTo: string; + deliveryBestEffort: boolean; timeoutSeconds: string; }; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 839566151cd..b09100494f7 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -22,32 +22,93 @@ function createProps(overrides: Partial = {}): CronProps { return { basePath: "", loading: false, + jobsLoadingMore: false, status: null, jobs: [], + jobsTotal: 0, + jobsHasMore: false, + jobsQuery: "", + jobsEnabledFilter: "all", + jobsSortBy: "nextRunAtMs", + jobsSortDir: "asc", error: null, busy: false, form: { ...DEFAULT_CRON_FORM }, + fieldErrors: {}, + canSubmit: true, + editingJobId: null, channels: [], channelLabels: {}, runsJobId: null, runs: [], + runsTotal: 0, + runsHasMore: false, + runsLoadingMore: false, + runsScope: "all", + runsStatuses: [], + runsDeliveryStatuses: [], + runsStatusFilter: "all", + runsQuery: "", + runsSortDir: "desc", + agentSuggestions: [], + modelSuggestions: [], + thinkingSuggestions: [], + timezoneSuggestions: [], + deliveryToSuggestions: [], onFormChange: () => undefined, onRefresh: () => undefined, onAdd: () => undefined, + onEdit: () => undefined, + onClone: () => undefined, + onCancelEdit: () => undefined, onToggle: () => undefined, onRun: () => undefined, onRemove: () => undefined, onLoadRuns: () => undefined, + onLoadMoreJobs: () => undefined, + onJobsFiltersChange: () => undefined, + onLoadMoreRuns: () => undefined, + onRunsFiltersChange: () => undefined, ...overrides, }; } describe("cron view", () => { - it("prompts to select a job before showing run history", () => { + it("shows all-job history mode by default", () => { const container = document.createElement("div"); render(renderCron(createProps()), container); - expect(container.textContent).toContain("Select a job to inspect run history."); + expect(container.textContent).toContain("Latest runs across all jobs."); + expect(container.textContent).toContain("Status"); + expect(container.textContent).toContain("All statuses"); + expect(container.textContent).toContain("Delivery"); + expect(container.textContent).toContain("All delivery"); + expect(container.textContent).not.toContain("multi-select"); + }); + + it("toggles run status filter via dropdown checkboxes", () => { + const container = document.createElement("div"); + const onRunsFiltersChange = vi.fn(); + render( + renderCron( + createProps({ + onRunsFiltersChange, + }), + ), + container, + ); + + const statusOk = container.querySelector( + '.cron-filter-dropdown[data-filter="status"] input[value="ok"]', + ); + expect(statusOk).not.toBeNull(); + if (!(statusOk instanceof HTMLInputElement)) { + return; + } + statusOk.checked = true; + statusOk.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] }); }); it("loads run history when clicking a job row", () => { @@ -80,6 +141,7 @@ describe("cron view", () => { createProps({ jobs: [job], runsJobId: "job-1", + runsScope: "job", onLoadRuns, }), ), @@ -135,6 +197,7 @@ describe("cron view", () => { createProps({ jobs: [job], runsJobId: "job-1", + runsScope: "job", runs: [ { ts: 1, jobId: "job-1", status: "ok", summary: "older run" }, { ts: 2, jobId: "job-1", status: "ok", summary: "newer run" }, @@ -159,6 +222,30 @@ describe("cron view", () => { expect(summaries[1]).toBe("older run"); }); + it("labels past nextRunAtMs as due instead of next", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + runsScope: "all", + runs: [ + { + ts: Date.now(), + jobId: "job-1", + status: "ok", + summary: "done", + nextRunAtMs: Date.now() - 13 * 60_000, + }, + ], + }), + ), + container, + ); + + expect(container.textContent).toContain("Due"); + expect(container.textContent).not.toContain("Next 13"); + }); + it("shows webhook delivery option in the form", () => { const container = document.createElement("div"); render( @@ -198,7 +285,7 @@ describe("cron view", () => { expect(options).not.toContain("Announce summary (default)"); expect(options).toContain("Webhook POST"); expect(options).toContain("None (internal)"); - expect(container.querySelector('input[placeholder="https://example.invalid/cron"]')).toBeNull(); + expect(container.querySelector('input[placeholder="https://example.com/cron"]')).toBeNull(); }); it("shows webhook delivery details for jobs", () => { @@ -222,4 +309,346 @@ describe("cron view", () => { expect(container.textContent).toContain("webhook"); expect(container.textContent).toContain("https://example.invalid/cron"); }); + + it("wires the Edit action and shows save/cancel controls when editing", () => { + const container = document.createElement("div"); + const onEdit = vi.fn(); + const onLoadRuns = vi.fn(); + const onCancelEdit = vi.fn(); + const job = createJob("job-3"); + + render( + renderCron( + createProps({ + jobs: [job], + editingJobId: "job-3", + onEdit, + onLoadRuns, + onCancelEdit, + }), + ), + container, + ); + + const editButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Edit", + ); + expect(editButton).not.toBeUndefined(); + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onEdit).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledWith("job-3"); + + expect(container.textContent).toContain("Edit Job"); + expect(container.textContent).toContain("Save changes"); + + const cancelButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Cancel", + ); + expect(cancelButton).not.toBeUndefined(); + cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onCancelEdit).toHaveBeenCalledTimes(1); + }); + + it("renders advanced controls for cron + agent payload + delivery", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + deliveryMode: "announce", + }, + }), + ), + container, + ); + + expect(container.textContent).toContain("Advanced"); + expect(container.textContent).toContain("Exact timing (no stagger)"); + expect(container.textContent).toContain("Stagger window"); + expect(container.textContent).toContain("Model"); + expect(container.textContent).toContain("Thinking"); + expect(container.textContent).toContain("Best effort delivery"); + }); + + it("groups stagger window and unit inside the same stagger row", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + }, + }), + ), + container, + ); + + const staggerGroup = container.querySelector(".cron-stagger-group"); + expect(staggerGroup).not.toBeNull(); + expect(staggerGroup?.textContent).toContain("Stagger window"); + expect(staggerGroup?.textContent).toContain("Stagger unit"); + }); + + it("explains timeout blank behavior and shows cron jitter hint", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + }, + }), + ), + container, + ); + + expect(container.textContent).toContain( + "Optional. Leave blank to use the gateway default timeout behavior for this run.", + ); + expect(container.textContent).toContain("Need jitter? Use Advanced"); + }); + + it("disables Agent ID when clear-agent is enabled", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + clearAgent: true, + }, + }), + ), + container, + ); + + const agentInput = container.querySelector('input[placeholder="main or ops"]'); + expect(agentInput).not.toBeNull(); + expect(agentInput instanceof HTMLInputElement).toBe(true); + expect(agentInput instanceof HTMLInputElement ? agentInput.disabled : false).toBe(true); + }); + + it("renders sectioned cron form layout", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + expect(container.textContent).toContain("Enabled"); + expect(container.textContent).toContain("Jobs"); + expect(container.textContent).toContain("Next wake"); + expect(container.textContent).toContain("Basics"); + expect(container.textContent).toContain("Schedule"); + expect(container.textContent).toContain("Execution"); + expect(container.textContent).toContain("Delivery"); + expect(container.textContent).toContain("Advanced"); + }); + + it("renders checkbox fields with input first for alignment", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + const checkboxLabel = container.querySelector(".cron-checkbox"); + expect(checkboxLabel).not.toBeNull(); + const firstElement = checkboxLabel?.firstElementChild; + expect(firstElement?.tagName.toLowerCase()).toBe("input"); + }); + + it("hides cron-only advanced controls for non-cron schedules", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + payloadKind: "systemEvent", + deliveryMode: "none", + }, + }), + ), + container, + ); + expect(container.textContent).not.toContain("Exact timing (no stagger)"); + expect(container.textContent).not.toContain("Stagger window"); + expect(container.textContent).not.toContain("Model"); + expect(container.textContent).not.toContain("Best effort delivery"); + }); + + it("renders inline validation errors and disables submit when invalid", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + name: "", + scheduleKind: "cron", + cronExpr: "", + payloadText: "", + }, + fieldErrors: { + name: "Name is required.", + cronExpr: "Cron expression is required.", + payloadText: "Agent message is required.", + }, + canSubmit: false, + }), + ), + container, + ); + + expect(container.textContent).toContain("Name is required."); + expect(container.textContent).toContain("Cron expression is required."); + expect(container.textContent).toContain("Agent message is required."); + expect(container.textContent).toContain("Can't add job yet"); + expect(container.textContent).toContain("Fix 3 fields to continue."); + + const saveButton = Array.from(container.querySelectorAll("button")).find((btn) => + ["Add job", "Save changes"].includes(btn.textContent?.trim() ?? ""), + ); + expect(saveButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(true); + }); + + it("shows required legend and aria bindings for invalid required fields", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + name: "", + everyAmount: "", + payloadText: "", + }, + fieldErrors: { + name: "Name is required.", + everyAmount: "Interval must be greater than 0.", + payloadText: "Agent message is required.", + }, + canSubmit: false, + }), + ), + container, + ); + + expect(container.textContent).toContain("* Required"); + + const nameInput = container.querySelector("#cron-name"); + expect(nameInput?.getAttribute("aria-invalid")).toBe("true"); + expect(nameInput?.getAttribute("aria-describedby")).toBe("cron-error-name"); + expect(container.querySelector("#cron-error-name")?.textContent).toContain("Name is required."); + + const everyInput = container.querySelector("#cron-every-amount"); + expect(everyInput?.getAttribute("aria-invalid")).toBe("true"); + expect(everyInput?.getAttribute("aria-describedby")).toBe("cron-error-everyAmount"); + expect(container.querySelector("#cron-error-everyAmount")?.textContent).toContain( + "Interval must be greater than 0.", + ); + }); + + it("wires the Clone action from job rows", () => { + const container = document.createElement("div"); + const onClone = vi.fn(); + const onLoadRuns = vi.fn(); + const job = createJob("job-clone"); + render( + renderCron( + createProps({ + jobs: [job], + onClone, + onLoadRuns, + }), + ), + container, + ); + + const cloneButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Clone", + ); + expect(cloneButton).not.toBeUndefined(); + cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onClone).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledWith("job-clone"); + }); + + it("selects row when clicking Enable/Disable, Run, and Remove actions", () => { + const container = document.createElement("div"); + const onToggle = vi.fn(); + const onRun = vi.fn(); + const onRemove = vi.fn(); + const onLoadRuns = vi.fn(); + const job = createJob("job-actions"); + render( + renderCron( + createProps({ + jobs: [job], + onToggle, + onRun, + onRemove, + onLoadRuns, + }), + ), + container, + ); + + const enableButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Disable", + ); + expect(enableButton).not.toBeUndefined(); + enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const runButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Run", + ); + expect(runButton).not.toBeUndefined(); + runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const removeButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Remove", + ); + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onToggle).toHaveBeenCalledWith(job, false); + expect(onRun).toHaveBeenCalledWith(job); + expect(onRemove).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledTimes(3); + expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-actions"); + expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-actions"); + expect(onLoadRuns).toHaveBeenNthCalledWith(3, "job-actions"); + }); + + it("renders suggestion datalists for agent/model/thinking/timezone", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", payloadKind: "agentTurn" }, + agentSuggestions: ["main"], + modelSuggestions: ["openai/gpt-5.2"], + thinkingSuggestions: ["low"], + timezoneSuggestions: ["UTC"], + deliveryToSuggestions: ["+15551234567"], + }), + ), + container, + ); + + expect(container.querySelector("datalist#cron-agent-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-model-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull(); + expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull(); + }); }); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index e5cc32408ea..e84c6f9f03f 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -1,32 +1,119 @@ import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import type { CronFieldErrors, CronFieldKey } from "../controllers/cron.ts"; import { formatRelativeTimestamp, formatMs } from "../format.ts"; import { pathForTab } from "../navigation.ts"; import { formatCronSchedule, formatNextRun } from "../presenter.ts"; import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; +import type { + CronDeliveryStatus, + CronJobsEnabledFilter, + CronRunScope, + CronRunsStatusValue, + CronJobsSortBy, + CronRunsStatusFilter, + CronSortDir, +} from "../types.ts"; import type { CronFormState } from "../ui-types.ts"; export type CronProps = { basePath: string; loading: boolean; + jobsLoadingMore: boolean; status: CronStatus | null; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsQuery: string; + jobsEnabledFilter: CronJobsEnabledFilter; + jobsSortBy: CronJobsSortBy; + jobsSortDir: CronSortDir; error: string | null; busy: boolean; form: CronFormState; + fieldErrors: CronFieldErrors; + canSubmit: boolean; + editingJobId: string | null; channels: string[]; channelLabels?: Record; channelMeta?: ChannelUiMetaEntry[]; runsJobId: string | null; runs: CronRunLogEntry[]; + runsTotal: number; + runsHasMore: boolean; + runsLoadingMore: boolean; + runsScope: CronRunScope; + runsStatuses: CronRunsStatusValue[]; + runsDeliveryStatuses: CronDeliveryStatus[]; + runsStatusFilter: CronRunsStatusFilter; + runsQuery: string; + runsSortDir: CronSortDir; + agentSuggestions: string[]; + modelSuggestions: string[]; + thinkingSuggestions: string[]; + timezoneSuggestions: string[]; + deliveryToSuggestions: string[]; onFormChange: (patch: Partial) => void; onRefresh: () => void; onAdd: () => void; + onEdit: (job: CronJob) => void; + onClone: (job: CronJob) => void; + onCancelEdit: () => void; onToggle: (job: CronJob, enabled: boolean) => void; onRun: (job: CronJob) => void; onRemove: (job: CronJob) => void; onLoadRuns: (jobId: string) => void; + onLoadMoreJobs: () => void; + onJobsFiltersChange: (patch: { + cronJobsQuery?: string; + cronJobsEnabledFilter?: CronJobsEnabledFilter; + cronJobsSortBy?: CronJobsSortBy; + cronJobsSortDir?: CronSortDir; + }) => void | Promise; + onLoadMoreRuns: () => void; + onRunsFiltersChange: (patch: { + cronRunsScope?: CronRunScope; + cronRunsStatuses?: CronRunsStatusValue[]; + cronRunsDeliveryStatuses?: CronDeliveryStatus[]; + cronRunsStatusFilter?: CronRunsStatusFilter; + cronRunsQuery?: string; + cronRunsSortDir?: CronSortDir; + }) => void | Promise; }; +const RUN_STATUS_OPTIONS: Array<{ value: CronRunsStatusValue; label: string }> = [ + { value: "ok", label: "OK" }, + { value: "error", label: "Error" }, + { value: "skipped", label: "Skipped" }, +]; + +const RUN_DELIVERY_OPTIONS: Array<{ value: CronDeliveryStatus; label: string }> = [ + { value: "delivered", label: "Delivered" }, + { value: "not-delivered", label: "Not delivered" }, + { value: "unknown", label: "Unknown" }, + { value: "not-requested", label: "Not requested" }, +]; + +function toggleSelection(selected: T[], value: T, checked: boolean): T[] { + const set = new Set(selected); + if (checked) { + set.add(value); + } else { + set.delete(value); + } + return Array.from(set); +} + +function summarizeSelection(selectedLabels: string[], allLabel: string) { + if (selectedLabels.length === 0) { + return allLabel; + } + if (selectedLabels.length <= 2) { + return selectedLabels.join(", "); + } + return `${selectedLabels[0]} +${selectedLabels.length - 1}`; +} + function buildChannelOptions(props: CronProps): string[] { const options = ["last", ...props.channels.filter(Boolean)]; const current = props.form.deliveryChannel?.trim(); @@ -54,291 +141,975 @@ function resolveChannelLabel(props: CronProps, channel: string): string { return props.channelLabels?.[channel] ?? channel; } +function renderRunFilterDropdown(params: { + id: string; + title: string; + summary: string; + options: Array<{ value: string; label: string }>; + selected: string[]; + onToggle: (value: string, checked: boolean) => void; + onClear: () => void; +}) { + return html` +
+ ${params.title} +
+ + ${params.summary} + +
+
+ ${params.options.map( + (option) => html` + + `, + )} +
+
+ +
+
+
+
+ `; +} + +function renderSuggestionList(id: string, options: string[]) { + const clean = Array.from(new Set(options.map((option) => option.trim()).filter(Boolean))); + if (clean.length === 0) { + return nothing; + } + return html` + ${clean.map((value) => html` `)} + `; +} + +type BlockingField = { + key: CronFieldKey; + label: string; + message: string; + inputId: string; +}; + +function errorIdForField(key: CronFieldKey) { + return `cron-error-${key}`; +} + +function inputIdForField(key: CronFieldKey) { + if (key === "name") { + return "cron-name"; + } + if (key === "scheduleAt") { + return "cron-schedule-at"; + } + if (key === "everyAmount") { + return "cron-every-amount"; + } + if (key === "cronExpr") { + return "cron-cron-expr"; + } + if (key === "staggerAmount") { + return "cron-stagger-amount"; + } + if (key === "payloadText") { + return "cron-payload-text"; + } + if (key === "payloadModel") { + return "cron-payload-model"; + } + if (key === "payloadThinking") { + return "cron-payload-thinking"; + } + if (key === "timeoutSeconds") { + return "cron-timeout-seconds"; + } + return "cron-delivery-to"; +} + +function fieldLabelForKey( + key: CronFieldKey, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +) { + if (key === "payloadText") { + return form.payloadKind === "systemEvent" ? "Main timeline message" : "Assistant task prompt"; + } + if (key === "deliveryTo") { + return deliveryMode === "webhook" ? "Webhook URL" : "To"; + } + const labels: Record = { + name: "Name", + scheduleAt: "Run at", + everyAmount: "Every", + cronExpr: "Expression", + staggerAmount: "Stagger window", + payloadText: "Payload text", + payloadModel: "Model", + payloadThinking: "Thinking", + timeoutSeconds: "Timeout (seconds)", + deliveryTo: "To", + }; + return labels[key]; +} + +function collectBlockingFields( + errors: CronFieldErrors, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +): BlockingField[] { + const orderedKeys: CronFieldKey[] = [ + "name", + "scheduleAt", + "everyAmount", + "cronExpr", + "staggerAmount", + "payloadText", + "payloadModel", + "payloadThinking", + "timeoutSeconds", + "deliveryTo", + ]; + const fields: BlockingField[] = []; + for (const key of orderedKeys) { + const message = errors[key]; + if (!message) { + continue; + } + fields.push({ + key, + label: fieldLabelForKey(key, form, deliveryMode), + message, + inputId: inputIdForField(key), + }); + } + return fields; +} + +function focusFormField(id: string) { + const el = document.getElementById(id); + if (!(el instanceof HTMLElement)) { + return; + } + if (typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } + el.focus(); +} + +function renderFieldLabel(text: string, required = false) { + return html` + ${text} + ${ + required + ? html` + + required + ` + : nothing + } + `; +} + export function renderCron(props: CronProps) { + const isEditing = Boolean(props.editingJobId); + const isAgentTurn = props.form.payloadKind === "agentTurn"; + const isCronSchedule = props.form.scheduleKind === "cron"; const channelOptions = buildChannelOptions(props); const selectedJob = props.runsJobId == null ? undefined : props.jobs.find((job) => job.id === props.runsJobId); - const selectedRunTitle = selectedJob?.name ?? props.runsJobId ?? "(select a job)"; - const orderedRuns = props.runs.toSorted((a, b) => b.ts - a.ts); + const selectedRunTitle = + props.runsScope === "all" + ? "all jobs" + : (selectedJob?.name ?? props.runsJobId ?? "(select a job)"); + const runs = props.runs; + const selectedStatusLabels = RUN_STATUS_OPTIONS.filter((option) => + props.runsStatuses.includes(option.value), + ).map((option) => option.label); + const selectedDeliveryLabels = RUN_DELIVERY_OPTIONS.filter((option) => + props.runsDeliveryStatuses.includes(option.value), + ).map((option) => option.label); + const statusSummary = summarizeSelection(selectedStatusLabels, "All statuses"); + const deliverySummary = summarizeSelection(selectedDeliveryLabels, "All delivery"); const supportsAnnounce = props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; + const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); + const blockedByValidation = !props.busy && blockingFields.length > 0; + const submitDisabledReason = + blockedByValidation && !props.canSubmit + ? `Fix ${blockingFields.length} ${blockingFields.length === 1 ? "field" : "fields"} to continue.` + : ""; return html` -
-
-
Scheduler
-
Gateway-owned cron scheduler status.
-
-
-
Enabled
-
+
+
+
+
Enabled
+
+ ${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"} -
-
-
-
Jobs
-
${props.status?.jobs ?? "n/a"}
-
-
-
Next wake
-
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
-
- - ${props.error ? html`${props.error}` : nothing} +
+
Jobs
+
${props.status?.jobs ?? "n/a"}
+
+
+
Next wake
+
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
+ + ${props.error ? html`${props.error}` : nothing} +
+
-
-
New Job
-
Create a scheduled wakeup or agent run.
-
- - - - - -
- ${renderScheduleFields(props)} -
- - - -
- -
- + + + +
+
+ +
+
Schedule
+
Control when this job runs.
+
+ +
+ ${renderScheduleFields(props)} +
+ +
+
Execution
+
Choose when to wake, and what this job should do.
+
+ + + ${ - supportsAnnounce + isAgentTurn ? html` - + ` : nothing } - - - - - ${ - props.form.payloadKind === "agentTurn" - ? html` - - ` - : nothing - } - ${ - selectedDeliveryMode !== "none" - ? html` - +
+ +
+ +
+
Delivery
+
Choose where run summaries are sent.
+
+ + ` : nothing } + + + +
Announce posts a summary to chat. None keeps execution internal.
+ + ${ + selectedDeliveryMode !== "none" + ? html` + + ${ + selectedDeliveryMode === "announce" + ? html` + + ` + : nothing + } + ${ + selectedDeliveryMode === "webhook" + ? renderFieldError( + props.fieldErrors.deliveryTo, + errorIdForField("deliveryTo"), + ) + : nothing + } + ` + : nothing + } +
+
+ +
+ Advanced +
+ Optional overrides for delivery guarantees, schedule jitter, and model controls. +
+
+ + + ${ + isCronSchedule + ? html` + +
+ + +
+ ` + : nothing + } + ${ + isAgentTurn + ? html` + + + ` + : nothing + } + ${ + selectedDeliveryMode !== "none" + ? html` + + ` + : nothing + } +
+
+
+ ${ + blockedByValidation + ? html` +
+
Can't add job yet
+
Fill the required fields below to enable submit.
+
    + ${blockingFields.map( + (field) => html` +
  • + +
  • + `, + )} +
+
+ ` + : nothing + } +
+ + ${ + submitDisabledReason + ? html`
${submitDisabledReason}
` + : nothing + } + ${ + isEditing + ? html` + ` : nothing }
-
- -
- + -
-
Jobs
-
All scheduled jobs stored in the gateway.
- ${ - props.jobs.length === 0 - ? html` -
No jobs yet.
- ` - : html` -
- ${props.jobs.map((job) => renderJob(job, props))} -
- ` - } -
- -
-
Run history
-
Latest runs for ${selectedRunTitle}.
- ${ - props.runsJobId == null - ? html` -
Select a job to inspect run history.
- ` - : orderedRuns.length === 0 - ? html` -
No runs yet.
- ` - : html` -
- ${orderedRuns.map((entry) => renderRun(entry, props.basePath))} -
- ` - } -
+ ${renderSuggestionList("cron-agent-suggestions", props.agentSuggestions)} + ${renderSuggestionList("cron-model-suggestions", props.modelSuggestions)} + ${renderSuggestionList("cron-thinking-suggestions", props.thinkingSuggestions)} + ${renderSuggestionList("cron-tz-suggestions", props.timezoneSuggestions)} + ${renderSuggestionList("cron-delivery-to-suggestions", props.deliveryToSuggestions)} `; } @@ -346,31 +1117,44 @@ function renderScheduleFields(props: CronProps) { const form = props.form; if (form.scheduleKind === "at") { return html` -