mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 23:14:32 +00:00
test(commands): dedupe command and onboarding test cases
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user