From d02e4f28191677493259ec4c52425dc97e51faf4 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 23 Aug 2026 13:36:11 -0400 Subject: [PATCH 1/2] fix(messaging): stabilize detailed channel status JSON Signed-off-by: Julie Yaunches --- .../actions/sandbox/channel-status.test.ts | 30 ++++-- src/lib/actions/sandbox/channel-status.ts | 61 ++++++++---- test/cli/channel-status-json.test.ts | 98 +++++++++++++++++++ 3 files changed, 162 insertions(+), 27 deletions(-) create mode 100644 test/cli/channel-status-json.test.ts diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index f92f4fabd6c..d7d0917938e 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -382,23 +382,22 @@ describe("showSandboxChannelStatus (whatsapp)", () => { deps.execSandbox = execSpy as unknown as typeof deps.execSandbox; const result = await showSandboxChannelStatus("alpha", { deps, channel: "whatsapp" }); expect(execSpy).not.toHaveBeenCalled(); - expect(result && "verdict" in result && result.verdict).toBe("info"); + expect(result && "report" in result && result.report.verdict).toBe("info"); const dump = out_lines.join("\n"); expect(dump).toMatch(/registered but currently paused/); // The paused fallback must not claim it is the summary view nor tell the // operator to rerun the --channel command they are already running (#6887). const runtime = - result && "signals" in result - ? result.signals.find((s) => s.label === "Runtime health") + result && "report" in result + ? result.report.signals.find((s) => s.label === "Runtime health") : undefined; expect(runtime?.detail).toBe("not checked — whatsapp is currently paused"); expect(runtime?.hint).toBeUndefined(); }); it("labels a paused telegram channel as paused rather than summary view under --channel (#6887)", async () => { - // A probe-capable channel that is paused lands on the basic report even - // under an explicit --channel request, since the probe is gated on - // !channelIsPaused. The Runtime health signal must reflect the paused state. + // A probe-capable channel that is paused skips the live probe but keeps the + // detailed envelope. The Runtime health signal must reflect the paused state. const execSpy = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); const { deps } = makeDeps({ exec: () => ({ status: 0, stdout: "", stderr: "" }), @@ -412,11 +411,26 @@ describe("showSandboxChannelStatus (whatsapp)", () => { .join("\n"); expect(probeCommands).not.toMatch(/gateway\.log|pgrep/); const runtime = - result && "signals" in result - ? result.signals.find((s) => s.label === "Runtime health") + result && "report" in result + ? result.report.signals.find((s) => s.label === "Runtime health") : undefined; expect(runtime?.detail).toBe("not checked — telegram is currently paused"); expect(runtime?.hint).toBeUndefined(); + expect(result).toEqual({ + schemaVersion: 1, + sandbox: "alpha", + channel: "telegram", + report: { + schemaVersion: 1, + agent: "openclaw", + channel: "telegram", + verdict: "info", + probedAt: "2026-05-28T04:00:00.000Z", + signals: expect.any(Array), + hints: expect.any(Array), + }, + }); + expect(result && (await import("./channel-status")).exitCodeFor(result)).toBe(0); }); }); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index eb6d2261678..4db8b562dbd 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -84,20 +84,22 @@ export type ChannelStatusOptions = { deps?: StatusDeps; }; -type ChannelStatusSingleReport = - | { - schemaVersion: 1; - sandbox: string; - channel: string; - report: ChannelHealthReport; - } - | { - schemaVersion: 1; - sandbox: string; - channel: string; - verdict: "info"; - signals: DiagnosticSignal[]; - }; +type ChannelStatusDetailedReport = { + schemaVersion: 1; + sandbox: string; + channel: string; + report: ChannelHealthReport; +}; + +type ChannelStatusBasicReport = { + schemaVersion: 1; + sandbox: string; + channel: string; + verdict: "info"; + signals: DiagnosticSignal[]; +}; + +type ChannelStatusSingleReport = ChannelStatusDetailedReport | ChannelStatusBasicReport; type ChannelStatusSnapshotReport = | ChannelStatusSingleReport @@ -279,6 +281,7 @@ export function exitCodeFor(report: ChannelStatusReport): number { if ("report" in report) { switch (report.report.verdict) { case "healthy": + case "info": case "unknown": return 0; default: @@ -295,7 +298,7 @@ function buildBasicChannelReport( deps: Required, diagnostic: MessagingChannelDiagnosticSpec, options: { readonly includeDeepDiagnostics?: boolean; readonly channelPaused?: boolean } = {}, -): ChannelStatusSingleReport { +): ChannelStatusBasicReport { const entry = deps.getSandbox(sandboxName); const enabled = registry.getConfiguredMessagingChannelsFromEntry(entry).includes(channelName); const disabled = registry.getDisabledMessagingChannelsFromEntry(entry).includes(channelName); @@ -372,7 +375,7 @@ function buildBasicChannelReport( function buildUnknownConfiguredChannelReport( sandboxName: string, channelName: string, -): ChannelStatusSingleReport { +): ChannelStatusBasicReport { return { schemaVersion: 1, sandbox: sandboxName, @@ -494,9 +497,29 @@ function collectChannelReport( ) : undefined; if (!healthReport) { - return buildBasicChannelReport(sandboxName, channelName, agent, collectionDeps, diagnostic, { - channelPaused: channelIsPaused, - }); + const basicReport = buildBasicChannelReport( + sandboxName, + channelName, + agent, + collectionDeps, + diagnostic, + { channelPaused: channelIsPaused }, + ); + if (!hasHealthHook) return basicReport; + return { + schemaVersion: 1, + sandbox: sandboxName, + channel: channelName, + report: { + schemaVersion: 1, + agent: agent.name, + channel: channelName, + verdict: basicReport.verdict, + probedAt: collectionDeps.now().toISOString(), + signals: basicReport.signals, + hints: basicReport.signals.flatMap((signal) => (signal.hint ? [signal.hint] : [])), + }, + }; } const configSignals = buildConfigStatusSignals( sandboxName, diff --git a/test/cli/channel-status-json.test.ts b/test/cli/channel-status-json.test.ts new file mode 100644 index 00000000000..1f35c57ab03 --- /dev/null +++ b/test/cli/channel-status-json.test.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { expect } from "vitest"; + +import { test as it } from "../helpers/owned-test-resources"; +import { makeMessagingPlan } from "../helpers/messaging-plan-fixtures"; +import { runWithEnv, writeSandboxRegistry } from "./helpers"; + +it("keeps the detailed JSON envelope when paused Telegram skips its live probe (#10015)", ({ + testHome, +}) => { + const { home, bin } = testHome; + const sandboxName = "my-assistant"; + const openshell = path.join(bin, "openshell"); + const calls = path.join(home, "openshell.calls"); + fs.mkdirSync(bin, { recursive: true }); + writeSandboxRegistry(home, sandboxName, { + agent: "openclaw", + policies: ["telegram"], + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan({ + sandboxName, + channels: ["telegram"], + disabledChannels: ["telegram"], + }), + }, + }); + fs.writeFileSync( + openshell, + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(calls)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ]; then', + ` printf '%s\\n' ${JSON.stringify( + JSON.stringify({ + channels: { + telegram: { + enabled: true, + groupPolicy: "allowlist", + groups: { "*": { requireMention: true } }, + }, + }, + }), + )}`, + " exit 0", + "fi", + "exit 1", + ].join("\n"), + { mode: 0o755 }, + ); + + const result = runWithEnv( + `${sandboxName} channels status --channel telegram --json`, + testHome.environment({ NEMOCLAW_OPENSHELL_BIN: openshell }), + ); + + expect(result.code).toBe(0); + const status = JSON.parse(result.out) as Record; + expect(Object.keys(status).sort()).toEqual(["channel", "report", "sandbox", "schemaVersion"]); + const report = status.report as Record; + expect(Object.keys(report).sort()).toEqual([ + "agent", + "channel", + "hints", + "probedAt", + "schemaVersion", + "signals", + "verdict", + ]); + expect(report).toMatchObject({ + schemaVersion: 1, + agent: "openclaw", + channel: "telegram", + verdict: "info", + hints: expect.any(Array), + }); + expect(report.probedAt).toEqual(expect.any(String)); + expect(report.signals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "Channel registration", + severity: "warn", + detail: "telegram registered but currently paused", + }), + expect.objectContaining({ + label: "Runtime health", + severity: "info", + detail: "not checked — telegram is currently paused", + }), + ]), + ); + const invoked = fs.existsSync(calls) ? fs.readFileSync(calls, "utf8") : ""; + expect(invoked).not.toMatch(/gateway\.log|pgrep/); +}); From 00784a8b19ae1e9207305e393a2be7aa8a68597a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 10:59:59 -0700 Subject: [PATCH 2/2] fix(messaging): render informational channel status --- src/lib/actions/sandbox/channel-status.test.ts | 1 + src/lib/actions/sandbox/channel-status.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index d7d0917938e..aed49b0d89f 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -385,6 +385,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(result && "report" in result && result.report.verdict).toBe("info"); const dump = out_lines.join("\n"); expect(dump).toMatch(/registered but currently paused/); + expect(dump).toMatch(/Verdict:.*info/); // The paused fallback must not claim it is the summary view nor tell the // operator to rerun the --channel command they are already running (#6887). const runtime = diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 4db8b562dbd..ff1feabb1d9 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -259,6 +259,8 @@ function renderSingleChannelSignals( ? G : report.report.verdict === "idle" || report.report.verdict === "unpaired" ? YW + : report.report.verdict === "info" + ? D : RD; deps.out(` Verdict: ${verdictColor}${report.report.verdict}${R}`); for (const hint of report.report.hints) {