From 0872b505b00e1a80e02ff9c0ef1bd9fc5227d0e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 22:49:03 +0100 Subject: [PATCH] fix(cron): clarify no-delivery previews --- CHANGELOG.md | 1 + apps/macos/Sources/OpenClaw/SessionData.swift | 7 ++++++- .../OpenClawIPCTests/SessionDataTests.swift | 2 ++ src/commands/sessions.ts | 6 +++++- src/commands/status.command-sections.test.ts | 21 +++++++++++++++++++ src/commands/status.summary.runtime.test.ts | 9 ++++++++ src/commands/status.summary.runtime.ts | 4 ++++ src/commands/status.types.ts | 2 +- src/cron/delivery-preview.test.ts | 15 +++++++++++++ src/cron/delivery-preview.ts | 2 +- ui/src/styles/components.css | 5 +++++ ui/src/ui/controllers/sessions.ts | 6 +++++- ui/src/ui/types.ts | 2 +- ui/src/ui/views/sessions.test.ts | 20 ++++++++++++++++++ ui/src/ui/views/sessions.ts | 16 +++++++------- 15 files changed, 105 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbab5ffd7c..b122e8e1bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc. - Channels/streaming: normalize whitespace and case for `streaming.progress.label: "auto"` so progress draft labels keep using the built-in label pool instead of rendering a literal `auto` title. Thanks @vincentkoc. - Gateway/install: prefer supported system Node over nvm/fnm/volta/asdf/mise when regenerating managed gateway services, so `gateway install --force` no longer recreates service definitions that doctor immediately flags as version-manager-backed. Fixes #76339. Thanks @brokemac79. +- Cron/status: render explicit `delivery.mode: "none"` jobs as no-delivery previews and label cron session history distinctly instead of showing fallback delivery or direct-session rows. Fixes #76945. - Gateway/usage: serve `usage.cost` and `sessions.usage` from a durable transcript aggregate cache with lock-safe background refreshes and localized stale-cache status, so large usage views avoid repeated full scans. (#76650) Thanks @Marvinthebored. - Plugins/hooks: let `plugins.entries..hooks.timeoutMs` and `plugins.entries..hooks.timeouts` bound plugin typed hooks from operator config, so slow hooks can be tuned without patching installed plugin code. Fixes #76778. Thanks @vincentkoc. - Telegram: add `channels.telegram.mediaGroupFlushMs` at the top level and per account so operators can tune album buffering instead of being stuck with the hard-coded 500ms media-group flush window. Fixes #76149. Thanks @vincentkoc. diff --git a/apps/macos/Sources/OpenClaw/SessionData.swift b/apps/macos/Sources/OpenClaw/SessionData.swift index 8234cbdef85..2aab6dc01d9 100644 --- a/apps/macos/Sources/OpenClaw/SessionData.swift +++ b/apps/macos/Sources/OpenClaw/SessionData.swift @@ -103,10 +103,13 @@ struct SessionRow: Identifiable { } enum SessionKind { - case direct, group, global, unknown + case cron, direct, group, global, unknown static func from(key: String) -> SessionKind { if key == "global" { return .global } + let parts = key.lowercased().split(separator: ":").filter { !$0.isEmpty } + if parts.first == "cron" { return .cron } + if parts.count >= 3, parts[0] == "agent", parts[2] == "cron" { return .cron } if key.hasPrefix("group:") { return .group } if key.contains(":group:") { return .group } if key.contains(":channel:") { return .group } @@ -116,6 +119,7 @@ enum SessionKind { var label: String { switch self { + case .cron: "Cron" case .direct: "Direct" case .group: "Group" case .global: "Global" @@ -125,6 +129,7 @@ enum SessionKind { var tint: Color { switch self { + case .cron: .green case .direct: .accentColor case .group: .orange case .global: .purple diff --git a/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift b/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift index c8e3a812b09..42f2ad003e0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift @@ -5,6 +5,8 @@ import Testing struct SessionDataTests { @Test func `session kind from key detects common kinds`() { #expect(SessionKind.from(key: "global") == .global) + #expect(SessionKind.from(key: "cron:daily") == .cron) + #expect(SessionKind.from(key: "agent:main:cron:daily") == .cron) #expect(SessionKind.from(key: "discord:group:engineering") == .group) #expect(SessionKind.from(key: "unknown") == .unknown) #expect(SessionKind.from(key: "user@example.com") == .direct) diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index db4c00c73c4..2c405c27cbd 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -5,6 +5,7 @@ import { loadSessionStore, resolveSessionTotalTokens } from "../config/sessions. import { info } from "../globals.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { isRich, theme } from "../terminal/theme.js"; import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js"; @@ -27,7 +28,7 @@ import { type SessionRow = SessionDisplayRow & { agentId: string; - kind: "direct" | "group" | "global" | "unknown"; + kind: "cron" | "direct" | "group" | "global" | "unknown"; agentRuntime: ReturnType; }; @@ -84,6 +85,9 @@ function classifySessionKey(key: string, entry?: { chatType?: string | null }): if (key === "unknown") { return "unknown"; } + if (isCronSessionKey(key)) { + return "cron"; + } if (entry?.chatType === "group" || entry?.chatType === "channel") { return "group"; } diff --git a/src/commands/status.command-sections.test.ts b/src/commands/status.command-sections.test.ts index f839b8c7f10..398ee1b53af 100644 --- a/src/commands/status.command-sections.test.ts +++ b/src/commands/status.command-sections.test.ts @@ -70,6 +70,19 @@ describe("status.command-sections", () => { contextTokens: null, flags: [], }, + { + key: "agent:main:cron:daily-digest", + kind: "cron", + updatedAt: 2, + age: 7_000, + model: "gpt-5.5", + totalTokens: null, + totalTokensFresh: false, + remainingTokens: null, + percentUsed: null, + contextTokens: null, + flags: [], + }, ], verbose: true, shortenText: (value) => value.slice(0, 8), @@ -88,6 +101,14 @@ describe("status.command-sections", () => { Tokens: "12k", Cache: "cache ok", }, + { + Key: "agent:ma", + Kind: "cron", + Age: "7000ms", + Model: "gpt-5.5", + Tokens: "12k", + Cache: "cache ok", + }, ]); const emptyRows = buildStatusSessionsRows({ diff --git a/src/commands/status.summary.runtime.test.ts b/src/commands/status.summary.runtime.test.ts index 6c9cf27bbfc..bfb81c2a9f7 100644 --- a/src/commands/status.summary.runtime.test.ts +++ b/src/commands/status.summary.runtime.test.ts @@ -41,6 +41,15 @@ describe("statusSummaryRuntime.resolveContextTokensForModel", () => { }); }); +describe("statusSummaryRuntime.classifySessionKey", () => { + it("classifies cron history sessions distinctly", () => { + expect(statusSummaryRuntime.classifySessionKey("agent:main:cron:daily-digest")).toBe("cron"); + expect( + statusSummaryRuntime.classifySessionKey("agent:avery:cron:daily-digest:run:abc123"), + ).toBe("cron"); + }); +}); + describe("statusSummaryRuntime.resolveSessionModelRef", () => { const cfg = { agents: { diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index bbfad0139bb..0f9dda8799d 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -5,6 +5,7 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.js"; +import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -129,6 +130,9 @@ function classifySessionKey(key: string, entry?: SessionEntry) { if (key === "unknown") { return "unknown"; } + if (isCronSessionKey(key)) { + return "cron"; + } if (entry?.chatType === "group" || entry?.chatType === "channel") { return "group"; } diff --git a/src/commands/status.types.ts b/src/commands/status.types.ts index 0aa2c899289..c16f164b3ed 100644 --- a/src/commands/status.types.ts +++ b/src/commands/status.types.ts @@ -5,7 +5,7 @@ import type { TaskRegistrySummary } from "../tasks/task-registry.types.js"; export type SessionStatus = { agentId?: string; key: string; - kind: "direct" | "group" | "global" | "unknown"; + kind: "cron" | "direct" | "group" | "global" | "unknown"; sessionId?: string; updatedAt: number | null; age: number | null; diff --git a/src/cron/delivery-preview.test.ts b/src/cron/delivery-preview.test.ts index 31e5ea0a78b..f6f9ee9df8b 100644 --- a/src/cron/delivery-preview.test.ts +++ b/src/cron/delivery-preview.test.ts @@ -51,4 +51,19 @@ describe("resolveCronDeliveryPreview", () => { "resolved from last, session agent:avery:telegram:direct:direct-123", ); }); + + it("does not resolve routes for explicit no-delivery jobs", async () => { + const job = makeCronJob({ + delivery: { mode: "none" }, + sessionTarget: "isolated", + }); + + const preview = await resolveCronDeliveryPreview({ + cfg: {} as never, + job, + }); + + expect(preview).toEqual({ label: "not requested", detail: "not requested" }); + expect(mocks.resolveDeliveryTarget).not.toHaveBeenCalled(); + }); }); diff --git a/src/cron/delivery-preview.ts b/src/cron/delivery-preview.ts index 2cac095873e..73991fc50b6 100644 --- a/src/cron/delivery-preview.ts +++ b/src/cron/delivery-preview.ts @@ -40,7 +40,7 @@ export async function resolveCronDeliveryPreview(params: { job: CronJob; }): Promise { const plan = resolveCronDeliveryPlan(params.job); - if (!plan.requested && plan.mode === "none" && !params.job.delivery) { + if (plan.mode === "none") { return { label: "not requested", detail: "not requested" }; } if (plan.mode === "webhook") { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d42e8cdfa1b..b7c7bd80aac 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -2404,6 +2404,11 @@ background: rgba(59, 130, 246, 0.1); } +.data-table-badge--cron { + color: var(--success); + background: rgba(34, 197, 94, 0.1); +} + .data-table-badge--global { color: var(--warn); background: var(--warn-subtle); diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 68489d0e64f..7ad98f64f14 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -118,7 +118,11 @@ function hasOwn(record: Record, key: string): boolean { } function normalizeSessionKind(value: unknown): GatewaySessionRow["kind"] | undefined { - return value === "direct" || value === "group" || value === "global" || value === "unknown" + return value === "cron" || + value === "direct" || + value === "group" || + value === "global" || + value === "unknown" ? value : undefined; } diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index dd892c0e4a3..783ea649f96 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -417,7 +417,7 @@ export type SessionCompactionCheckpointPreview = Pick< export type GatewaySessionRow = { key: string; spawnedBy?: string; - kind: "direct" | "group" | "global" | "unknown"; + kind: "cron" | "direct" | "group" | "global" | "unknown"; label?: string; displayName?: string; surface?: string; diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 91f578309f3..68a1f6f48e6 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -220,6 +220,26 @@ describe("sessions view", () => { expect(keyCell?.getAttribute("title")).toBe("agent:unknown-agent:telegram:abc123"); }); + it("renders cron session kind distinctly", async () => { + const container = document.createElement("div"); + render( + renderSessions( + buildProps( + buildResult({ + key: "agent:main:cron:daily-digest", + kind: "cron", + updatedAt: Date.now(), + }), + ), + ), + container, + ); + await Promise.resolve(); + + const badge = container.querySelector(".data-table-badge--cron"); + expect(badge?.textContent?.trim()).toBe("cron"); + }); + it("keeps raw keys for inherited identity object properties", async () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 4f5a60e1635..b3574107a52 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -519,13 +519,15 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) { ? `${pathForTab("chat", props.basePath)}?session=${encodeURIComponent(row.key)}` : null; const badgeClass = - row.kind === "direct" - ? "data-table-badge--direct" - : row.kind === "group" - ? "data-table-badge--group" - : row.kind === "global" - ? "data-table-badge--global" - : "data-table-badge--unknown"; + row.kind === "cron" + ? "data-table-badge--cron" + : row.kind === "direct" + ? "data-table-badge--direct" + : row.kind === "group" + ? "data-table-badge--group" + : row.kind === "global" + ? "data-table-badge--global" + : "data-table-badge--unknown"; return [ html`