fix(onboarding): recovery hints target the wrong Gateway (#129577)

* fix(onboarding): preserve scoped gateway recovery commands

* test(memory): deduplicate archive transcript fixtures
This commit is contained in:
Peter Steinberger
2026-08-26 04:12:52 -07:00
committed by GitHub
parent e69b076e8b
commit 51e2dfe68e
3 changed files with 105 additions and 28 deletions
@@ -296,28 +296,18 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
updateMode: "none",
},
);
const archiveMessage = (content: string) =>
JSON.stringify({ type: "message", message: { role: "user", content } });
const archivePath = path.join(
sessionsDir,
`${sessionId}.jsonl.deleted.2026-06-25T12-01-00.000Z`,
);
fsSync.writeFileSync(
archivePath,
JSON.stringify({
type: "message",
message: { role: "user", content: "Archived JSONL transcript text" },
}),
);
fsSync.writeFileSync(archivePath, archiveMessage("Archived JSONL transcript text"));
const resetArchivePath = path.join(
sessionsDir,
`${sessionId}.jsonl.reset.2026-06-25T12-02-00.000Z`,
);
fsSync.writeFileSync(
resetArchivePath,
JSON.stringify({
type: "message",
message: { role: "user", content: "Retained pre-reset conversation fact" },
}),
);
fsSync.writeFileSync(resetArchivePath, archiveMessage("Retained pre-reset conversation fact"));
expect(fsSync.existsSync(path.join(sessionsDir, `${sessionId}.jsonl`))).toBe(false);
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
@@ -6,7 +6,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { setTestEnvValue } from "../test-utils/env.js";
import { setTestEnvValue, withEnv, withEnvAsync } from "../test-utils/env.js";
import {
capturedReplaceConfigFileCalls,
configWritePluginLeaseDepths,
@@ -90,6 +90,88 @@ describe("logNonInteractiveOnboardingFailure", () => {
]);
});
it.each([
{
name: "active profile",
env: { OPENCLAW_PROFILE: "work", OPENCLAW_CONTAINER_HINT: undefined },
selector: "--profile work",
},
{
name: "container precedence over the active profile",
env: { OPENCLAW_PROFILE: "work", OPENCLAW_CONTAINER_HINT: "preview" },
selector: "--container preview",
},
])("keeps $name on every recovery command in human and JSON output", ({ env, selector }) => {
const cases = [
{
detail: "unauthorized: invalid token",
commands: ["doctor --fix"],
},
{
detail: "Cannot find module sqlite-vec",
commands: ["doctor --fix"],
},
{
detail: "connect ECONNREFUSED",
diagnostics: {
service: {
label: "Gateway",
loaded: false,
loadState: { status: "not-loaded" as const },
loadedText: "not loaded",
},
},
commands: ["gateway install --force"],
},
{
detail: "connect ECONNREFUSED",
diagnostics: {
service: {
label: "Gateway",
loaded: true,
loadState: { status: "loaded" as const },
loadedText: "loaded",
runtimeStatus: "stopped",
},
},
commands: ["gateway restart"],
},
{
detail: "startup timed out",
diagnostics: { lastGatewayError: "configuration parse failed" },
commands: ["gateway status --deep"],
},
{
detail: "connect ECONNREFUSED",
commands: ["gateway run", "gateway restart"],
},
];
withEnv(env, () => {
for (const { detail, diagnostics, commands } of cases) {
for (const json of [false, true]) {
const output = vi.fn();
logNonInteractiveOnboardingFailure({
...failure,
hints: undefined,
opts: { json },
runtime: { ...runtime, log: output, error: output },
detail,
diagnostics,
});
const emitted = String(output.mock.calls[0]?.[0]);
const hint = json
? (JSON.parse(emitted) as { hints: string[] }).hints[0]
: emitted.split("\n").find((line) => line.startsWith("Fix:"));
for (const command of commands) {
expect(hint).toContain(`\`openclaw ${selector} ${command}\``);
}
}
}
});
});
it("leaves hints for a non-gateway-health phase unchanged", () => {
const hints = [callerFix, "Keep the configured environment available."];
const { runtimeWithCapture, readCapturedJson } = createOnboardJsonCaptureRuntime();
@@ -869,7 +951,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
});
}, 60_000);
it("classifies daemon health ECONNREFUSED failures with a recovery command", async () => {
it("classifies daemon health ECONNREFUSED failures with a profile-scoped recovery command", async () => {
await withStateDir("state-local-daemon-health-refused-", async (stateDir) => {
gatewayReachableState.mock = vi.fn(async () => ({
ok: false,
@@ -883,11 +965,16 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
readLastGatewayErrorLineMock.mockResolvedValueOnce("");
const { runtimeWithCapture, readCapturedJson } = createOnboardJsonCaptureRuntime();
await expectOnboardLocalJsonSetupFailure({
runSetup: runNonInteractiveSetup,
stateDir,
runtime: runtimeWithCapture,
});
await withEnvAsync(
{ OPENCLAW_PROFILE: "work", OPENCLAW_CONTAINER_HINT: undefined },
async () => {
await expectOnboardLocalJsonSetupFailure({
runSetup: runNonInteractiveSetup,
stateDir,
runtime: runtimeWithCapture,
});
},
);
const parsed = JSON.parse(readCapturedJson()) as {
ok: boolean;
@@ -898,7 +985,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
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`.");
expect(parsed.hints).toContain("Fix: run `openclaw --profile work gateway restart`.");
});
}, 60_000);
});
@@ -4,6 +4,7 @@
* JSON success/failure payloads and human-readable gateway health diagnostics
* are kept here so local and remote setup report failures consistently.
*/
import { formatCliCommand } from "../../../cli/command-format.js";
import type { GatewayServiceLoadState } from "../../../daemon/service-types.js";
import { redactSecrets } from "../../../logging/redact.js";
import { type RuntimeEnv, writeRuntimeJson } from "../../../runtime.js";
@@ -153,17 +154,16 @@ function recoveryHintForGatewayHealthFailure(
): string | undefined {
switch (classification) {
case "auth-mismatch":
return "Fix: run `openclaw doctor --fix`.";
case "module-missing":
return "Fix: run `openclaw doctor --fix`.";
return `Fix: run \`${formatCliCommand("openclaw doctor --fix")}\`.`;
case "service-missing":
return "Fix: run `openclaw gateway install --force`.";
return `Fix: run \`${formatCliCommand("openclaw gateway install --force")}\`.`;
case "service-stopped":
return "Fix: run `openclaw gateway restart`.";
return `Fix: run \`${formatCliCommand("openclaw gateway restart")}\`.`;
case "startup-blocked":
return "Fix: run `openclaw gateway status --deep`.";
return `Fix: run \`${formatCliCommand("openclaw gateway status --deep")}\`.`;
case "not-listening":
return "Fix: start `openclaw gateway run`, or run `openclaw gateway restart` for a managed gateway.";
return `Fix: start \`${formatCliCommand("openclaw gateway run")}\`, or run \`${formatCliCommand("openclaw gateway restart")}\` for a managed gateway.`;
default:
return undefined;
}