Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions src/lib/actions/sandbox/channel-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,23 +382,23 @@ 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/);
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 =
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: "" }),
Expand All @@ -412,11 +412,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);
});
});

Expand Down
63 changes: 44 additions & 19 deletions src/lib/actions/sandbox/channel-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -257,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) {
Expand All @@ -279,6 +283,7 @@ export function exitCodeFor(report: ChannelStatusReport): number {
if ("report" in report) {
switch (report.report.verdict) {
case "healthy":
case "info":
case "unknown":
return 0;
default:
Expand All @@ -295,7 +300,7 @@ function buildBasicChannelReport(
deps: Required<StatusDeps>,
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);
Expand Down Expand Up @@ -372,7 +377,7 @@ function buildBasicChannelReport(
function buildUnknownConfiguredChannelReport(
sandboxName: string,
channelName: string,
): ChannelStatusSingleReport {
): ChannelStatusBasicReport {
return {
schemaVersion: 1,
sandbox: sandboxName,
Expand Down Expand Up @@ -494,9 +499,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,
Expand Down
98 changes: 98 additions & 0 deletions test/cli/channel-status-json.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
expect(Object.keys(status).sort()).toEqual(["channel", "report", "sandbox", "schemaVersion"]);
const report = status.report as Record<string, unknown>;
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/);
});
Loading