mirror of https://github.com/openclaw/openclaw.git
test(commands): dedupe command and onboarding test cases
This commit is contained in:
parent
7e29d604ba
commit
cded1b960a
|
|
@ -6,11 +6,36 @@ const flushMicrotasks = async () => {
|
|||
await Promise.resolve();
|
||||
};
|
||||
|
||||
async function withFakeTimers(run: () => Promise<void>) {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
function createTypingHarness(overrides: Partial<Parameters<typeof createTypingCallbacks>[0]> = {}) {
|
||||
const start = overrides.start ?? vi.fn().mockResolvedValue(undefined);
|
||||
const stop = overrides.stop ?? vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = overrides.onStartError ?? vi.fn();
|
||||
const onStopError = overrides.onStopError ?? vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
...(onStopError ? { onStopError } : {}),
|
||||
...(overrides.maxConsecutiveFailures !== undefined
|
||||
? { maxConsecutiveFailures: overrides.maxConsecutiveFailures }
|
||||
: {}),
|
||||
...(overrides.maxDurationMs !== undefined ? { maxDurationMs: overrides.maxDurationMs } : {}),
|
||||
});
|
||||
return { start, stop, onStartError, onStopError, callbacks };
|
||||
}
|
||||
|
||||
describe("createTypingCallbacks", () => {
|
||||
it("invokes start on reply start", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
const { start, onStartError, callbacks } = createTypingHarness();
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
|
|
@ -19,9 +44,9 @@ describe("createTypingCallbacks", () => {
|
|||
});
|
||||
|
||||
it("reports start errors", async () => {
|
||||
const start = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
const { onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockRejectedValue(new Error("fail")),
|
||||
});
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
|
|
@ -29,11 +54,9 @@ describe("createTypingCallbacks", () => {
|
|||
});
|
||||
|
||||
it("invokes stop on idle and reports stop errors", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockRejectedValue(new Error("stop"));
|
||||
const onStartError = vi.fn();
|
||||
const onStopError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
|
||||
const { stop, onStopError, callbacks } = createTypingHarness({
|
||||
stop: vi.fn().mockRejectedValue(new Error("stop")),
|
||||
});
|
||||
|
||||
callbacks.onIdle?.();
|
||||
await flushMicrotasks();
|
||||
|
|
@ -43,13 +66,8 @@ describe("createTypingCallbacks", () => {
|
|||
});
|
||||
|
||||
it("sends typing keepalive pings until idle cleanup", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
await withFakeTimers(async () => {
|
||||
const { start, stop, callbacks } = createTypingHarness();
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
|
@ -68,18 +86,14 @@ describe("createTypingCallbacks", () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("stops keepalive after consecutive start failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockRejectedValue(new Error("gone"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await withFakeTimers(async () => {
|
||||
const { start, onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockRejectedValue(new Error("gone")),
|
||||
});
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -90,19 +104,13 @@ describe("createTypingCallbacks", () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not restart keepalive when breaker trips on initial start", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockRejectedValue(new Error("gone"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
onStartError,
|
||||
await withFakeTimers(async () => {
|
||||
const { start, onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockRejectedValue(new Error("gone")),
|
||||
maxConsecutiveFailures: 1,
|
||||
});
|
||||
|
||||
|
|
@ -112,28 +120,21 @@ describe("createTypingCallbacks", () => {
|
|||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("resets failure counter after a successful keepalive tick", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await withFakeTimers(async () => {
|
||||
let callCount = 0;
|
||||
const start = vi.fn().mockImplementation(async () => {
|
||||
callCount += 1;
|
||||
if (callCount % 2 === 1) {
|
||||
throw new Error("flaky");
|
||||
}
|
||||
});
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
onStartError,
|
||||
const { start, onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
callCount += 1;
|
||||
if (callCount % 2 === 1) {
|
||||
throw new Error("flaky");
|
||||
}
|
||||
}),
|
||||
maxConsecutiveFailures: 2,
|
||||
});
|
||||
|
||||
await callbacks.onReplyStart(); // fail
|
||||
await vi.advanceTimersByTimeAsync(3_000); // success
|
||||
await vi.advanceTimersByTimeAsync(3_000); // fail
|
||||
|
|
@ -142,16 +143,11 @@ describe("createTypingCallbacks", () => {
|
|||
|
||||
expect(start).toHaveBeenCalledTimes(5);
|
||||
expect(onStartError).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates stop across idle and cleanup", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
const { stop, callbacks } = createTypingHarness();
|
||||
|
||||
callbacks.onIdle?.();
|
||||
callbacks.onCleanup?.();
|
||||
|
|
@ -161,12 +157,8 @@ describe("createTypingCallbacks", () => {
|
|||
});
|
||||
|
||||
it("does not restart keepalive after idle cleanup", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
await withFakeTimers(async () => {
|
||||
const { start, stop, callbacks } = createTypingHarness();
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -179,26 +171,15 @@ describe("createTypingCallbacks", () => {
|
|||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== TTL Safety Tests ==========
|
||||
describe("TTL safety", () => {
|
||||
it("auto-stops typing after maxDurationMs", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await withFakeTimers(async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 10_000,
|
||||
});
|
||||
const { start, stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -212,24 +193,13 @@ describe("createTypingCallbacks", () => {
|
|||
expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("TTL exceeded"));
|
||||
|
||||
consoleWarn.mockRestore();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-stop if idle is called before TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await withFakeTimers(async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 10_000,
|
||||
});
|
||||
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
|
|
@ -249,18 +219,12 @@ describe("createTypingCallbacks", () => {
|
|||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
consoleWarn.mockRestore();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default 60s TTL when not specified", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
await withFakeTimers(async () => {
|
||||
const { stop, callbacks } = createTypingHarness();
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
|
|
@ -271,46 +235,24 @@ describe("createTypingCallbacks", () => {
|
|||
// Should stop at 60s
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("disables TTL when maxDurationMs is 0", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 0,
|
||||
});
|
||||
await withFakeTimers(async () => {
|
||||
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 0 });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
// Should not auto-stop even after long time
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("resets TTL timer on restart after idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 10_000,
|
||||
});
|
||||
await withFakeTimers(async () => {
|
||||
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||
|
||||
// First start
|
||||
await callbacks.onReplyStart();
|
||||
|
|
@ -330,9 +272,7 @@ describe("createTypingCallbacks", () => {
|
|||
|
||||
// Should not trigger stop again since it's closed
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -156,61 +156,49 @@ async function expectCronEditWithScheduleLookupExit(
|
|||
).rejects.toThrow("__exit__:1");
|
||||
}
|
||||
|
||||
async function runCronRunAndCaptureExit(params: { ran: boolean }) {
|
||||
resetGatewayMock();
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, callParams?: unknown) => {
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (method === "cron.run") {
|
||||
return { ok: true, params: callParams, ran: params.ran };
|
||||
}
|
||||
return { ok: true, params: callParams };
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeModule = await import("../runtime.js");
|
||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||
const originalExit = runtime.exit;
|
||||
const exitSpy = vi.fn();
|
||||
runtime.exit = exitSpy;
|
||||
try {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
||||
} finally {
|
||||
runtime.exit = originalExit;
|
||||
}
|
||||
return exitSpy;
|
||||
}
|
||||
|
||||
describe("cron cli", () => {
|
||||
it("exits 0 for cron run when job executes successfully", async () => {
|
||||
resetGatewayMock();
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (method === "cron.run") {
|
||||
return { ok: true, params, ran: true };
|
||||
}
|
||||
return { ok: true, params };
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeModule = await import("../runtime.js");
|
||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||
const originalExit = runtime.exit;
|
||||
const exitSpy = vi.fn();
|
||||
runtime.exit = exitSpy;
|
||||
try {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
runtime.exit = originalExit;
|
||||
}
|
||||
});
|
||||
|
||||
it("exits 1 for cron run when job does not execute", async () => {
|
||||
resetGatewayMock();
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (method === "cron.run") {
|
||||
return { ok: true, params, ran: false };
|
||||
}
|
||||
return { ok: true, params };
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeModule = await import("../runtime.js");
|
||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||
const originalExit = runtime.exit;
|
||||
const exitSpy = vi.fn();
|
||||
runtime.exit = exitSpy;
|
||||
try {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
runtime.exit = originalExit;
|
||||
}
|
||||
it.each([
|
||||
{
|
||||
name: "exits 0 for cron run when job executes successfully",
|
||||
ran: true,
|
||||
expectedExitCode: 0,
|
||||
},
|
||||
{
|
||||
name: "exits 1 for cron run when job does not execute",
|
||||
ran: false,
|
||||
expectedExitCode: 1,
|
||||
},
|
||||
])("$name", async ({ ran, expectedExitCode }) => {
|
||||
const exitSpy = await runCronRunAndCaptureExit({ ran });
|
||||
expect(exitSpy).toHaveBeenCalledWith(expectedExitCode);
|
||||
});
|
||||
|
||||
it("trims model and thinking on cron add", { timeout: CRON_CLI_TEST_TIMEOUT_MS }, async () => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,20 @@ function makeRuntime() {
|
|||
};
|
||||
}
|
||||
|
||||
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
|
||||
const writes: string[] = [];
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
try {
|
||||
await run();
|
||||
return writes.join("");
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("ensureConfigReady", () => {
|
||||
async function loadEnsureConfigReady() {
|
||||
vi.resetModules();
|
||||
|
|
@ -107,36 +121,22 @@ describe("ensureConfigReady", () => {
|
|||
});
|
||||
|
||||
it("prevents preflight stdout noise when suppression is enabled", async () => {
|
||||
const stdoutWrites: string[] = [];
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||
process.stdout.write("Doctor warnings\n");
|
||||
});
|
||||
try {
|
||||
const output = await withCapturedStdout(async () => {
|
||||
await runEnsureConfigReady(["message"], true);
|
||||
expect(stdoutWrites.join("")).not.toContain("Doctor warnings");
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
expect(output).not.toContain("Doctor warnings");
|
||||
});
|
||||
|
||||
it("allows preflight stdout noise when suppression is not enabled", async () => {
|
||||
const stdoutWrites: string[] = [];
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||
process.stdout.write("Doctor warnings\n");
|
||||
});
|
||||
try {
|
||||
const output = await withCapturedStdout(async () => {
|
||||
await runEnsureConfigReady(["message"], false);
|
||||
expect(stdoutWrites.join("")).toContain("Doctor warnings");
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
expect(output).toContain("Doctor warnings");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -129,6 +129,31 @@ function mockAcpManager(params: {
|
|||
} as unknown as ReturnType<typeof acpManagerModule.getAcpSessionManager>);
|
||||
}
|
||||
|
||||
async function runAcpSessionWithPolicyOverrides(params: {
|
||||
acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>;
|
||||
resolveSession?: Parameters<typeof mockAcpManager>[0]["resolveSession"];
|
||||
}) {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfigWithAcpOverrides(home, storePath, params.acpOverrides);
|
||||
|
||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
||||
mockAcpManager({
|
||||
runTurn: (input: unknown) => runTurn(input),
|
||||
...(params.resolveSession ? { resolveSession: params.resolveSession } : {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_DISPATCH_DISABLED",
|
||||
});
|
||||
expect(runTurn).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
describe("agentCommand ACP runtime routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -221,50 +246,19 @@ describe("agentCommand ACP runtime routing", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("blocks ACP turns when ACP is disabled by policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfigWithAcpOverrides(home, storePath, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_DISPATCH_DISABLED",
|
||||
});
|
||||
expect(runTurn).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks ACP turns when ACP dispatch is disabled by policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfigWithAcpOverrides(home, storePath, {
|
||||
it.each([
|
||||
{
|
||||
name: "blocks ACP turns when ACP is disabled by policy",
|
||||
acpOverrides: { enabled: false } satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
|
||||
},
|
||||
{
|
||||
name: "blocks ACP turns when ACP dispatch is disabled by policy",
|
||||
acpOverrides: {
|
||||
dispatch: { enabled: false },
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_DISPATCH_DISABLED",
|
||||
});
|
||||
expect(runTurn).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
} satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
|
||||
},
|
||||
])("$name", async ({ acpOverrides }) => {
|
||||
await runAcpSessionWithPolicyOverrides({ acpOverrides });
|
||||
});
|
||||
|
||||
it("blocks ACP turns when ACP agent is disallowed by policy", async () => {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,20 @@ async function runWithDefaultAgentConfig(params: {
|
|||
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
}
|
||||
|
||||
async function runEmbeddedWithTempConfig(params: {
|
||||
args: Parameters<typeof agentCommand>[0];
|
||||
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
||||
telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>;
|
||||
agentsList?: Array<{ id: string; default?: boolean }>;
|
||||
}) {
|
||||
return withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, params.agentOverrides, params.telegramOverrides, params.agentsList);
|
||||
await agentCommand(params.args, runtime);
|
||||
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
});
|
||||
}
|
||||
|
||||
function writeSessionStoreSeed(
|
||||
storePath: string,
|
||||
sessions: Record<string, Record<string, unknown>>,
|
||||
|
|
@ -101,54 +115,149 @@ function writeSessionStoreSeed(
|
|||
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
|
||||
}
|
||||
|
||||
function createDefaultAgentResult(params?: {
|
||||
payloads?: Array<Record<string, unknown>>;
|
||||
durationMs?: number;
|
||||
}) {
|
||||
return {
|
||||
payloads: params?.payloads ?? [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: params?.durationMs ?? 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getLastEmbeddedCall() {
|
||||
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
}
|
||||
|
||||
function expectLastRunProviderModel(provider: string, model: string): void {
|
||||
const callArgs = getLastEmbeddedCall();
|
||||
expect(callArgs?.provider).toBe(provider);
|
||||
expect(callArgs?.model).toBe(model);
|
||||
}
|
||||
|
||||
function readSessionStore<T>(storePath: string): Record<string, T> {
|
||||
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
|
||||
}
|
||||
|
||||
async function withCrossAgentResumeFixture(
|
||||
run: (params: {
|
||||
home: string;
|
||||
storePattern: string;
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await withTempHome(async (home) => {
|
||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
||||
const sessionId = "session-exec-hook";
|
||||
const sessionKey = "agent:exec:hook:gmail:thread-1";
|
||||
writeSessionStoreSeed(execStore, {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
},
|
||||
});
|
||||
mockConfig(home, storePattern, undefined, undefined, [
|
||||
{ id: "dev" },
|
||||
{ id: "exec", default: true },
|
||||
]);
|
||||
await agentCommand({ message: "resume me", sessionId }, runtime);
|
||||
await run({ home, storePattern, sessionId, sessionKey });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPersistedSessionFile(params: {
|
||||
seedKey: string;
|
||||
sessionId: string;
|
||||
expectedPathFragment: string;
|
||||
}) {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
writeSessionStoreSeed(store, {
|
||||
[params.seedKey]: {
|
||||
sessionId: params.sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
mockConfig(home, store);
|
||||
await agentCommand({ message: "hi", sessionKey: params.seedKey }, runtime);
|
||||
const saved = readSessionStore<{ sessionId?: string; sessionFile?: string }>(store);
|
||||
const entry = saved[params.seedKey];
|
||||
expect(entry?.sessionId).toBe(params.sessionId);
|
||||
expect(entry?.sessionFile).toContain(params.expectedPathFragment);
|
||||
expect(getLastEmbeddedCall()?.sessionFile).toBe(entry?.sessionFile);
|
||||
});
|
||||
}
|
||||
|
||||
async function runAgentWithSessionKey(sessionKey: string): Promise<void> {
|
||||
await agentCommand({ message: "hi", sessionKey }, runtime);
|
||||
}
|
||||
|
||||
async function expectDefaultThinkLevel(params: {
|
||||
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
||||
catalogEntry: Record<string, unknown>;
|
||||
expected: string;
|
||||
}) {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, params.agentOverrides);
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([params.catalogEntry as never]);
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
expect(getLastEmbeddedCall()?.thinkLevel).toBe(params.expected);
|
||||
});
|
||||
}
|
||||
|
||||
function createTelegramOutboundPlugin() {
|
||||
const sendWithTelegram = async (
|
||||
ctx: {
|
||||
deps?: {
|
||||
sendTelegram?: (
|
||||
to: string,
|
||||
text: string,
|
||||
opts: Record<string, unknown>,
|
||||
) => Promise<{
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
}>;
|
||||
};
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
},
|
||||
mediaUrl?: string,
|
||||
) => {
|
||||
const sendTelegram = ctx.deps?.sendTelegram;
|
||||
if (!sendTelegram) {
|
||||
throw new Error("sendTelegram dependency missing");
|
||||
}
|
||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
...(mediaUrl ? { mediaUrl } : {}),
|
||||
verbose: false,
|
||||
});
|
||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
||||
};
|
||||
|
||||
return createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async (ctx) => {
|
||||
const sendTelegram = ctx.deps?.sendTelegram;
|
||||
if (!sendTelegram) {
|
||||
throw new Error("sendTelegram dependency missing");
|
||||
}
|
||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
verbose: false,
|
||||
});
|
||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
||||
},
|
||||
sendMedia: async (ctx) => {
|
||||
const sendTelegram = ctx.deps?.sendTelegram;
|
||||
if (!sendTelegram) {
|
||||
throw new Error("sendTelegram dependency missing");
|
||||
}
|
||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
mediaUrl: ctx.mediaUrl,
|
||||
verbose: false,
|
||||
});
|
||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
||||
},
|
||||
sendText: async (ctx) => sendWithTelegram(ctx),
|
||||
sendMedia: async (ctx) => sendWithTelegram(ctx, ctx.mediaUrl),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runCliAgentSpy.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never);
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
|
||||
});
|
||||
|
|
@ -191,28 +300,20 @@ describe("agentCommand", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("defaults senderIsOwner to true for local agent runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.senderIsOwner).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("honors explicit senderIsOwner override", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.senderIsOwner).toBe(false);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "defaults senderIsOwner to true for local agent runs",
|
||||
args: { message: "hi", to: "+1555" },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "honors explicit senderIsOwner override",
|
||||
args: { message: "hi", to: "+1555", senderIsOwner: false },
|
||||
expected: false,
|
||||
},
|
||||
])("$name", async ({ args, expected }) => {
|
||||
const callArgs = await runEmbeddedWithTempConfig({ args });
|
||||
expect(callArgs?.senderIsOwner).toBe(expected);
|
||||
});
|
||||
|
||||
it("resumes when session-id is provided", async () => {
|
||||
|
|
@ -235,53 +336,21 @@ describe("agentCommand", () => {
|
|||
});
|
||||
|
||||
it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
||||
writeSessionStoreSeed(execStore, {
|
||||
"agent:exec:hook:gmail:thread-1": {
|
||||
sessionId: "session-exec-hook",
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
},
|
||||
});
|
||||
mockConfig(home, storePattern, undefined, undefined, [
|
||||
{ id: "dev" },
|
||||
{ id: "exec", default: true },
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.sessionKey).toBe("agent:exec:hook:gmail:thread-1");
|
||||
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
|
||||
const callArgs = getLastEmbeddedCall();
|
||||
expect(callArgs?.sessionKey).toBe(sessionKey);
|
||||
expect(callArgs?.agentId).toBe("exec");
|
||||
expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`);
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards resolved outbound session context when resuming by sessionId", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
||||
writeSessionStoreSeed(execStore, {
|
||||
"agent:exec:hook:gmail:thread-1": {
|
||||
sessionId: "session-exec-hook",
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
},
|
||||
});
|
||||
mockConfig(home, storePattern, undefined, undefined, [
|
||||
{ id: "dev" },
|
||||
{ id: "exec", default: true },
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
|
||||
|
||||
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
|
||||
const deliverCall = deliverAgentCommandResultSpy.mock.calls.at(-1)?.[0];
|
||||
expect(deliverCall?.opts.sessionKey).toBeUndefined();
|
||||
expect(deliverCall?.outboundSession).toEqual(
|
||||
expect.objectContaining({
|
||||
key: "agent:exec:hook:gmail:thread-1",
|
||||
key: sessionKey,
|
||||
agentId: "exec",
|
||||
}),
|
||||
);
|
||||
|
|
@ -362,9 +431,7 @@ describe("agentCommand", () => {
|
|||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.provider).toBe("openai");
|
||||
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
||||
expectLastRunProviderModel("openai", "gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -446,13 +513,7 @@ describe("agentCommand", () => {
|
|||
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
|
||||
]);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:subagent:allow-any",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
await runAgentWithSessionKey("agent:main:subagent:allow-any");
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.provider).toBe("openai");
|
||||
|
|
@ -497,17 +558,9 @@ describe("agentCommand", () => {
|
|||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||
]);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:subagent:clear-overrides",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
await runAgentWithSessionKey("agent:main:subagent:clear-overrides");
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.provider).toBe("openai");
|
||||
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
||||
expectLastRunProviderModel("openai", "gpt-4.1-mini");
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
||||
string,
|
||||
|
|
@ -566,68 +619,18 @@ describe("agentCommand", () => {
|
|||
});
|
||||
|
||||
it("persists resolved sessionFile for existing session keys", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
writeSessionStoreSeed(store, {
|
||||
"agent:main:subagent:abc": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:subagent:abc",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionId?: string; sessionFile?: string }
|
||||
>;
|
||||
const entry = saved["agent:main:subagent:abc"];
|
||||
expect(entry?.sessionId).toBe("sess-main");
|
||||
expect(entry?.sessionFile).toContain(
|
||||
`${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
|
||||
);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
||||
await expectPersistedSessionFile({
|
||||
seedKey: "agent:main:subagent:abc",
|
||||
sessionId: "sess-main",
|
||||
expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves topic transcript suffix when persisting missing sessionFile", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
writeSessionStoreSeed(store, {
|
||||
"agent:main:telegram:group:123:topic:456": {
|
||||
sessionId: "sess-topic",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:telegram:group:123:topic:456",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionId?: string; sessionFile?: string }
|
||||
>;
|
||||
const entry = saved["agent:main:telegram:group:123:topic:456"];
|
||||
expect(entry?.sessionId).toBe("sess-topic");
|
||||
expect(entry?.sessionFile).toContain("sess-topic-topic-456.jsonl");
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
||||
await expectPersistedSessionFile({
|
||||
seedKey: "agent:main:telegram:group:123:topic:456",
|
||||
sessionId: "sess-topic",
|
||||
expectedPathFragment: "sess-topic-topic-456.jsonl",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -715,76 +718,61 @@ describe("agentCommand", () => {
|
|||
});
|
||||
|
||||
it("defaults thinking to low for reasoning-capable models", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.thinkLevel).toBe("low");
|
||||
await expectDefaultThinkLevel({
|
||||
catalogEntry: {
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
expected: "low",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, {
|
||||
await expectDefaultThinkLevel({
|
||||
agentOverrides: {
|
||||
model: { primary: "anthropic/claude-opus-4-6" },
|
||||
models: { "anthropic/claude-opus-4-6": {} },
|
||||
});
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Opus 4.6",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.thinkLevel).toBe("adaptive");
|
||||
},
|
||||
catalogEntry: {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Opus 4.6",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
expected: "adaptive",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers per-model thinking over global thinkingDefault", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, {
|
||||
await expectDefaultThinkLevel({
|
||||
agentOverrides: {
|
||||
thinkingDefault: "low",
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {
|
||||
params: { thinking: "high" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.thinkLevel).toBe("high");
|
||||
},
|
||||
catalogEntry: {
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
expected: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("prints JSON payload when requested", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
|
||||
meta: {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(
|
||||
createDefaultAgentResult({
|
||||
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
|
||||
durationMs: 42,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
|
|
@ -802,15 +790,10 @@ describe("agentCommand", () => {
|
|||
});
|
||||
|
||||
it("passes the message through as the agent prompt", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "ping", to: "+1333" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.prompt).toBe("ping");
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: { message: "ping", to: "+1333" },
|
||||
});
|
||||
expect(callArgs?.prompt).toBe("ping");
|
||||
});
|
||||
|
||||
it("passes through telegram accountId when delivering", async () => {
|
||||
|
|
@ -861,48 +844,31 @@ describe("agentCommand", () => {
|
|||
});
|
||||
|
||||
it("uses reply channel as the message channel context", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
|
||||
|
||||
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: { message: "hi", agentId: "ops", replyChannel: "slack" },
|
||||
agentsList: [{ id: "ops" }],
|
||||
});
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
});
|
||||
|
||||
it("prefers runContext for embedded routing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
to: "+1555",
|
||||
channel: "whatsapp",
|
||||
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
expect(callArgs?.agentAccountId).toBe("acct-2");
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: {
|
||||
message: "hi",
|
||||
to: "+1555",
|
||||
channel: "whatsapp",
|
||||
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
||||
},
|
||||
});
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
expect(callArgs?.agentAccountId).toBe("acct-2");
|
||||
});
|
||||
|
||||
it("forwards accountId to embedded runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555", accountId: "kev" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.agentAccountId).toBe("kev");
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: { message: "hi", to: "+1555", accountId: "kev" },
|
||||
});
|
||||
expect(callArgs?.agentAccountId).toBe("kev");
|
||||
});
|
||||
|
||||
it("logs output when delivery is disabled", async () => {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,39 @@ describe("applyAuthChoiceMiniMax", () => {
|
|||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
}
|
||||
|
||||
async function runMiniMaxChoice(params: {
|
||||
authChoice: Parameters<typeof applyAuthChoiceMiniMax>[0]["authChoice"];
|
||||
opts?: Parameters<typeof applyAuthChoiceMiniMax>[0]["opts"];
|
||||
env?: { apiKey?: string; oauthToken?: string };
|
||||
prompter?: Parameters<typeof createMinimaxPrompter>[0];
|
||||
}) {
|
||||
const agentDir = await setupTempState();
|
||||
resetMiniMaxEnv();
|
||||
if (params.env?.apiKey !== undefined) {
|
||||
process.env.MINIMAX_API_KEY = params.env.apiKey;
|
||||
}
|
||||
if (params.env?.oauthToken !== undefined) {
|
||||
process.env.MINIMAX_OAUTH_TOKEN = params.env.oauthToken;
|
||||
}
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice: params.authChoice,
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({
|
||||
text,
|
||||
confirm,
|
||||
...params.prompter,
|
||||
}),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
...(params.opts ? { opts: params.opts } : {}),
|
||||
});
|
||||
|
||||
return { agentDir, result, text, confirm };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await lifecycle.cleanup();
|
||||
});
|
||||
|
|
@ -92,18 +125,8 @@ describe("applyAuthChoiceMiniMax", () => {
|
|||
])(
|
||||
"$caseName",
|
||||
async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => {
|
||||
const agentDir = await setupTempState();
|
||||
resetMiniMaxEnv();
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||
authChoice,
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
tokenProvider,
|
||||
token,
|
||||
|
|
@ -126,80 +149,57 @@ describe("applyAuthChoiceMiniMax", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("uses env token for minimax-api-key-cn as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.MINIMAX_API_KEY = "mm-env-token";
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice: "minimax-api-key-cn",
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
|
||||
provider: "minimax-cn",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe(
|
||||
"minimax-cn/MiniMax-M2.5",
|
||||
);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
expect(confirm).toHaveBeenCalled();
|
||||
|
||||
const parsed = await readAuthProfiles(agentDir);
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token");
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses env token for minimax-api-key-cn as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.MINIMAX_API_KEY = "mm-env-token";
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice: "minimax-api-key-cn",
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
secretInputMode: "ref",
|
||||
it.each([
|
||||
{
|
||||
name: "uses env token for minimax-api-key-cn as plaintext by default",
|
||||
opts: undefined,
|
||||
expectKey: "mm-env-token",
|
||||
expectKeyRef: undefined,
|
||||
expectConfirmCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "uses env token for minimax-api-key-cn as keyRef in ref mode",
|
||||
opts: { secretInputMode: "ref" as const },
|
||||
expectKey: undefined,
|
||||
expectKeyRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MINIMAX_API_KEY",
|
||||
},
|
||||
expectConfirmCalls: 0,
|
||||
},
|
||||
])("$name", async ({ opts, expectKey, expectKeyRef, expectConfirmCalls }) => {
|
||||
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||
authChoice: "minimax-api-key-cn",
|
||||
opts,
|
||||
env: { apiKey: "mm-env-token" },
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
if (!opts) {
|
||||
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
|
||||
provider: "minimax-cn",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe(
|
||||
"minimax-cn/MiniMax-M2.5",
|
||||
);
|
||||
}
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
expect(confirm).toHaveBeenCalledTimes(expectConfirmCalls);
|
||||
|
||||
const parsed = await readAuthProfiles(agentDir);
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MINIMAX_API_KEY",
|
||||
});
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBeUndefined();
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe(expectKey);
|
||||
if (expectKeyRef) {
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual(expectKeyRef);
|
||||
} else {
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses minimax-api-lightning default model", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
resetMiniMaxEnv();
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||
authChoice: "minimax-api-lightning",
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
tokenProvider: "minimax",
|
||||
token: "mm-lightning-token",
|
||||
|
|
|
|||
|
|
@ -24,163 +24,117 @@ describe("volcengine/byteplus auth choice", () => {
|
|||
return env.agentDir;
|
||||
}
|
||||
|
||||
function createTestContext(defaultSelect: string, confirmResult = true, textValue = "unused") {
|
||||
return {
|
||||
prompter: createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => confirmResult),
|
||||
text: vi.fn(async () => textValue),
|
||||
},
|
||||
{ defaultSelect },
|
||||
),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderAuthCase = {
|
||||
provider: "volcengine" | "byteplus";
|
||||
authChoice: "volcengine-api-key" | "byteplus-api-key";
|
||||
envVar: "VOLCANO_ENGINE_API_KEY" | "BYTEPLUS_API_KEY";
|
||||
envValue: string;
|
||||
profileId: "volcengine:default" | "byteplus:default";
|
||||
applyAuthChoice: typeof applyAuthChoiceVolcengine | typeof applyAuthChoiceBytePlus;
|
||||
};
|
||||
|
||||
async function runProviderAuthChoice(
|
||||
testCase: ProviderAuthCase,
|
||||
options?: {
|
||||
defaultSelect?: string;
|
||||
confirmResult?: boolean;
|
||||
textValue?: string;
|
||||
secretInputMode?: "ref";
|
||||
},
|
||||
) {
|
||||
const agentDir = await setupTempState();
|
||||
process.env[testCase.envVar] = testCase.envValue;
|
||||
|
||||
const { prompter, runtime } = createTestContext(
|
||||
options?.defaultSelect ?? "plaintext",
|
||||
options?.confirmResult ?? true,
|
||||
options?.textValue ?? "unused",
|
||||
);
|
||||
|
||||
const result = await testCase.applyAuthChoice({
|
||||
authChoice: testCase.authChoice,
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
...(options?.secretInputMode ? { opts: { secretInputMode: options.secretInputMode } } : {}),
|
||||
});
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
|
||||
return { result, parsed };
|
||||
}
|
||||
|
||||
const providerAuthCases: ProviderAuthCase[] = [
|
||||
{
|
||||
provider: "volcengine",
|
||||
authChoice: "volcengine-api-key",
|
||||
envVar: "VOLCANO_ENGINE_API_KEY",
|
||||
envValue: "volc-env-key",
|
||||
profileId: "volcengine:default",
|
||||
applyAuthChoice: applyAuthChoiceVolcengine,
|
||||
},
|
||||
{
|
||||
provider: "byteplus",
|
||||
authChoice: "byteplus-api-key",
|
||||
envVar: "BYTEPLUS_API_KEY",
|
||||
envValue: "byte-env-key",
|
||||
profileId: "byteplus:default",
|
||||
applyAuthChoice: applyAuthChoiceBytePlus,
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(async () => {
|
||||
await lifecycle.cleanup();
|
||||
});
|
||||
|
||||
it("stores volcengine env key as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
||||
it.each(providerAuthCases)(
|
||||
"stores $provider env key as plaintext by default",
|
||||
async (testCase) => {
|
||||
const { result, parsed } = await runProviderAuthChoice(testCase);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.config.auth?.profiles?.[testCase.profileId]).toMatchObject({
|
||||
provider: testCase.provider,
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(parsed.profiles?.[testCase.profileId]?.key).toBe(testCase.envValue);
|
||||
expect(parsed.profiles?.[testCase.profileId]?.keyRef).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "plaintext" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceVolcengine({
|
||||
authChoice: "volcengine-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
it.each(providerAuthCases)("stores $provider env key as keyRef in ref mode", async (testCase) => {
|
||||
const { result, parsed } = await runProviderAuthChoice(testCase, {
|
||||
defaultSelect: "ref",
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.config.auth?.profiles?.["volcengine:default"]).toMatchObject({
|
||||
provider: "volcengine",
|
||||
mode: "api_key",
|
||||
expect(parsed.profiles?.[testCase.profileId]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: testCase.envVar },
|
||||
});
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-env-key");
|
||||
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores volcengine env key as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "ref" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceVolcengine({
|
||||
authChoice: "volcengine-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["volcengine:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" },
|
||||
});
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores byteplus env key as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "plaintext" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceBytePlus({
|
||||
authChoice: "byteplus-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.config.auth?.profiles?.["byteplus:default"]).toMatchObject({
|
||||
provider: "byteplus",
|
||||
mode: "api_key",
|
||||
});
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["byteplus:default"]?.key).toBe("byte-env-key");
|
||||
expect(parsed.profiles?.["byteplus:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores byteplus env key as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "ref" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceBytePlus({
|
||||
authChoice: "byteplus-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["byteplus:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" },
|
||||
});
|
||||
expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined();
|
||||
expect(parsed.profiles?.[testCase.profileId]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores explicit volcengine key when env is not used", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => false),
|
||||
text: vi.fn(async () => "volc-manual-key"),
|
||||
},
|
||||
{ defaultSelect: "" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceVolcengine({
|
||||
authChoice: "volcengine-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
const { result, parsed } = await runProviderAuthChoice(providerAuthCases[0], {
|
||||
defaultSelect: "",
|
||||
confirmResult: false,
|
||||
textValue: "volc-manual-key",
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-manual-key");
|
||||
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import {
|
|||
const runtime = createTestRuntime();
|
||||
let clackPrompterModule: typeof import("../wizard/clack-prompter.js");
|
||||
|
||||
function formatChannelStatusJoined(channelAccounts: Record<string, unknown>) {
|
||||
return formatGatewayChannelsStatusLines({ channelAccounts }).join("\n");
|
||||
}
|
||||
|
||||
describe("channels command", () => {
|
||||
beforeAll(async () => {
|
||||
clackPrompterModule = await import("../wizard/clack-prompter.js");
|
||||
|
|
@ -45,23 +49,53 @@ describe("channels command", () => {
|
|||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
it("adds a non-default telegram account", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
await channelsAddCommand(
|
||||
{ channel: "telegram", account: "alerts", token: "123:abc" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
function getWrittenConfig<T>(): T {
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
return configMocks.writeConfigFile.mock.calls[0]?.[0] as T;
|
||||
}
|
||||
|
||||
async function runRemoveWithConfirm(
|
||||
args: Parameters<typeof channelsRemoveCommand>[0],
|
||||
): Promise<void> {
|
||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
||||
const promptSpy = vi
|
||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
||||
.mockReturnValue(prompt as never);
|
||||
try {
|
||||
await channelsRemoveCommand(args, runtime, { hasFlags: true });
|
||||
} finally {
|
||||
promptSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
async function addTelegramAccount(account: string, token: string): Promise<void> {
|
||||
await channelsAddCommand({ channel: "telegram", account, token }, runtime, {
|
||||
hasFlags: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function addAlertsTelegramAccount(token: string): Promise<{
|
||||
channels?: {
|
||||
telegram?: {
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, { botToken?: string }>;
|
||||
};
|
||||
};
|
||||
}> {
|
||||
await addTelegramAccount("alerts", token);
|
||||
return getWrittenConfig<{
|
||||
channels?: {
|
||||
telegram?: {
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, { botToken?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>();
|
||||
}
|
||||
|
||||
it("adds a non-default telegram account", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
const next = await addAlertsTelegramAccount("123:abc");
|
||||
expect(next.channels?.telegram?.enabled).toBe(true);
|
||||
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
|
||||
});
|
||||
|
|
@ -83,13 +117,9 @@ describe("channels command", () => {
|
|||
},
|
||||
});
|
||||
|
||||
await channelsAddCommand(
|
||||
{ channel: "telegram", account: "alerts", token: "alerts-token" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
await addTelegramAccount("alerts", "alerts-token");
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: {
|
||||
telegram?: {
|
||||
botToken?: string;
|
||||
|
|
@ -109,7 +139,7 @@ describe("channels command", () => {
|
|||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.telegram?.accounts?.default).toEqual({
|
||||
botToken: "legacy-token",
|
||||
dmPolicy: "allowlist",
|
||||
|
|
@ -137,20 +167,7 @@ describe("channels command", () => {
|
|||
},
|
||||
});
|
||||
|
||||
await channelsAddCommand(
|
||||
{ channel: "telegram", account: "alerts", token: "alerts-token" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
telegram?: {
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, { botToken?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const next = await addAlertsTelegramAccount("alerts-token");
|
||||
expect(next.channels?.telegram?.enabled).toBe(true);
|
||||
expect(next.channels?.telegram?.accounts?.default).toEqual({});
|
||||
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
|
||||
|
|
@ -169,12 +186,11 @@ describe("channels command", () => {
|
|||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: {
|
||||
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
|
||||
};
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.slack?.enabled).toBe(true);
|
||||
expect(next.channels?.slack?.botToken).toBe("xoxb-1");
|
||||
expect(next.channels?.slack?.appToken).toBe("xapp-1");
|
||||
|
|
@ -199,12 +215,11 @@ describe("channels command", () => {
|
|||
hasFlags: true,
|
||||
});
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: {
|
||||
discord?: { accounts?: Record<string, { token?: string }> };
|
||||
};
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.discord?.accounts?.work).toBeUndefined();
|
||||
expect(next.channels?.discord?.accounts?.default?.token).toBe("d0");
|
||||
});
|
||||
|
|
@ -217,11 +232,11 @@ describe("channels command", () => {
|
|||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: {
|
||||
whatsapp?: { accounts?: Record<string, { name?: string }> };
|
||||
};
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe("Family Phone");
|
||||
});
|
||||
|
||||
|
|
@ -250,13 +265,13 @@ describe("channels command", () => {
|
|||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: {
|
||||
signal?: {
|
||||
accounts?: Record<string, { account?: string; name?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.signal?.accounts?.lab?.account).toBe("+15555550123");
|
||||
expect(next.channels?.signal?.accounts?.lab?.name).toBe("Lab");
|
||||
expect(next.channels?.signal?.accounts?.default?.name).toBe("Primary");
|
||||
|
|
@ -270,20 +285,12 @@ describe("channels command", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
||||
const promptSpy = vi
|
||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
||||
.mockReturnValue(prompt as never);
|
||||
await runRemoveWithConfirm({ channel: "discord", account: "default" });
|
||||
|
||||
await channelsRemoveCommand({ channel: "discord", account: "default" }, runtime, {
|
||||
hasFlags: true,
|
||||
});
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: { discord?: { enabled?: boolean } };
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.discord?.enabled).toBe(false);
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("includes external auth profiles in JSON output", async () => {
|
||||
|
|
@ -348,14 +355,14 @@ describe("channels command", () => {
|
|||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: {
|
||||
telegram?: {
|
||||
name?: string;
|
||||
accounts?: Record<string, { botToken?: string; name?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.telegram?.name).toBeUndefined();
|
||||
expect(next.channels?.telegram?.accounts?.default?.name).toBe("Primary Bot");
|
||||
});
|
||||
|
|
@ -377,14 +384,14 @@ describe("channels command", () => {
|
|||
hasFlags: true,
|
||||
});
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
const next = getWrittenConfig<{
|
||||
channels?: {
|
||||
discord?: {
|
||||
name?: string;
|
||||
accounts?: Record<string, { name?: string; token?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>();
|
||||
expect(next.channels?.discord?.name).toBeUndefined();
|
||||
expect(next.channels?.discord?.accounts?.default?.name).toBe("Primary Bot");
|
||||
expect(next.channels?.discord?.accounts?.work?.token).toBe("d1");
|
||||
|
|
@ -405,8 +412,9 @@ describe("channels command", () => {
|
|||
expect(telegramIndex).toBeLessThan(whatsappIndex);
|
||||
});
|
||||
|
||||
it("surfaces Discord privileged intent issues in channels status output", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
it.each([
|
||||
{
|
||||
name: "surfaces Discord privileged intent issues in channels status output",
|
||||
channelAccounts: {
|
||||
discord: [
|
||||
{
|
||||
|
|
@ -417,14 +425,14 @@ describe("channels command", () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i);
|
||||
expect(lines.join("\n")).toMatch(/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/);
|
||||
});
|
||||
|
||||
it("surfaces Discord permission audit issues in channels status output", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
patterns: [
|
||||
/Warnings:/,
|
||||
/Message Content Intent is disabled/i,
|
||||
/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "surfaces Discord permission audit issues in channels status output",
|
||||
channelAccounts: {
|
||||
discord: [
|
||||
{
|
||||
|
|
@ -444,14 +452,10 @@ describe("channels command", () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/permission audit/i);
|
||||
expect(lines.join("\n")).toMatch(/Channel 111/i);
|
||||
});
|
||||
|
||||
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
patterns: [/Warnings:/, /permission audit/i, /Channel 111/i],
|
||||
},
|
||||
{
|
||||
name: "surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled",
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
|
|
@ -462,54 +466,54 @@ describe("channels command", () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
||||
patterns: [/Warnings:/, /Telegram Bot API privacy mode/i],
|
||||
},
|
||||
])("$name", ({ channelAccounts, patterns }) => {
|
||||
const joined = formatChannelStatusJoined(channelAccounts);
|
||||
for (const pattern of patterns) {
|
||||
expect(joined).toMatch(pattern);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes Telegram bot username from probe data", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
probe: { ok: true, bot: { username: "openclaw_bot" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
const joined = formatChannelStatusJoined({
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
probe: { ok: true, bot: { username: "openclaw_bot" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/bot:@openclaw_bot/);
|
||||
expect(joined).toMatch(/bot:@openclaw_bot/);
|
||||
});
|
||||
|
||||
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
hasWildcardUnmentionedGroups: true,
|
||||
unresolvedGroups: 1,
|
||||
groups: [
|
||||
{
|
||||
chatId: "-1001",
|
||||
ok: false,
|
||||
status: "left",
|
||||
error: "not in group",
|
||||
},
|
||||
],
|
||||
},
|
||||
const joined = formatChannelStatusJoined({
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
hasWildcardUnmentionedGroups: true,
|
||||
unresolvedGroups: 1,
|
||||
groups: [
|
||||
{
|
||||
chatId: "-1001",
|
||||
ok: false,
|
||||
status: "left",
|
||||
error: "not in group",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
|
||||
expect(lines.join("\n")).toMatch(/Group -1001/i);
|
||||
expect(joined).toMatch(/Warnings:/);
|
||||
expect(joined).toMatch(/membership probing is not possible/i);
|
||||
expect(joined).toMatch(/Group -1001/i);
|
||||
});
|
||||
|
||||
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
|
||||
|
|
@ -591,16 +595,8 @@ describe("channels command", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
||||
const promptSpy = vi
|
||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
||||
.mockReturnValue(prompt as never);
|
||||
|
||||
await channelsRemoveCommand({ channel: "telegram", account: "default" }, runtime, {
|
||||
hasFlags: true,
|
||||
});
|
||||
await runRemoveWithConfirm({ channel: "telegram", account: "default" });
|
||||
|
||||
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,35 +51,56 @@ function makeRuntime(): RuntimeEnv {
|
|||
|
||||
const noopPrompter = {} as WizardPrompter;
|
||||
|
||||
describe("promptAuthConfig", () => {
|
||||
it("keeps Kilo provider models while applying allowlist defaults", async () => {
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
||||
mocks.applyAuthChoice.mockResolvedValue({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
kilocode: {
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||
],
|
||||
},
|
||||
},
|
||||
function createKilocodeProvider() {
|
||||
return {
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createApplyAuthChoiceConfig(includeMinimaxProvider = false) {
|
||||
return {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.promptModelAllowlist.mockResolvedValue({
|
||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
||||
});
|
||||
models: {
|
||||
providers: {
|
||||
kilocode: createKilocodeProvider(),
|
||||
...(includeMinimaxProvider
|
||||
? {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||
async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) {
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
||||
mocks.applyAuthChoice.mockResolvedValue(createApplyAuthChoiceConfig(includeMinimaxProvider));
|
||||
mocks.promptModelAllowlist.mockResolvedValue({
|
||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
||||
});
|
||||
|
||||
return promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||
}
|
||||
|
||||
describe("promptAuthConfig", () => {
|
||||
it("keeps Kilo provider models while applying allowlist defaults", async () => {
|
||||
const result = await runPromptAuthConfigWithAllowlist();
|
||||
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
||||
"anthropic/claude-opus-4.6",
|
||||
"minimax/minimax-m2.5:free",
|
||||
|
|
@ -90,38 +111,7 @@ describe("promptAuthConfig", () => {
|
|||
});
|
||||
|
||||
it("does not mutate provider model catalogs when allowlist is set", async () => {
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
||||
mocks.applyAuthChoice.mockResolvedValue({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
kilocode: {
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||
],
|
||||
},
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.promptModelAllowlist.mockResolvedValue({
|
||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
||||
});
|
||||
|
||||
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||
const result = await runPromptAuthConfigWithAllowlist(true);
|
||||
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
||||
"anthropic/claude-opus-4.6",
|
||||
"minimax/minimax-m2.5:free",
|
||||
|
|
|
|||
|
|
@ -28,67 +28,109 @@ describe("onboard auth credentials secret refs", () => {
|
|||
await lifecycle.cleanup();
|
||||
});
|
||||
|
||||
it("keeps env-backed moonshot key as plaintext by default", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-");
|
||||
type AuthProfileEntry = { key?: string; keyRef?: unknown; metadata?: unknown };
|
||||
|
||||
async function withAuthEnv(
|
||||
prefix: string,
|
||||
run: (env: Awaited<ReturnType<typeof setupAuthTestEnv>>) => Promise<void>,
|
||||
) {
|
||||
const env = await setupAuthTestEnv(prefix);
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
|
||||
|
||||
await setMoonshotApiKey("sk-moonshot-env");
|
||||
await run(env);
|
||||
}
|
||||
|
||||
async function readProfile(
|
||||
agentDir: string,
|
||||
profileId: string,
|
||||
): Promise<AuthProfileEntry | undefined> {
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
key: "sk-moonshot-env",
|
||||
profiles?: Record<string, AuthProfileEntry>;
|
||||
}>(agentDir);
|
||||
return parsed.profiles?.[profileId];
|
||||
}
|
||||
|
||||
async function expectStoredAuthKey(params: {
|
||||
prefix: string;
|
||||
envVar?: string;
|
||||
envValue?: string;
|
||||
profileId: string;
|
||||
apply: (agentDir: string) => Promise<void>;
|
||||
expected: AuthProfileEntry;
|
||||
absent?: Array<keyof AuthProfileEntry>;
|
||||
}) {
|
||||
await withAuthEnv(params.prefix, async (env) => {
|
||||
if (params.envVar && params.envValue !== undefined) {
|
||||
process.env[params.envVar] = params.envValue;
|
||||
}
|
||||
await params.apply(env.agentDir);
|
||||
const profile = await readProfile(env.agentDir, params.profileId);
|
||||
expect(profile).toMatchObject(params.expected);
|
||||
for (const key of params.absent ?? []) {
|
||||
expect(profile?.[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps env-backed moonshot key as plaintext by default", async () => {
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
envValue: "sk-moonshot-env",
|
||||
profileId: "moonshot:default",
|
||||
apply: async () => {
|
||||
await setMoonshotApiKey("sk-moonshot-env");
|
||||
},
|
||||
expected: {
|
||||
key: "sk-moonshot-env",
|
||||
},
|
||||
absent: ["keyRef"],
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed moonshot key as keyRef when secret-input-mode=ref", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-ref-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
|
||||
|
||||
await setMoonshotApiKey("sk-moonshot-env", env.agentDir, { secretInputMode: "ref" });
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-ref-",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
envValue: "sk-moonshot-env",
|
||||
profileId: "moonshot:default",
|
||||
apply: async (agentDir) => {
|
||||
await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" });
|
||||
},
|
||||
expected: {
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
},
|
||||
absent: ["key"],
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-inline-ref-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
|
||||
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-inline-ref-",
|
||||
profileId: "moonshot:default",
|
||||
apply: async () => {
|
||||
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
|
||||
},
|
||||
expected: {
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
},
|
||||
absent: ["key"],
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps plaintext moonshot key when no env ref applies", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-plaintext-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-other";
|
||||
|
||||
await setMoonshotApiKey("sk-moonshot-plaintext");
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
key: "sk-moonshot-plaintext",
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-plaintext-",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
envValue: "sk-moonshot-other",
|
||||
profileId: "moonshot:default",
|
||||
apply: async () => {
|
||||
await setMoonshotApiKey("sk-moonshot-plaintext");
|
||||
},
|
||||
expected: {
|
||||
key: "sk-moonshot-plaintext",
|
||||
},
|
||||
absent: ["keyRef"],
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves cloudflare metadata when storing keyRef", async () => {
|
||||
|
|
@ -111,35 +153,35 @@ describe("onboard auth credentials secret refs", () => {
|
|||
});
|
||||
|
||||
it("keeps env-backed openai key as plaintext by default", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.OPENAI_API_KEY = "sk-openai-env";
|
||||
|
||||
await setOpenaiApiKey("sk-openai-env");
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["openai:default"]).toMatchObject({
|
||||
key: "sk-openai-env",
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-openai-",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
envValue: "sk-openai-env",
|
||||
profileId: "openai:default",
|
||||
apply: async () => {
|
||||
await setOpenaiApiKey("sk-openai-env");
|
||||
},
|
||||
expected: {
|
||||
key: "sk-openai-env",
|
||||
},
|
||||
absent: ["keyRef"],
|
||||
});
|
||||
expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed openai key as keyRef in ref mode", async () => {
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-ref-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.OPENAI_API_KEY = "sk-openai-env";
|
||||
|
||||
await setOpenaiApiKey("sk-openai-env", env.agentDir, { secretInputMode: "ref" });
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["openai:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-openai-ref-",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
envValue: "sk-openai-env",
|
||||
profileId: "openai:default",
|
||||
apply: async (agentDir) => {
|
||||
await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" });
|
||||
},
|
||||
expected: {
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
absent: ["key"],
|
||||
});
|
||||
expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,68 @@ function createUnexpectedPromptGuards() {
|
|||
};
|
||||
}
|
||||
|
||||
type SetupChannelsOptions = Parameters<typeof setupChannels>[3];
|
||||
|
||||
function runSetupChannels(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: WizardPrompter,
|
||||
options?: SetupChannelsOptions,
|
||||
) {
|
||||
return setupChannels(cfg, createExitThrowingRuntime(), prompter, {
|
||||
skipConfirm: true,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function createQuickstartTelegramSelect(options?: {
|
||||
configuredAction?: "skip";
|
||||
strictUnexpected?: boolean;
|
||||
}) {
|
||||
return vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
if (options?.configuredAction && message.includes("already configured")) {
|
||||
return options.configuredAction;
|
||||
}
|
||||
if (options?.strictUnexpected) {
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
}
|
||||
|
||||
function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) {
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
return {
|
||||
prompter: createPrompter({ select, multiselect, text }),
|
||||
multiselect,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken,
|
||||
...(typeof enabled === "boolean" ? { enabled } : {}),
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function patchTelegramAdapter(overrides: Parameters<typeof patchChannelOnboardingAdapter>[1]) {
|
||||
return patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
default: {
|
||||
access: vi.fn(async () => {
|
||||
|
|
@ -81,10 +143,7 @@ describe("setupChannels", () => {
|
|||
text: text as unknown as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
quickstartDefaults: true,
|
||||
forceAllowFromChannels: ["whatsapp"],
|
||||
});
|
||||
|
|
@ -116,10 +175,7 @@ describe("setupChannels", () => {
|
|||
text: text as unknown as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
|
||||
|
|
@ -146,11 +202,7 @@ describe("setupChannels", () => {
|
|||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
});
|
||||
await runSetupChannels({} as OpenClawConfig, prompter);
|
||||
|
||||
const sawPrimer = note.mock.calls.some(
|
||||
([message, title]) =>
|
||||
|
|
@ -162,41 +214,18 @@ describe("setupChannels", () => {
|
|||
});
|
||||
|
||||
it("prompts for configured channel action and skips configuration when told to skip", async () => {
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
if (message.includes("already configured")) {
|
||||
return "skip";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
const select = createQuickstartTelegramSelect({
|
||||
configuredAction: "skip",
|
||||
strictUnexpected: true,
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
},
|
||||
const { prompter, multiselect, text } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
|
||||
await runSetupChannels(createTelegramCfg("token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Select channel (QuickStart)" }),
|
||||
);
|
||||
|
|
@ -231,58 +260,26 @@ describe("setupChannels", () => {
|
|||
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "token",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
},
|
||||
);
|
||||
await runSetupChannels(createTelegramCfg("token", false), prompter);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses configureInteractive skip without mutating selection/account state", async () => {
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const select = createQuickstartTelegramSelect();
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureInteractive = vi.fn(async () => "skip" as const);
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
const restore = patchTelegramAdapter({
|
||||
configureInteractive,
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
|
|
@ -300,12 +297,7 @@ describe("setupChannels", () => {
|
|||
});
|
||||
|
||||
it("applies configureInteractive result cfg/account updates", async () => {
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const select = createQuickstartTelegramSelect();
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
|
|
@ -321,27 +313,16 @@ describe("setupChannels", () => {
|
|||
const configure = vi.fn(async () => {
|
||||
throw new Error("configure should not be called when configureInteractive is present");
|
||||
});
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
const restore = patchTelegramAdapter({
|
||||
configureInteractive,
|
||||
configure,
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
|
|
@ -358,12 +339,7 @@ describe("setupChannels", () => {
|
|||
});
|
||||
|
||||
it("uses configureWhenConfigured when channel is already configured", async () => {
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const select = createQuickstartTelegramSelect();
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
|
|
@ -381,43 +357,21 @@ describe("setupChannels", () => {
|
|||
"configure should not be called when configureWhenConfigured handles updates",
|
||||
);
|
||||
});
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
const restore = patchTelegramAdapter({
|
||||
configureInteractive: undefined,
|
||||
configureWhenConfigured,
|
||||
configure,
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "old-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
},
|
||||
);
|
||||
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
});
|
||||
|
||||
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
|
||||
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
||||
|
|
@ -433,55 +387,28 @@ describe("setupChannels", () => {
|
|||
});
|
||||
|
||||
it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
});
|
||||
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureWhenConfigured = vi.fn(async () => "skip" as const);
|
||||
const configure = vi.fn(async () => {
|
||||
throw new Error("configure should not run when configureWhenConfigured handles skip");
|
||||
});
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
const restore = patchTelegramAdapter({
|
||||
configureInteractive: undefined,
|
||||
configureWhenConfigured,
|
||||
configure,
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "old-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
},
|
||||
);
|
||||
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
});
|
||||
|
||||
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
||||
|
|
@ -496,54 +423,27 @@ describe("setupChannels", () => {
|
|||
});
|
||||
|
||||
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
});
|
||||
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureInteractive = vi.fn(async () => "skip" as const);
|
||||
const configureWhenConfigured = vi.fn(async () => {
|
||||
throw new Error("configureWhenConfigured should not run when configureInteractive exists");
|
||||
});
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
const restore = patchTelegramAdapter({
|
||||
configureInteractive,
|
||||
configureWhenConfigured,
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "old-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
},
|
||||
);
|
||||
await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
});
|
||||
|
||||
expect(configureInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,43 @@ function expectOpenAiCompatResult(params: {
|
|||
expect(params.result.config.models?.providers?.custom?.api).toBe("openai-completions");
|
||||
}
|
||||
|
||||
function buildCustomProviderConfig(contextWindow?: number) {
|
||||
if (contextWindow === undefined) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "foo-large",
|
||||
name: "foo-large",
|
||||
contextWindow,
|
||||
maxTokens: contextWindow > CONTEXT_WINDOW_HARD_MIN_TOKENS ? 4096 : 1024,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyCustomModelConfigWithContextWindow(contextWindow?: number) {
|
||||
return applyCustomApiConfig({
|
||||
config: buildCustomProviderConfig(contextWindow),
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
}
|
||||
|
||||
describe("promptCustomApiConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
|
@ -327,89 +364,28 @@ describe("promptCustomApiConfig", () => {
|
|||
});
|
||||
|
||||
describe("applyCustomApiConfig", () => {
|
||||
it("uses hard-min context window for newly added custom models", () => {
|
||||
const result = applyCustomApiConfig({
|
||||
config: {},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "uses hard-min context window for newly added custom models",
|
||||
existingContextWindow: undefined,
|
||||
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
},
|
||||
{
|
||||
name: "upgrades existing custom model context window when below hard minimum",
|
||||
existingContextWindow: 4096,
|
||||
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
},
|
||||
{
|
||||
name: "preserves existing custom model context window when already above minimum",
|
||||
existingContextWindow: 131072,
|
||||
expectedContextWindow: 131072,
|
||||
},
|
||||
])("$name", ({ existingContextWindow, expectedContextWindow }) => {
|
||||
const result = applyCustomModelConfigWithContextWindow(existingContextWindow);
|
||||
const model = result.config.models?.providers?.custom?.models?.find(
|
||||
(entry) => entry.id === "foo-large",
|
||||
);
|
||||
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
|
||||
});
|
||||
|
||||
it("upgrades existing custom model context window when below hard minimum", () => {
|
||||
const result = applyCustomApiConfig({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "foo-large",
|
||||
name: "foo-large",
|
||||
contextWindow: 4096,
|
||||
maxTokens: 1024,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
|
||||
const model = result.config.models?.providers?.custom?.models?.find(
|
||||
(entry) => entry.id === "foo-large",
|
||||
);
|
||||
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
|
||||
});
|
||||
|
||||
it("preserves existing custom model context window when already above minimum", () => {
|
||||
const result = applyCustomApiConfig({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "foo-large",
|
||||
name: "foo-large",
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
|
||||
const model = result.config.models?.providers?.custom?.models?.find(
|
||||
(entry) => entry.id === "foo-large",
|
||||
);
|
||||
expect(model?.contextWindow).toBe(131072);
|
||||
expect(model?.contextWindow).toBe(expectedContextWindow);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
|||
return createWizardPrompter(overrides, { defaultSelect: "" });
|
||||
}
|
||||
|
||||
function createSelectPrompter(
|
||||
responses: Partial<Record<string, string>>,
|
||||
): WizardPrompter["select"] {
|
||||
return vi.fn(async (params) => {
|
||||
const value = responses[params.message];
|
||||
if (value !== undefined) {
|
||||
return value as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
}
|
||||
|
||||
describe("promptRemoteGatewayConfig", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
|
||||
|
||||
|
|
@ -49,17 +61,10 @@ describe("promptRemoteGatewayConfig", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Select gateway") {
|
||||
return "0" as never;
|
||||
}
|
||||
if (params.message === "Connection method") {
|
||||
return "direct" as never;
|
||||
}
|
||||
if (params.message === "Gateway auth") {
|
||||
return "token" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
const select = createSelectPrompter({
|
||||
"Select gateway": "0",
|
||||
"Connection method": "direct",
|
||||
"Gateway auth": "token",
|
||||
});
|
||||
|
||||
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
||||
|
|
@ -106,12 +111,7 @@ describe("promptRemoteGatewayConfig", () => {
|
|||
return "";
|
||||
}) as WizardPrompter["text"];
|
||||
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway auth") {
|
||||
return "off" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
const select = createSelectPrompter({ "Gateway auth": "off" });
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const prompter = createPrompter({
|
||||
|
|
@ -138,12 +138,7 @@ describe("promptRemoteGatewayConfig", () => {
|
|||
return "";
|
||||
}) as WizardPrompter["text"];
|
||||
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway auth") {
|
||||
return "off" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
const select = createSelectPrompter({ "Gateway auth": "off" });
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const prompter = createPrompter({
|
||||
|
|
|
|||
|
|
@ -85,6 +85,66 @@ async function withUnknownUsageStore(run: () => Promise<void>) {
|
|||
}
|
||||
}
|
||||
|
||||
function getRuntimeLogs() {
|
||||
return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
}
|
||||
|
||||
function getJoinedRuntimeLogs() {
|
||||
return getRuntimeLogs().join("\n");
|
||||
}
|
||||
|
||||
async function runStatusAndGetLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand(args, runtime as never);
|
||||
return getRuntimeLogs();
|
||||
}
|
||||
|
||||
async function runStatusAndGetJoinedLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
||||
await runStatusAndGetLogs(args);
|
||||
return getJoinedRuntimeLogs();
|
||||
}
|
||||
|
||||
type ProbeGatewayResult = {
|
||||
ok: boolean;
|
||||
url: string;
|
||||
connectLatencyMs: number | null;
|
||||
error: string | null;
|
||||
close: { code: number; reason: string } | null;
|
||||
health: unknown;
|
||||
status: unknown;
|
||||
presence: unknown;
|
||||
configSnapshot: unknown;
|
||||
};
|
||||
|
||||
function mockProbeGatewayResult(overrides: Partial<ProbeGatewayResult>) {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "timeout",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>): Promise<T> {
|
||||
const prevValue = process.env[key];
|
||||
process.env[key] = value;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (prevValue === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prevValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadSessionStore: vi.fn().mockReturnValue({
|
||||
"+1000": createDefaultSessionStoreEntry(),
|
||||
|
|
@ -367,86 +427,68 @@ describe("statusCommand", () => {
|
|||
|
||||
it("prints unknown usage in formatted output when totalTokens is missing", async () => {
|
||||
await withUnknownUsageStore(async () => {
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
const logs = await runStatusAndGetLogs();
|
||||
expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(logs.some((l: string) => l.includes("OpenClaw status"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Overview"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Security audit"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Summary:"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("CRITICAL"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Dashboard"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("macos 14.0 (arm64)"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Memory"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Channels"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("+1000"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("50%"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("40% cached"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("LaunchAgent"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("FAQ:"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Troubleshooting:"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Next steps:"))).toBe(true);
|
||||
const logs = await runStatusAndGetLogs();
|
||||
for (const token of [
|
||||
"OpenClaw status",
|
||||
"Overview",
|
||||
"Security audit",
|
||||
"Summary:",
|
||||
"CRITICAL",
|
||||
"Dashboard",
|
||||
"macos 14.0 (arm64)",
|
||||
"Memory",
|
||||
"Channels",
|
||||
"WhatsApp",
|
||||
"bootstrap files",
|
||||
"Sessions",
|
||||
"+1000",
|
||||
"50%",
|
||||
"40% cached",
|
||||
"LaunchAgent",
|
||||
"FAQ:",
|
||||
"Troubleshooting:",
|
||||
"Next steps:",
|
||||
]) {
|
||||
expect(logs.some((line) => line.includes(token))).toBe(true);
|
||||
}
|
||||
expect(
|
||||
logs.some(
|
||||
(l: string) =>
|
||||
l.includes("openclaw status --all") ||
|
||||
l.includes("openclaw --profile isolated status --all") ||
|
||||
l.includes("openclaw status --all") ||
|
||||
l.includes("openclaw --profile isolated status --all"),
|
||||
(line) =>
|
||||
line.includes("openclaw status --all") ||
|
||||
line.includes("openclaw --profile isolated status --all"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows gateway auth when reachable", async () => {
|
||||
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "abcd1234";
|
||||
try {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
await withEnvVar("OPENCLAW_GATEWAY_TOKEN", "abcd1234", async () => {
|
||||
mockProbeGatewayResult({
|
||||
ok: true,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: 123,
|
||||
error: null,
|
||||
close: null,
|
||||
health: {},
|
||||
status: {},
|
||||
presence: [],
|
||||
configSnapshot: null,
|
||||
});
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
const logs = await runStatusAndGetLogs();
|
||||
expect(logs.some((l: string) => l.includes("auth token"))).toBe(true);
|
||||
} finally {
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces channel runtime errors from the gateway", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
mockProbeGatewayResult({
|
||||
ok: true,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: {},
|
||||
status: {},
|
||||
presence: [],
|
||||
configSnapshot: null,
|
||||
});
|
||||
mocks.callGateway.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
|
|
@ -471,98 +513,58 @@ describe("statusCommand", () => {
|
|||
},
|
||||
});
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(logs.join("\n")).toMatch(/Signal/i);
|
||||
expect(logs.join("\n")).toMatch(/iMessage/i);
|
||||
expect(logs.join("\n")).toMatch(/gateway:/i);
|
||||
expect(logs.join("\n")).toMatch(/WARN/);
|
||||
const joined = await runStatusAndGetJoinedLogs();
|
||||
expect(joined).toMatch(/Signal/i);
|
||||
expect(joined).toMatch(/iMessage/i);
|
||||
expect(joined).toMatch(/gateway:/i);
|
||||
expect(joined).toMatch(/WARN/);
|
||||
});
|
||||
|
||||
it("prints requestId-aware recovery guidance when gateway pairing is required", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
it.each([
|
||||
{
|
||||
name: "prints requestId-aware recovery guidance when gateway pairing is required",
|
||||
error: "connect failed: pairing required (requestId: req-123)",
|
||||
close: { code: 1008, reason: "pairing required (requestId: req-123)" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("Gateway pairing approval required.");
|
||||
expect(joined).toContain("devices approve req-123");
|
||||
expect(joined).toContain("devices approve --latest");
|
||||
expect(joined).toContain("devices list");
|
||||
});
|
||||
|
||||
it("prints fallback recovery guidance when pairing requestId is unavailable", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
closeReason: "pairing required (requestId: req-123)",
|
||||
includes: ["devices approve req-123"],
|
||||
excludes: [],
|
||||
},
|
||||
{
|
||||
name: "prints fallback recovery guidance when pairing requestId is unavailable",
|
||||
error: "connect failed: pairing required",
|
||||
close: { code: 1008, reason: "connect failed" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
closeReason: "connect failed",
|
||||
includes: [],
|
||||
excludes: ["devices approve req-"],
|
||||
},
|
||||
{
|
||||
name: "does not render unsafe requestId content into approval command hints",
|
||||
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
|
||||
closeReason: "pairing required (requestId: req-123;rm -rf /)",
|
||||
includes: [],
|
||||
excludes: ["devices approve req-123;rm -rf /"],
|
||||
},
|
||||
])("$name", async ({ error, closeReason, includes, excludes }) => {
|
||||
mockProbeGatewayResult({
|
||||
error,
|
||||
close: { code: 1008, reason: closeReason },
|
||||
});
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
const joined = logs.join("\n");
|
||||
const joined = await runStatusAndGetJoinedLogs();
|
||||
expect(joined).toContain("Gateway pairing approval required.");
|
||||
expect(joined).not.toContain("devices approve req-");
|
||||
expect(joined).toContain("devices approve --latest");
|
||||
expect(joined).toContain("devices list");
|
||||
});
|
||||
|
||||
it("does not render unsafe requestId content into approval command hints", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
|
||||
close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
||||
expect(joined).toContain("Gateway pairing approval required.");
|
||||
expect(joined).not.toContain("devices approve req-123;rm -rf /");
|
||||
expect(joined).toContain("devices approve --latest");
|
||||
for (const expected of includes) {
|
||||
expect(joined).toContain(expected);
|
||||
}
|
||||
for (const blocked of excludes) {
|
||||
expect(joined).not.toContain(blocked);
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts requestId from close reason when error text omits it", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
mockProbeGatewayResult({
|
||||
error: "connect failed: pairing required",
|
||||
close: { code: 1008, reason: "pairing required (requestId: req-close-456)" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
||||
const joined = await runStatusAndGetJoinedLogs();
|
||||
expect(joined).toContain("devices approve req-close-456");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,35 +2,57 @@ import { EventEmitter } from "node:events";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
|
||||
|
||||
function createGatewayWaitHarness() {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
return { emitter, disconnect, abort };
|
||||
}
|
||||
|
||||
function startGatewayWait(params?: {
|
||||
onGatewayError?: (error: unknown) => void;
|
||||
shouldStopOnError?: (error: unknown) => boolean;
|
||||
registerForceStop?: (fn: (error: unknown) => void) => void;
|
||||
}) {
|
||||
const harness = createGatewayWaitHarness();
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter: harness.emitter, disconnect: harness.disconnect },
|
||||
abortSignal: harness.abort.signal,
|
||||
...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}),
|
||||
...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}),
|
||||
...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}),
|
||||
});
|
||||
return { ...harness, promise };
|
||||
}
|
||||
|
||||
async function expectAbortToResolve(params: {
|
||||
emitter: EventEmitter;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
abort: AbortController;
|
||||
promise: Promise<void>;
|
||||
expectedDisconnectBeforeAbort?: number;
|
||||
}) {
|
||||
if (params.expectedDisconnectBeforeAbort !== undefined) {
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort);
|
||||
}
|
||||
expect(params.emitter.listenerCount("error")).toBe(1);
|
||||
params.abort.abort();
|
||||
await expect(params.promise).resolves.toBeUndefined();
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(params.emitter.listenerCount("error")).toBe(0);
|
||||
}
|
||||
|
||||
describe("waitForDiscordGatewayStop", () => {
|
||||
it("resolves on abort and disconnects gateway", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
expect(emitter.listenerCount("error")).toBe(1);
|
||||
abort.abort();
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait();
|
||||
await expectAbortToResolve({ emitter, disconnect, abort, promise });
|
||||
});
|
||||
|
||||
it("rejects on gateway error and disconnects", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const onGatewayError = vi.fn();
|
||||
const abort = new AbortController();
|
||||
const err = new Error("boom");
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
onGatewayError,
|
||||
});
|
||||
|
||||
|
|
@ -46,28 +68,23 @@ describe("waitForDiscordGatewayStop", () => {
|
|||
});
|
||||
|
||||
it("ignores gateway errors when instructed", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const onGatewayError = vi.fn();
|
||||
const abort = new AbortController();
|
||||
const err = new Error("transient");
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
onGatewayError,
|
||||
shouldStopOnError: () => false,
|
||||
});
|
||||
|
||||
emitter.emit("error", err);
|
||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||
expect(disconnect).toHaveBeenCalledTimes(0);
|
||||
expect(emitter.listenerCount("error")).toBe(1);
|
||||
|
||||
abort.abort();
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
await expectAbortToResolve({
|
||||
emitter,
|
||||
disconnect,
|
||||
abort,
|
||||
promise,
|
||||
expectedDisconnectBeforeAbort: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves on abort without a gateway", async () => {
|
||||
|
|
@ -83,14 +100,9 @@ describe("waitForDiscordGatewayStop", () => {
|
|||
});
|
||||
|
||||
it("rejects via registerForceStop and disconnects gateway", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
let forceStop: ((err: unknown) => void) | undefined;
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
const { emitter, disconnect, promise } = startGatewayWait({
|
||||
registerForceStop: (fn) => {
|
||||
forceStop = fn;
|
||||
},
|
||||
|
|
@ -106,14 +118,9 @@ describe("waitForDiscordGatewayStop", () => {
|
|||
});
|
||||
|
||||
it("ignores forceStop after promise already settled", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
let forceStop: ((err: unknown) => void) | undefined;
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
const { abort, disconnect, promise } = startGatewayWait({
|
||||
registerForceStop: (fn) => {
|
||||
forceStop = fn;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -59,24 +59,34 @@ describe("configureGatewayForOnboarding", () => {
|
|||
};
|
||||
}
|
||||
|
||||
it("generates a token when the prompt returns undefined", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
|
||||
async function runGatewayConfig(params?: {
|
||||
flow?: "advanced" | "quickstart";
|
||||
bindChoice?: string;
|
||||
authChoice?: "token" | "password";
|
||||
tailscaleChoice?: "off" | "serve";
|
||||
textQueue?: Array<string | undefined>;
|
||||
nextConfig?: Record<string, unknown>;
|
||||
}) {
|
||||
const authChoice = params?.authChoice ?? "token";
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "off"],
|
||||
textQueue: ["18789", undefined],
|
||||
selectQueue: [params?.bindChoice ?? "loopback", authChoice, params?.tailscaleChoice ?? "off"],
|
||||
textQueue: params?.textQueue ?? ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
return configureGatewayForOnboarding({
|
||||
flow: params?.flow ?? "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
nextConfig: params?.nextConfig ?? {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
quickstartGateway: createQuickstartGateway(authChoice),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
it("generates a token when the prompt returns undefined", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
const result = await runGatewayConfig();
|
||||
|
||||
expect(result.settings.gatewayToken).toBe("generated-token");
|
||||
expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([
|
||||
|
|
@ -95,21 +105,10 @@ describe("configureGatewayForOnboarding", () => {
|
|||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.randomToken.mockClear();
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "off"],
|
||||
textQueue: [],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
try {
|
||||
const result = await configureGatewayForOnboarding({
|
||||
const result = await runGatewayConfig({
|
||||
flow: "quickstart",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
textQueue: [],
|
||||
});
|
||||
|
||||
expect(result.settings.gatewayToken).toBe("token-from-env");
|
||||
|
|
@ -124,22 +123,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||
|
||||
it("does not set password to literal 'undefined' when prompt returns undefined", async () => {
|
||||
mocks.randomToken.mockReturnValue("unused");
|
||||
|
||||
// Flow: loopback bind → password auth → tailscale off
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "password", "off"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("password"),
|
||||
prompter,
|
||||
runtime,
|
||||
const result = await runGatewayConfig({
|
||||
authChoice: "password",
|
||||
});
|
||||
|
||||
const authConfig = result.nextConfig.gateway?.auth as { mode?: string; password?: string };
|
||||
|
|
@ -150,21 +135,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||
|
||||
it("seeds control UI allowed origins for non-loopback binds", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["lan", "token", "off"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
const result = await runGatewayConfig({
|
||||
bindChoice: "lan",
|
||||
});
|
||||
|
||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toEqual([
|
||||
|
|
@ -176,21 +148,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
const result = await runGatewayConfig({
|
||||
tailscaleChoice: "serve",
|
||||
});
|
||||
|
||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
||||
|
|
@ -201,21 +160,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
const result = await runGatewayConfig({
|
||||
tailscaleChoice: "serve",
|
||||
});
|
||||
|
||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined();
|
||||
|
|
@ -224,21 +170,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99");
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
const result = await runGatewayConfig({
|
||||
tailscaleChoice: "serve",
|
||||
});
|
||||
|
||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
||||
|
|
@ -249,16 +182,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||
it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
const result = await runGatewayConfig({
|
||||
tailscaleChoice: "serve",
|
||||
nextConfig: {
|
||||
gateway: {
|
||||
controlUi: {
|
||||
|
|
@ -266,10 +191,6 @@ describe("configureGatewayForOnboarding", () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue