mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix: harden gateway recovery diagnostics and media delivery
Harden gateway recovery diagnostics and media delivery.\n\n- Accept gateway send asVoice and map it to outbound audioAsVoice.\n- Preserve generated Swift protocol models for the gateway send schema.\n- Keep the broader recovery hardening for install/update/status/vector/TTS paths in one reviewed PR.\n\nProof:\n- Focused local gateway/outbound/update/status/doctor/sqlite-vec tests passed.\n- oxfmt --check and git diff --check passed.\n- Testbox OPENCLAW_TESTBOX=1 pnpm check:changed passed at 2f5ef650e97763a61ff43c28e61707db84c50060.\n- GitHub required checks are green at the merge SHA; the qa-lab parity gate is optional/surface-only and was still pending.
This commit is contained in:
@@ -473,6 +473,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let message: String?
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let asvoice: Bool?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -487,6 +488,7 @@ public struct SendParams: Codable, Sendable {
|
||||
message: String?,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
asvoice: Bool?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -500,6 +502,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.asvoice = asvoice
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -515,6 +518,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case asvoice = "asVoice"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
|
||||
@@ -473,6 +473,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let message: String?
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let asvoice: Bool?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -487,6 +488,7 @@ public struct SendParams: Codable, Sendable {
|
||||
message: String?,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
asvoice: Bool?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -500,6 +502,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.asvoice = asvoice
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -515,6 +518,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case asvoice = "asVoice"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
|
||||
25
packages/memory-host-sdk/src/host/sqlite-vec.test.ts
Normal file
25
packages/memory-host-sdk/src/host/sqlite-vec.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("sqlite-vec", () => {
|
||||
throw new Error("bundled sqlite-vec should not load when extensionPath is explicit");
|
||||
});
|
||||
|
||||
import { loadSqliteVecExtension } from "./sqlite-vec.js";
|
||||
|
||||
describe("loadSqliteVecExtension", () => {
|
||||
it("loads explicit extensionPath without importing bundled sqlite-vec", async () => {
|
||||
const db = {
|
||||
enableLoadExtension: vi.fn(),
|
||||
loadExtension: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
loadSqliteVecExtension({
|
||||
db: db as never,
|
||||
extensionPath: "/opt/openclaw/sqlite-vec.so",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, extensionPath: "/opt/openclaw/sqlite-vec.so" });
|
||||
expect(db.enableLoadExtension).toHaveBeenCalledWith(true);
|
||||
expect(db.loadExtension).toHaveBeenCalledWith("/opt/openclaw/sqlite-vec.so");
|
||||
});
|
||||
});
|
||||
@@ -18,17 +18,16 @@ export async function loadSqliteVecExtension(params: {
|
||||
extensionPath?: string;
|
||||
}): Promise<{ ok: boolean; extensionPath?: string; error?: string }> {
|
||||
try {
|
||||
const sqliteVec = await loadSqliteVecModule();
|
||||
const resolvedPath = normalizeOptionalString(params.extensionPath);
|
||||
const extensionPath = resolvedPath ?? sqliteVec.getLoadablePath();
|
||||
|
||||
params.db.enableLoadExtension(true);
|
||||
if (resolvedPath) {
|
||||
params.db.loadExtension(extensionPath);
|
||||
} else {
|
||||
sqliteVec.load(params.db);
|
||||
params.db.loadExtension(resolvedPath);
|
||||
return { ok: true, extensionPath: resolvedPath };
|
||||
}
|
||||
|
||||
const sqliteVec = await loadSqliteVecModule();
|
||||
const extensionPath = sqliteVec.getLoadablePath();
|
||||
sqliteVec.load(params.db);
|
||||
return { ok: true, extensionPath };
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
|
||||
@@ -142,6 +142,45 @@ exit 0
|
||||
await cleanupScript(scriptPath);
|
||||
});
|
||||
|
||||
it("fails with sudo systemd guidance when the gateway unit is system-scoped", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
const tmpDir = await makeTempDir("openclaw-restart-helper-");
|
||||
const fakeBinDir = path.join(tmpDir, "bin");
|
||||
const callsPath = path.join(tmpDir, "systemctl-calls.log");
|
||||
await fs.mkdir(fakeBinDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(fakeBinDir, "systemctl"),
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "$OPENCLAW_SYSTEMCTL_CALLS"
|
||||
if [ "$1" = "--user" ] && [ "$2" = "is-active" ]; then exit 3; fi
|
||||
if [ "$1" = "--user" ] && [ "$2" = "is-enabled" ]; then exit 1; fi
|
||||
if [ "$1" = "is-active" ] && [ "$2" = "--quiet" ]; then exit 0; fi
|
||||
if [ "$1" = "is-enabled" ] && [ "$2" = "--quiet" ]; then exit 0; fi
|
||||
if [ "$1" = "--user" ] && [ "$2" = "restart" ]; then exit 99; fi
|
||||
exit 1
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const { scriptPath } = await prepareAndReadScript({
|
||||
OPENCLAW_PROFILE: "default",
|
||||
HOME: path.join(tmpDir, "home"),
|
||||
OPENCLAW_STATE_DIR: path.join(tmpDir, "state"),
|
||||
});
|
||||
const result = await executeScript(scriptPath, {
|
||||
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
|
||||
OPENCLAW_SYSTEMCTL_CALLS: callsPath,
|
||||
});
|
||||
const calls = await fs.readFile(callsPath, "utf-8");
|
||||
|
||||
expect(result.code).toBe(78);
|
||||
expect(result.stderr).toContain("system-scoped openclaw gateway unit detected");
|
||||
expect(result.stderr).toContain("sudo systemctl restart openclaw-gateway.service");
|
||||
expect(calls).toContain("--user is-active --quiet openclaw-gateway.service");
|
||||
expect(calls).toContain("is-active --quiet openclaw-gateway.service");
|
||||
expect(calls).not.toContain("--user restart openclaw-gateway.service");
|
||||
});
|
||||
|
||||
it("creates a launchd restart script on macOS", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
process.getuid = () => 501;
|
||||
|
||||
@@ -85,16 +85,32 @@ export async function prepareRestartScript(
|
||||
# Standalone restart script — survives parent process termination.
|
||||
# Wait briefly to ensure file locks are released after update.
|
||||
sleep 1
|
||||
exec 3>&2
|
||||
${logSetup}
|
||||
printf '[%s] openclaw restart attempt source=update target=%s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&2
|
||||
if systemctl --user restart '${escaped}'; then
|
||||
status=0
|
||||
printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2
|
||||
if systemctl --user is-active --quiet '${escaped}' || systemctl --user is-enabled --quiet '${escaped}'; then
|
||||
if systemctl --user restart '${escaped}'; then
|
||||
status=0
|
||||
printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2
|
||||
else
|
||||
status=$?
|
||||
printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
|
||||
fi
|
||||
elif systemctl is-active --quiet '${escaped}' || systemctl is-enabled --quiet '${escaped}'; then
|
||||
status=78
|
||||
printf '[%s] system-scoped openclaw gateway unit detected; update cannot restart it without sudo. Run: sudo systemctl restart %s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&2
|
||||
printf '[%s] system-scoped openclaw gateway unit detected; update cannot restart it without sudo. Run: sudo systemctl restart %s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&3 2>/dev/null || true
|
||||
else
|
||||
status=$?
|
||||
printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
|
||||
if systemctl --user restart '${escaped}'; then
|
||||
status=0
|
||||
printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2
|
||||
else
|
||||
status=$?
|
||||
printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
|
||||
fi
|
||||
fi
|
||||
# Self-cleanup
|
||||
exec 3>&-
|
||||
rm -f "$0"
|
||||
exit "$status"
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
@@ -901,6 +904,38 @@ describe("maybeRepairGatewayServiceConfig", () => {
|
||||
expect(mocks.install).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when the gateway service entrypoint resolves to a source checkout", async () => {
|
||||
await withEnvAsync({}, async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-service-layout-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(root, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "src"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "extensions"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "0.0.0-test" }),
|
||||
"utf8",
|
||||
);
|
||||
const entrypoint = path.join(root, "dist", "index.js");
|
||||
await fs.writeFile(entrypoint, "export {};\n", "utf8");
|
||||
mocks.readCommand.mockResolvedValue(createGatewayCommand(entrypoint));
|
||||
mocks.auditGatewayServiceConfig.mockResolvedValue({ ok: true, issues: [] });
|
||||
mocks.buildGatewayInstallPlan.mockResolvedValue(createGatewayCommand(entrypoint));
|
||||
|
||||
await runRepair({ gateway: {} });
|
||||
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("resolves to a source checkout"),
|
||||
"Gateway service config",
|
||||
);
|
||||
expect(mocks.install).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeScanExtraGatewayServices", () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readEmbeddedGatewayToken,
|
||||
SERVICE_AUDIT_CODES,
|
||||
} from "../daemon/service-audit.js";
|
||||
import { summarizeGatewayServiceLayout } from "../daemon/service-layout.js";
|
||||
import { readManagedServiceEnvKeysFromEnvironment } from "../daemon/service-managed-env.js";
|
||||
import { resolveGatewayService, type GatewayServiceCommandConfig } from "../daemon/service.js";
|
||||
import {
|
||||
@@ -367,6 +368,16 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
if (serviceWrapperPath) {
|
||||
note(`Gateway service invokes ${OPENCLAW_WRAPPER_ENV_KEY}: ${serviceWrapperPath}`, "Gateway");
|
||||
}
|
||||
const serviceLayout = await summarizeGatewayServiceLayout(command);
|
||||
if (serviceLayout?.entrypointSourceCheckout) {
|
||||
note(
|
||||
[
|
||||
`Gateway service entrypoint resolves to a source checkout: ${serviceLayout.packageRootReal ?? serviceLayout.packageRoot ?? serviceLayout.entrypointReal ?? serviceLayout.entrypoint}.`,
|
||||
"Run `openclaw doctor --fix` from the intended package install, or reinstall the gateway service with `openclaw gateway install --force`.",
|
||||
].join("\n"),
|
||||
"Gateway service config",
|
||||
);
|
||||
}
|
||||
|
||||
const tokenRefConfigured = Boolean(
|
||||
resolveSecretInputRef({
|
||||
|
||||
@@ -590,6 +590,35 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("classifies daemon health ECONNREFUSED failures with a recovery command", async () => {
|
||||
await withStateDir("state-local-daemon-health-refused-", async (stateDir) => {
|
||||
waitForGatewayReachableMock = vi.fn(async () => ({
|
||||
ok: false,
|
||||
detail: "connect ECONNREFUSED 127.0.0.1:18789",
|
||||
}));
|
||||
gatewayServiceMock.readRuntime.mockResolvedValueOnce({
|
||||
status: "stopped",
|
||||
state: "failed",
|
||||
pid: 0,
|
||||
});
|
||||
readLastGatewayErrorLineMock.mockResolvedValueOnce("");
|
||||
|
||||
const { runtimeWithCapture, readCapturedJson } = createJsonCaptureRuntime();
|
||||
await expectLocalJsonSetupFailure(stateDir, runtimeWithCapture);
|
||||
|
||||
const parsed = JSON.parse(readCapturedJson()) as {
|
||||
ok: boolean;
|
||||
phase: string;
|
||||
classification?: string;
|
||||
hints?: string[];
|
||||
};
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.phase).toBe("gateway-health");
|
||||
expect(parsed.classification).toBe("service-stopped");
|
||||
expect(parsed.hints).toContain("Fix: run `openclaw gateway restart`.");
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("auto-generates token auth when binding LAN and persists the token", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
||||
|
||||
@@ -16,6 +16,14 @@ export type GatewayHealthFailureDiagnostics = {
|
||||
inspectError?: string;
|
||||
};
|
||||
|
||||
export type GatewayHealthFailureClassification =
|
||||
| "not-listening"
|
||||
| "auth-mismatch"
|
||||
| "service-missing"
|
||||
| "service-stopped"
|
||||
| "startup-blocked"
|
||||
| "runtime-deps-broken";
|
||||
|
||||
export function logNonInteractiveOnboardingJson(params: {
|
||||
opts: OnboardOptions;
|
||||
runtime: RuntimeEnv;
|
||||
@@ -78,6 +86,71 @@ function formatGatewayRuntimeSummary(
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function hasConnectionRefusedDetail(detail: string): boolean {
|
||||
return /\b(?:econnrefused|connection refused|connect refused)\b/i.test(detail);
|
||||
}
|
||||
|
||||
function classifyGatewayHealthFailure(params: {
|
||||
detail?: string;
|
||||
diagnostics?: GatewayHealthFailureDiagnostics;
|
||||
}): GatewayHealthFailureClassification | undefined {
|
||||
const detail = params.detail ?? "";
|
||||
const lastGatewayError = params.diagnostics?.lastGatewayError ?? "";
|
||||
const combined = `${detail}\n${lastGatewayError}`;
|
||||
if (
|
||||
/\b(?:unauthorized|forbidden|invalid token|invalid password|auth mismatch)\b/i.test(combined)
|
||||
) {
|
||||
return "auth-mismatch";
|
||||
}
|
||||
if (
|
||||
/\b(?:runtime[- ]deps?|runtime dependencies|cannot find module|sqlite-vec|loadextension)\b/i.test(
|
||||
combined,
|
||||
)
|
||||
) {
|
||||
return "runtime-deps-broken";
|
||||
}
|
||||
if (params.diagnostics?.service?.loaded === false && hasConnectionRefusedDetail(detail)) {
|
||||
return "service-missing";
|
||||
}
|
||||
const runtimeStatus = params.diagnostics?.service?.runtimeStatus;
|
||||
if (
|
||||
runtimeStatus &&
|
||||
runtimeStatus !== "running" &&
|
||||
runtimeStatus !== "active" &&
|
||||
hasConnectionRefusedDetail(detail)
|
||||
) {
|
||||
return "service-stopped";
|
||||
}
|
||||
if (lastGatewayError.trim()) {
|
||||
return "startup-blocked";
|
||||
}
|
||||
if (hasConnectionRefusedDetail(detail)) {
|
||||
return "not-listening";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function recoveryHintForGatewayHealthFailure(
|
||||
classification: GatewayHealthFailureClassification | undefined,
|
||||
): string | undefined {
|
||||
switch (classification) {
|
||||
case "auth-mismatch":
|
||||
return "Fix: run `openclaw doctor --fix`.";
|
||||
case "runtime-deps-broken":
|
||||
return "Fix: run `openclaw doctor --fix`.";
|
||||
case "service-missing":
|
||||
return "Fix: run `openclaw gateway install --force`.";
|
||||
case "service-stopped":
|
||||
return "Fix: run `openclaw gateway restart`.";
|
||||
case "startup-blocked":
|
||||
return "Fix: run `openclaw gateway status --deep`.";
|
||||
case "not-listening":
|
||||
return "Fix: start `openclaw gateway run`, or run `openclaw gateway restart` for a managed gateway.";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function logNonInteractiveOnboardingFailure(params: {
|
||||
opts: OnboardOptions;
|
||||
runtime: RuntimeEnv;
|
||||
@@ -99,7 +172,12 @@ export function logNonInteractiveOnboardingFailure(params: {
|
||||
daemonRuntime?: string;
|
||||
diagnostics?: GatewayHealthFailureDiagnostics;
|
||||
}) {
|
||||
const hints = params.hints?.filter(Boolean) ?? [];
|
||||
const classification = classifyGatewayHealthFailure({
|
||||
detail: params.detail,
|
||||
diagnostics: params.diagnostics,
|
||||
});
|
||||
const recoveryHint = recoveryHintForGatewayHealthFailure(classification);
|
||||
const hints = [...(recoveryHint ? [recoveryHint] : []), ...(params.hints?.filter(Boolean) ?? [])];
|
||||
const gatewayRuntime = formatGatewayRuntimeSummary(params.diagnostics);
|
||||
|
||||
if (params.opts.json) {
|
||||
@@ -108,6 +186,7 @@ export function logNonInteractiveOnboardingFailure(params: {
|
||||
mode: params.mode,
|
||||
phase: params.phase,
|
||||
message: params.message,
|
||||
classification,
|
||||
detail: params.detail,
|
||||
gateway: params.gateway,
|
||||
installDaemon: Boolean(params.installDaemon),
|
||||
@@ -121,6 +200,7 @@ export function logNonInteractiveOnboardingFailure(params: {
|
||||
|
||||
const lines = [
|
||||
params.message,
|
||||
classification ? `Classification: ${classification}` : undefined,
|
||||
params.detail ? `Last probe: ${params.detail}` : undefined,
|
||||
params.diagnostics?.service
|
||||
? `Service: ${params.diagnostics.service.label} (${params.diagnostics.service.loaded ? params.diagnostics.service.loadedText : "not loaded"})`
|
||||
|
||||
48
src/commands/status.daemon.test.ts
Normal file
48
src/commands/status.daemon.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getDaemonStatusSummary } from "./status.daemon.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readServiceStatusSummary: vi.fn(),
|
||||
resolveGatewayService: vi.fn(() => ({ kind: "gateway" })),
|
||||
resolveNodeService: vi.fn(() => ({ kind: "node" })),
|
||||
}));
|
||||
|
||||
vi.mock("./status.service-summary.js", () => ({
|
||||
readServiceStatusSummary: mocks.readServiceStatusSummary,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service.js", () => ({
|
||||
resolveGatewayService: mocks.resolveGatewayService,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/node-service.js", () => ({
|
||||
resolveNodeService: mocks.resolveNodeService,
|
||||
}));
|
||||
|
||||
describe("status daemon summary", () => {
|
||||
it("preserves service layout diagnostics for status output", async () => {
|
||||
mocks.readServiceStatusSummary.mockResolvedValueOnce({
|
||||
label: "systemd",
|
||||
installed: true,
|
||||
loaded: true,
|
||||
managedByOpenClaw: true,
|
||||
externallyManaged: false,
|
||||
loadedText: "enabled",
|
||||
runtime: { status: "running", pid: 1234 },
|
||||
layout: {
|
||||
execStart: "/usr/bin/node /opt/openclaw/dist/entry.js gateway",
|
||||
sourceScope: "system",
|
||||
entrypointSourceCheckout: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getDaemonStatusSummary()).resolves.toMatchObject({
|
||||
runtimeShort: expect.stringContaining("running"),
|
||||
layout: {
|
||||
execStart: "/usr/bin/node /opt/openclaw/dist/entry.js gateway",
|
||||
sourceScope: "system",
|
||||
entrypointSourceCheckout: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ type DaemonStatusSummary = {
|
||||
loadedText: string;
|
||||
runtime: Awaited<ReturnType<typeof readServiceStatusSummary>>["runtime"];
|
||||
runtimeShort: string | null;
|
||||
layout: Awaited<ReturnType<typeof readServiceStatusSummary>>["layout"];
|
||||
};
|
||||
|
||||
async function buildDaemonStatusSummary(
|
||||
@@ -29,6 +30,7 @@ async function buildDaemonStatusSummary(
|
||||
loadedText: summary.loadedText,
|
||||
runtime: summary.runtime,
|
||||
runtimeShort: formatDaemonRuntimeShort(summary.runtime),
|
||||
layout: summary.layout,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayService } from "../daemon/service.js";
|
||||
import type { GatewayServiceEnvArgs } from "../daemon/service.js";
|
||||
import { createMockGatewayService } from "../daemon/service.test-helpers.js";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { readServiceStatusSummary } from "./status.service-summary.js";
|
||||
|
||||
function createService(overrides: Partial<GatewayService>): GatewayService {
|
||||
@@ -89,4 +92,47 @@ describe("readServiceStatusSummary", () => {
|
||||
expect(summary.loaded).toBe(true);
|
||||
expect(summary.runtime).toMatchObject({ status: "running" });
|
||||
});
|
||||
|
||||
it("includes service layout diagnostics and flags source checkout entrypoints", async () => {
|
||||
await withTempDir({ prefix: "openclaw-status-service-layout-" }, async (root) => {
|
||||
await fs.mkdir(path.join(root, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "src"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "extensions"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "0.0.0-test" }),
|
||||
"utf8",
|
||||
);
|
||||
const entrypoint = path.join(root, "dist", "index.js");
|
||||
const serviceFile = path.join(root, "openclaw-gateway.service");
|
||||
await fs.writeFile(entrypoint, "export {};\n", "utf8");
|
||||
await fs.writeFile(serviceFile, "[Service]\n", "utf8");
|
||||
const realRoot = await fs.realpath(root);
|
||||
|
||||
const summary = await readServiceStatusSummary(
|
||||
createService({
|
||||
isLoaded: vi.fn(async () => true),
|
||||
readCommand: vi.fn(async () => ({
|
||||
programArguments: ["/usr/bin/node", entrypoint, "gateway", "run"],
|
||||
sourcePath: serviceFile,
|
||||
})),
|
||||
readRuntime: vi.fn(async () => ({ status: "running" })),
|
||||
}),
|
||||
"Daemon",
|
||||
);
|
||||
|
||||
expect(summary.layout).toMatchObject({
|
||||
sourcePath: serviceFile,
|
||||
sourcePathReal: path.join(realRoot, "openclaw-gateway.service"),
|
||||
entrypoint,
|
||||
entrypointReal: path.join(realRoot, "dist", "index.js"),
|
||||
packageRoot: realRoot,
|
||||
packageRootReal: realRoot,
|
||||
packageVersion: "0.0.0-test",
|
||||
entrypointSourceCheckout: true,
|
||||
});
|
||||
expect(summary.layout?.execStart).toContain("gateway run");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
summarizeGatewayServiceLayout,
|
||||
type GatewayServiceLayoutSummary,
|
||||
} from "../daemon/service-layout.js";
|
||||
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
|
||||
import { readGatewayServiceState, type GatewayService } from "../daemon/service.js";
|
||||
|
||||
@@ -9,6 +13,7 @@ export type ServiceStatusSummary = {
|
||||
externallyManaged: boolean;
|
||||
loadedText: string;
|
||||
runtime: GatewayServiceRuntime | undefined;
|
||||
layout?: GatewayServiceLayoutSummary;
|
||||
};
|
||||
|
||||
export async function readServiceStatusSummary(
|
||||
@@ -17,6 +22,7 @@ export async function readServiceStatusSummary(
|
||||
): Promise<ServiceStatusSummary> {
|
||||
try {
|
||||
const state = await readGatewayServiceState(service, { env: process.env });
|
||||
const layout = await summarizeGatewayServiceLayout(state.command);
|
||||
const managedByOpenClaw = state.installed;
|
||||
const externallyManaged = !managedByOpenClaw && state.running;
|
||||
const installed = managedByOpenClaw || externallyManaged;
|
||||
@@ -33,6 +39,7 @@ export async function readServiceStatusSummary(
|
||||
externallyManaged,
|
||||
loadedText,
|
||||
runtime: state.runtime,
|
||||
...(layout ? { layout } : {}),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
|
||||
141
src/daemon/service-layout.ts
Normal file
141
src/daemon/service-layout.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readPackageName, readPackageVersion } from "../infra/package-json.js";
|
||||
import type { GatewayServiceCommandConfig } from "./service-types.js";
|
||||
|
||||
export type GatewayServiceLayoutSummary = {
|
||||
execStart: string;
|
||||
sourcePath?: string;
|
||||
sourcePathReal?: string;
|
||||
sourceScope?: "user" | "system";
|
||||
entrypoint?: string;
|
||||
entrypointReal?: string;
|
||||
packageRoot?: string;
|
||||
packageRootReal?: string;
|
||||
packageVersion?: string;
|
||||
entrypointSourceCheckout?: boolean;
|
||||
};
|
||||
|
||||
function shellQuoteArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function formatExecStart(programArguments: readonly string[]): string {
|
||||
return programArguments.map(shellQuoteArg).join(" ");
|
||||
}
|
||||
|
||||
function resolveSystemdScopeFromServicePath(
|
||||
sourcePath: string | undefined,
|
||||
): "user" | "system" | undefined {
|
||||
const normalized = sourcePath?.replaceAll("\\", "/") ?? "";
|
||||
if (!normalized.endsWith(".service")) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
normalized.startsWith("/etc/systemd/") ||
|
||||
normalized.startsWith("/usr/lib/systemd/") ||
|
||||
normalized.startsWith("/lib/systemd/")
|
||||
) {
|
||||
return "system";
|
||||
}
|
||||
return "user";
|
||||
}
|
||||
|
||||
function findGatewayEntrypoint(programArguments: readonly string[]): string | undefined {
|
||||
const gatewayIndex = programArguments.indexOf("gateway");
|
||||
if (gatewayIndex <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return programArguments[gatewayIndex - 1];
|
||||
}
|
||||
|
||||
async function tryRealpath(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return await fs.realpath(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isSourceCheckoutRoot(candidate: string): Promise<boolean> {
|
||||
const hasRepoMarker =
|
||||
(await pathExists(path.join(candidate, ".git"))) ||
|
||||
(await pathExists(path.join(candidate, "pnpm-workspace.yaml")));
|
||||
if (!hasRepoMarker) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(await pathExists(path.join(candidate, "src"))) &&
|
||||
(await pathExists(path.join(candidate, "extensions")))
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveOpenClawPackageRoot(entrypoint: string): Promise<string | undefined> {
|
||||
let current = path.dirname(path.resolve(entrypoint));
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
const packageJson = path.join(current, "package.json");
|
||||
if (await pathExists(packageJson)) {
|
||||
const name = await readPackageName(current);
|
||||
if (name === "openclaw") {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
const next = path.dirname(current);
|
||||
if (next === current) {
|
||||
return undefined;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function summarizeGatewayServiceLayout(
|
||||
command: GatewayServiceCommandConfig | null,
|
||||
): Promise<GatewayServiceLayoutSummary | undefined> {
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const sourcePath = command.sourcePath?.trim() || undefined;
|
||||
const entrypoint = findGatewayEntrypoint(command.programArguments);
|
||||
const [sourcePathReal, entrypointReal] = await Promise.all([
|
||||
tryRealpath(sourcePath),
|
||||
tryRealpath(entrypoint),
|
||||
]);
|
||||
const packageRoot = entrypointReal ? await resolveOpenClawPackageRoot(entrypointReal) : undefined;
|
||||
const packageRootReal = await tryRealpath(packageRoot);
|
||||
const packageVersion = packageRoot
|
||||
? ((await readPackageVersion(packageRoot)) ?? undefined)
|
||||
: undefined;
|
||||
const entrypointSourceCheckout = packageRootReal
|
||||
? await isSourceCheckoutRoot(packageRootReal)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
execStart: formatExecStart(command.programArguments),
|
||||
...(sourcePath ? { sourcePath } : {}),
|
||||
...(sourcePathReal ? { sourcePathReal } : {}),
|
||||
...(sourcePath ? { sourceScope: resolveSystemdScopeFromServicePath(sourcePath) } : {}),
|
||||
...(entrypoint ? { entrypoint } : {}),
|
||||
...(entrypointReal ? { entrypointReal } : {}),
|
||||
...(packageRoot ? { packageRoot } : {}),
|
||||
...(packageRootReal ? { packageRootReal } : {}),
|
||||
...(packageVersion ? { packageVersion } : {}),
|
||||
...(entrypointSourceCheckout !== undefined ? { entrypointSourceCheckout } : {}),
|
||||
};
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export const SendParamsSchema = Type.Object(
|
||||
message: Type.Optional(Type.String()),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
mediaUrls: Type.Optional(Type.Array(Type.String())),
|
||||
asVoice: Type.Optional(Type.Boolean()),
|
||||
gifPlayback: Type.Optional(Type.Boolean()),
|
||||
channel: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
|
||||
@@ -283,6 +283,37 @@ describe("gateway send mirroring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps gateway asVoice sends onto outbound audioAsVoice payloads", async () => {
|
||||
mockDeliverySuccess("m-voice");
|
||||
|
||||
const { respond } = await runSend({
|
||||
to: "channel:C1",
|
||||
message: "voice note",
|
||||
mediaUrl: "file:///tmp/openclaw-voice.ogg",
|
||||
asVoice: true,
|
||||
channel: "slack",
|
||||
idempotencyKey: "idem-voice",
|
||||
});
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
text: "voice note",
|
||||
mediaUrl: "file:///tmp/openclaw-voice.ogg",
|
||||
audioAsVoice: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ messageId: "m-voice" }),
|
||||
undefined,
|
||||
expect.objectContaining({ channel: "slack" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards gateway client scopes into outbound delivery", async () => {
|
||||
mockDeliverySuccess("m-scope");
|
||||
|
||||
|
||||
@@ -390,6 +390,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
message?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
asVoice?: boolean;
|
||||
gifPlayback?: boolean;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
@@ -469,7 +470,14 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
const deliveryTarget = idLikeTarget?.to ?? resolvedTarget.to;
|
||||
const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
|
||||
const outboundPayloads = [{ text: message, mediaUrl, mediaUrls }];
|
||||
const outboundPayloads = [
|
||||
{
|
||||
text: message,
|
||||
mediaUrl,
|
||||
mediaUrls,
|
||||
...(request.asVoice === true ? { audioAsVoice: true } : {}),
|
||||
},
|
||||
];
|
||||
const outboundPayloadPlan = createOutboundPayloadPlan(outboundPayloads);
|
||||
const mirrorProjection = projectOutboundPayloadPlanForMirror(outboundPayloadPlan);
|
||||
const mirrorText = mirrorProjection.text;
|
||||
|
||||
@@ -233,6 +233,36 @@ describe("runMessageAction media behavior", () => {
|
||||
vi.mocked(loadWebMedia).mockImplementation(actualLoadWebMedia);
|
||||
});
|
||||
|
||||
it("forwards asVoice from send actions into core delivery", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "workspace",
|
||||
source: "test",
|
||||
plugin: workspacePlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runDrySend({
|
||||
cfg: workspaceConfig,
|
||||
actionParams: {
|
||||
channel: "workspace",
|
||||
target: "12345678",
|
||||
message: "voice note",
|
||||
media: "https://example.com/voice.ogg",
|
||||
asVoice: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(channelResolutionMocks.executeSendAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
asVoice: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("sendAttachment hydration", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -610,6 +610,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
|
||||
const forceDocument =
|
||||
readBooleanParam(params, "forceDocument") ?? readBooleanParam(params, "asDocument") ?? false;
|
||||
const asVoice =
|
||||
readBooleanParam(params, "asVoice") ??
|
||||
readBooleanParam(params, "audioAsVoice") ??
|
||||
parsed.audioAsVoice ??
|
||||
false;
|
||||
const bestEffort = readBooleanParam(params, "bestEffort");
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
|
||||
@@ -696,6 +701,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
message,
|
||||
mediaUrl: mediaUrl || undefined,
|
||||
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
|
||||
asVoice,
|
||||
gifPlayback,
|
||||
forceDocument,
|
||||
bestEffort: bestEffort ?? undefined,
|
||||
|
||||
@@ -204,6 +204,29 @@ describe("sendMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps voice media sends onto outbound audioAsVoice payloads", async () => {
|
||||
await sendMessage({
|
||||
cfg: {},
|
||||
channel: "forum",
|
||||
to: "123456",
|
||||
content: "voice note",
|
||||
mediaUrl: "file:///tmp/openclaw-voice.ogg",
|
||||
asVoice: true,
|
||||
});
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
text: "voice note",
|
||||
mediaUrl: "file:///tmp/openclaw-voice.ogg",
|
||||
audioAsVoice: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies mirror matrix semantics for MEDIA and silent token variants", async () => {
|
||||
const matrix: Array<{
|
||||
name: string;
|
||||
|
||||
@@ -67,6 +67,7 @@ type MessageSendParams = {
|
||||
channel?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
asVoice?: boolean;
|
||||
gifPlayback?: boolean;
|
||||
forceDocument?: boolean;
|
||||
accountId?: string;
|
||||
@@ -242,6 +243,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||
text: params.content,
|
||||
mediaUrl: params.mediaUrl,
|
||||
mediaUrls: params.mediaUrls,
|
||||
audioAsVoice: params.asVoice === true,
|
||||
},
|
||||
]);
|
||||
const normalizedPayloads = projectOutboundPayloadPlanForDelivery(outboundPlan);
|
||||
@@ -327,6 +329,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||
message: params.content,
|
||||
mediaUrl: params.mediaUrl,
|
||||
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : params.mediaUrls,
|
||||
asVoice: params.asVoice,
|
||||
gifPlayback: params.gifPlayback,
|
||||
accountId: params.accountId,
|
||||
agentId: params.agentId,
|
||||
|
||||
@@ -128,6 +128,7 @@ export async function executeSendAction(params: {
|
||||
message: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
asVoice?: boolean;
|
||||
gifPlayback?: boolean;
|
||||
forceDocument?: boolean;
|
||||
bestEffort?: boolean;
|
||||
@@ -179,6 +180,7 @@ export async function executeSendAction(params: {
|
||||
requesterSenderE164: params.ctx.requesterSenderE164,
|
||||
mediaUrl: params.mediaUrl || undefined,
|
||||
mediaUrls: params.mediaUrls,
|
||||
asVoice: params.asVoice,
|
||||
channel: params.ctx.channel || undefined,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
replyToId: params.replyToId,
|
||||
|
||||
@@ -484,6 +484,26 @@ describe("update global helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("flags global package roots that resolve into source checkouts", async () => {
|
||||
await withTempDir({ prefix: "openclaw-update-global-source-checkout-" }, async (base) => {
|
||||
const checkoutRoot = path.join(base, "checkout");
|
||||
const globalRoot = path.join(base, "prefix", "lib", "node_modules");
|
||||
const packageRoot = path.join(globalRoot, "openclaw");
|
||||
await fs.mkdir(path.join(checkoutRoot, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(checkoutRoot, "src"), { recursive: true });
|
||||
await fs.mkdir(path.join(checkoutRoot, "extensions"), { recursive: true });
|
||||
await fs.writeFile(path.join(checkoutRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
|
||||
await writeGlobalPackageJson(checkoutRoot, "2026.4.27");
|
||||
await fs.mkdir(globalRoot, { recursive: true });
|
||||
await fs.symlink(checkoutRoot, packageRoot, "dir");
|
||||
const realCheckoutRoot = await fs.realpath(checkoutRoot);
|
||||
|
||||
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
|
||||
`global package root resolves to source checkout: ${realCheckoutRoot}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not require private QA sidecars when the inventory is missing", async () => {
|
||||
await withTempDir({ prefix: "openclaw-update-global-legacy-" }, async (packageRoot) => {
|
||||
await writeGlobalPackageJson(packageRoot);
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function collectInstalledGlobalPackageErrors(params: {
|
||||
expectedVersion?: string | null;
|
||||
}): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
errors.push(...(await collectSourceCheckoutInstallErrors(params.packageRoot)));
|
||||
const installedVersion = await readPackageVersion(params.packageRoot);
|
||||
if (params.expectedVersion && installedVersion !== params.expectedVersion) {
|
||||
errors.push(
|
||||
@@ -117,6 +118,18 @@ export async function collectInstalledGlobalPackageErrors(params: {
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function collectSourceCheckoutInstallErrors(packageRoot: string): Promise<string[]> {
|
||||
const realPackageRoot = await tryRealpath(packageRoot);
|
||||
const hasSourceCheckoutShape =
|
||||
((await pathExists(path.join(realPackageRoot, ".git"))) ||
|
||||
(await pathExists(path.join(realPackageRoot, "pnpm-workspace.yaml")))) &&
|
||||
(await pathExists(path.join(realPackageRoot, "src"))) &&
|
||||
(await pathExists(path.join(realPackageRoot, "extensions")));
|
||||
return hasSourceCheckoutShape
|
||||
? [`global package root resolves to source checkout: ${realPackageRoot}`]
|
||||
: [];
|
||||
}
|
||||
|
||||
function shouldRequirePackagedDistInventory(version: string | null | undefined): boolean {
|
||||
const parsed = parseSemver(version ?? null);
|
||||
if (!parsed) {
|
||||
|
||||
@@ -79,4 +79,13 @@ describe("task boundaries", () => {
|
||||
|
||||
expect(importers.toSorted()).toEqual([...TASK_REGISTRY_ALLOWED_IMPORTERS].toSorted());
|
||||
});
|
||||
|
||||
it("keeps task registry maintenance chat-type checks on the lightweight parser", () => {
|
||||
const maintenance = sources.find(
|
||||
({ relative }) => relative === "tasks/task-registry.maintenance.ts",
|
||||
);
|
||||
|
||||
expect(maintenance?.source).toContain("session-chat-type-shared.js");
|
||||
expect(maintenance?.source).not.toContain("session-chat-type.js");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
sweepExpiredPluginStateEntries,
|
||||
} from "../plugin-state/plugin-state-store.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { deriveSessionChatType } from "../sessions/session-chat-type.js";
|
||||
import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -386,7 +386,7 @@ function hasBackingSession(task: TaskRecord): boolean {
|
||||
}
|
||||
if (task.runtime === "subagent" || task.runtime === "cli") {
|
||||
if (task.runtime === "cli") {
|
||||
const chatType = deriveSessionChatType(childSessionKey);
|
||||
const chatType = deriveSessionChatTypeFromKey(childSessionKey);
|
||||
if (chatType === "channel" || chatType === "group" || chatType === "direct") {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user