Compare commits

..
Author SHA1 Message Date
Peter Steinberger 5c197c38c1 fix: harden BlueBubbles voice memos (#1477) (thanks @Nicell) 2026-01-23 04:33:36 +00:00
Clawd 559494ee8a feat(bluebubbles): add asVoice support for voice memos
Add asVoice parameter to sendBlueBubblesAttachment that converts audio
to iMessage voice memo format (Opus CAF at 48kHz) and sets isAudioMessage
flag in the BlueBubbles API.

This follows the existing asVoice pattern used by Telegram.

- Convert audio to Opus CAF format using ffmpeg when asVoice=true
- Set isAudioMessage=true in BlueBubbles attachment API
- Pass asVoice through action handler and media-send
2026-01-23 04:16:35 +00:00
22 changed files with 711 additions and 1177 deletions

No files matched your search

+1 -4
View File
@@ -9,12 +9,11 @@ Docs: https://docs.clawd.bot
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477) Thanks @Nicell.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
@@ -39,8 +38,6 @@ Docs: https://docs.clawd.bot
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- Slack: hydrate thread root attachments for replies and include multi-file context. (#1479) Thanks @travisirby.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
+1 -5
View File
@@ -115,11 +115,7 @@ if (!shouldBuild()) {
runNode();
} else {
logRunner("Building TypeScript (dist is stale).");
const pnpmArgs = ["exec", compiler, ...projectArgs];
const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm";
const buildArgs =
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
const build = spawn(buildCmd, buildArgs, {
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
cwd,
env,
stdio: "inherit",
-14
View File
@@ -357,20 +357,6 @@ describe("handleSlackAction", () => {
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("passes threadId through to readSlackMessages", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction(
{ action: "readMessages", channelId: "C1", threadId: "12345.6789" },
cfg,
);
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
expect(opts?.threadId).toBe("12345.6789");
});
it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([
-2
View File
@@ -214,13 +214,11 @@ export async function handleSlackAction(
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
});
const messages = result.messages.map((message) =>
withNormalizedTimestamp(
@@ -1,34 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSlackActions } from "./slack.actions.js";
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../../agents/tools/slack-actions.js", () => ({
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
}));
describe("slack actions adapter", () => {
it("forwards threadId for read", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
const actions = createSlackActions("slack");
await actions.handleAction?.({
channel: "slack",
action: "read",
cfg,
params: {
channelId: "C1",
threadId: "171234.567",
},
});
const [params] = handleSlackAction.mock.calls[0] ?? [];
expect(params).toMatchObject({
action: "readMessages",
channelId: "C1",
threadId: "171234.567",
});
});
});
-1
View File
@@ -133,7 +133,6 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
accountId: accountId ?? undefined,
},
cfg,
+2 -6
View File
@@ -94,13 +94,9 @@ export function buildParseArgv(params: {
: baseArgv[0]?.endsWith("clawdbot")
? baseArgv.slice(1)
: baseArgv;
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
const looksLikeNode =
normalizedArgv.length >= 2 &&
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv];
}
-13
View File
@@ -217,19 +217,6 @@ export async function openUrl(url: string): Promise<boolean> {
}
}
export async function openUrlInBackground(url: string): Promise<boolean> {
if (process.platform !== "darwin") return false;
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv || resolved.command !== "open") return false;
const command = ["open", "-g", url];
try {
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
return true;
} catch {
return false;
}
}
export async function ensureWorkspaceAndSessions(
workspaceDir: string,
runtime: RuntimeEnv,
+445 -348
View File
@@ -49,372 +49,469 @@ function parseSseDataLines(text: string): string[] {
}
describe("OpenAI-compatible HTTP API (e2e)", () => {
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
{
const port = await getFreePort();
const server = await startServerWithDefaultConfig(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
}
{
const port = await getFreePort();
const server = await startServer(port, {
openAiChatCompletionsEnabled: false,
});
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
}
});
it("handles request validation and routing", async () => {
it("is disabled by default (requires config)", { timeout: 120_000 }, async () => {
const port = await getFreePort();
const server = await startServer(port);
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads } as never);
};
const server = await startServerWithDefaultConfig(port);
try {
{
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "GET",
headers: { authorization: "Bearer secret" },
});
expect(res.status).toBe(405);
await res.text();
}
{
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(res.status).toBe(401);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{ "x-clawdbot-agent-id": "beta" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
},
{ "x-clawdbot-agent-id": "alpha" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:alpha:/,
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{
"x-clawdbot-agent-id": "beta",
"x-clawdbot-session-key": "agent:beta:openai:custom",
},
);
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
"agent:beta:openai:custom",
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
user: "alice",
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openai-user:alice",
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "input_text", text: "world" },
],
},
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
await res.text();
}
{
mockAgentOnce([{ text: "I am Claude" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
await res.text();
}
{
mockAgentOnce([{ text: "ok" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
stream: false,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.object).toBe("chat.completion");
expect(Array.isArray(json.choices)).toBe(true);
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
expect(msg.role).toBe("assistant");
expect(msg.content).toBe("hello");
}
{
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "system", content: "yo" }],
});
expect(res.status).toBe(400);
const missingUserJson = (await res.json()) as Record<string, unknown>;
expect((missingUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
}
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true", async () => {
it("can be disabled via config (404)", async () => {
const port = await getFreePort();
const server = await startServer(port, {
openAiChatCompletionsEnabled: false,
});
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
});
it("rejects non-POST", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
{
agentCommand.mockReset();
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
return { payloads: [{ text: "hello" }] } as never;
});
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "GET",
headers: { authorization: "Bearer secret" },
});
expect(res.status).toBe(405);
} finally {
await server.close({ reason: "test done" });
}
});
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
it("rejects missing auth", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(res.status).toBe(401);
} finally {
await server.close({ reason: "test done" });
}
});
it("routes to a specific agent via header", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{ "x-clawdbot-agent-id": "beta" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
it("routes to a specific agent via model (no custom headers)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
it("prefers explicit header agent over model agent", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(
port,
{
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
},
{ "x-clawdbot-agent-id": "alpha" },
);
expect(res.status).toBe(200);
const text = await res.text();
const data = parseSseDataLines(text);
expect(data[data.length - 1]).toBe("[DONE]");
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:alpha:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hello");
}
it("honors x-clawdbot-session-key override", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
{
agentCommand.mockReset();
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
return { payloads: [{ text: "hihi" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{
"x-clawdbot-agent-id": "beta",
"x-clawdbot-session-key": "agent:beta:openai:custom",
},
);
expect(res.status).toBe(200);
const repeatedRes = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(repeatedRes.status).toBe(200);
const repeatedText = await repeatedRes.text();
const repeatedData = parseSseDataLines(repeatedText);
const repeatedChunks = repeatedData
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const repeatedContent = repeatedChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(repeatedContent).toBe("hihi");
}
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
"agent:beta:openai:custom",
);
} finally {
await server.close({ reason: "test done" });
}
});
{
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
it("uses OpenAI user for a stable session key", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const fallbackRes = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(fallbackRes.status).toBe(200);
const fallbackText = await fallbackRes.text();
expect(fallbackText).toContain("[DONE]");
expect(fallbackText).toContain("hello");
}
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
user: "alice",
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openai-user:alice",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("extracts user message text from array content", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "input_text", text: "world" },
],
},
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
} finally {
await server.close({ reason: "test done" });
}
});
it("includes conversation history when multiple messages are provided", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "I am Claude" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
} finally {
await server.close({ reason: "test done" });
}
});
it("does not include history markers for single message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("treats developer role same as system role", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
} finally {
await server.close({ reason: "test done" });
}
});
it("includes tool output when it is the latest message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
} finally {
await server.close({ reason: "test done" });
}
});
it("returns a non-streaming OpenAI chat.completion response", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: false,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.object).toBe("chat.completion");
expect(Array.isArray(json.choices)).toBe(true);
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
expect(msg.role).toBe("assistant");
expect(msg.content).toBe("hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("requires a user message", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "system", content: "yo" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true (delta events)", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
return { payloads: [{ text: "hello" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
const text = await res.text();
const data = parseSseDataLines(text);
expect(data[data.length - 1]).toBe("[DONE]");
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("preserves repeated identical deltas when streaming SSE", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
return { payloads: [{ text: "hihi" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const text = await res.text();
const data = parseSseDataLines(text);
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hihi");
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true (fallback when no deltas)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain("[DONE]");
expect(text).toContain("hello");
} finally {
await server.close({ reason: "test done" });
}
+71 -82
View File
@@ -1,106 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { afterEach, describe, expect, it } from "vitest";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
onceMessage,
startGatewayServer,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
beforeAll(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
afterEach(async () => {
for (const { server, ws } of servers) {
try {
ws.close();
await server.close();
} catch {
/* ignore */
}
}
servers.length = 0;
await new Promise((resolve) => setTimeout(resolve, 50));
});
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws);
return ws;
};
describe("gateway config.apply", () => {
it("writes config, stores sentinel, and schedules restart", async () => {
const ws = await openClient();
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const id = "req-1";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true);
// Verify sentinel file was created (restart was scheduled)
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
// Wait for file to be written
await new Promise((resolve) => setTimeout(resolve, 100));
try {
const id = "req-1";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");
} catch {
// File may not exist if signal delivery is mocked, verify response was ok instead
expect(res.ok).toBe(true);
// Verify sentinel file was created (restart was scheduled)
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
// Wait for file to be written
await new Promise((resolve) => setTimeout(resolve, 100));
try {
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");
} catch {
// File may not exist if signal delivery is mocked, verify response was ok instead
expect(res.ok).toBe(true);
}
} finally {
ws.close();
}
});
it("rejects invalid raw config", async () => {
const ws = await openClient();
try {
const id = "req-2";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: "{",
},
}),
);
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(false);
} finally {
ws.close();
}
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const id = "req-2";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: "{",
},
}),
);
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(false);
});
});
+1 -1
View File
@@ -12,7 +12,7 @@ import {
waitForSystemEvent,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
installGatewayTestHooks();
async function yieldToEventLoop() {
// Avoid relying on timers (fake timers can leak between tests).
+31 -33
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
@@ -21,35 +21,12 @@ import {
} from "./test-helpers.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
beforeAll(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, opts);
return ws;
};
installGatewayTestHooks();
describe("gateway server health/presence", () => {
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
const ws = await openClient();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
@@ -74,6 +51,7 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();
await server.close();
});
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
@@ -98,7 +76,8 @@ describe("gateway server health/presence", () => {
payload?: unknown;
};
const ws = await openClient();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitHeartbeat = onceMessage<EventFrame>(
ws,
@@ -138,10 +117,12 @@ describe("gateway server health/presence", () => {
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
ws.close();
await server.close();
});
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
const ws = await openClient();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
ws.send(
@@ -159,10 +140,12 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close();
await server.close();
});
test("agent events stream with seq", { timeout: 8000 }, async () => {
const ws = await openClient();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const runId = randomUUID();
const evtPromise = onceMessage(
@@ -180,6 +163,7 @@ describe("gateway server health/presence", () => {
expect(evt.payload.data.msg).toBe("hi");
ws.close();
await server.close();
});
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
@@ -193,7 +177,16 @@ describe("gateway server health/presence", () => {
});
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
const clients = await Promise.all([openClient(), openClient(), openClient()]);
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
await connectOk(c);
return c;
};
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const waits = clients.map((c) =>
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
);
@@ -211,6 +204,7 @@ describe("gateway server health/presence", () => {
expect(typeof evt.seq).toBe("number");
}
for (const c of clients) c.close();
await server.close();
});
test("presence includes client fingerprint", async () => {
@@ -228,7 +222,8 @@ describe("gateway server health/presence", () => {
signedAtMs,
token: null,
});
const ws = await openClient({
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
role,
scopes,
client: {
@@ -269,11 +264,13 @@ describe("gateway server health/presence", () => {
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
ws.close();
await server.close();
});
test("cli connections are not tracked as instances", async () => {
const { server, ws } = await startServerWithClient();
const cliId = `cli-${randomUUID()}`;
const ws = await openClient({
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.CLI,
version: "dev",
@@ -297,5 +294,6 @@ describe("gateway server health/presence", () => {
expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
ws.close();
await server.close();
});
});
+7 -13
View File
@@ -1,21 +1,9 @@
import { afterAll, beforeAll, test } from "vitest";
import { test } from "vitest";
import WebSocket from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js";
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
beforeAll(async () => {
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
});
function connectReq(
ws: WebSocket,
params: { clientId: string; platform: string; token?: string; password?: string },
@@ -55,6 +43,8 @@ function connectReq(
}
test("accepts clawdbot-ios as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -71,9 +61,12 @@ test("accepts clawdbot-ios as a valid gateway client id", async () => {
}
ws.close();
await server.close();
});
test("accepts clawdbot-android as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -90,4 +83,5 @@ test("accepts clawdbot-android as a valid gateway client id", async () => {
}
ws.close();
await server.close();
});
@@ -1,16 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
connectOk,
embeddedRunMock,
getFreePort,
installGatewayTestHooks,
piSdkMock,
rpcReq,
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
@@ -41,31 +39,7 @@ vi.mock("../auto-reply/reply/abort.js", async () => {
};
});
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
beforeAll(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const hello = await connectOk(ws, opts);
return { ws, hello };
};
installGatewayTestHooks();
describe("gateway server sessions", () => {
beforeEach(() => {
@@ -124,7 +98,8 @@ describe("gateway server sessions", () => {
},
});
const { ws, hello } = await openClient();
const { server, ws } = await startServerWithClient();
const hello = await connectOk(ws);
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
expect.arrayContaining([
"sessions.list",
@@ -361,6 +336,7 @@ describe("gateway server sessions", () => {
);
ws.close();
await server.close();
});
test("sessions.preview returns transcript previews", async () => {
@@ -389,7 +365,8 @@ describe("gateway server sessions", () => {
},
});
const { ws } = await openClient();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const preview = await rpcReq<{
previews: Array<{
key: string;
@@ -406,6 +383,7 @@ describe("gateway server sessions", () => {
expect(entry?.items[1]?.text).toContain("call weather");
ws.close();
await server.close();
});
test("sessions.delete rejects main and aborts active runs", async () => {
@@ -437,7 +415,8 @@ describe("gateway server sessions", () => {
embeddedRunMock.activeIds.add("sess-active");
embeddedRunMock.waitResults.set("sess-active", true);
const { ws } = await openClient();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" });
expect(mainDelete.ok).toBe(false);
@@ -460,5 +439,6 @@ describe("gateway server sessions", () => {
expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]);
ws.close();
await server.close();
});
});
+71 -112
View File
@@ -3,7 +3,7 @@ import { type AddressInfo, createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from "vitest";
import { afterEach, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws";
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
@@ -75,79 +75,67 @@ export async function writeSessionStore(params: {
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
}
async function setupGatewayTestHome() {
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
previousStateDir = process.env.CLAWDBOT_STATE_DIR;
previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH;
previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER;
previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
delete process.env.CLAWDBOT_CONFIG_PATH;
}
export function installGatewayTestHooks() {
beforeEach(async () => {
// Some tests intentionally use fake timers; ensure they don't leak into gateway suites.
vi.useRealTimers();
setLoggerOverride({ level: "silent", consoleLevel: "silent" });
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
previousStateDir = process.env.CLAWDBOT_STATE_DIR;
previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH;
previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER;
previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
delete process.env.CLAWDBOT_CONFIG_PATH;
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
tempConfigRoot = path.join(tempHome, ".clawdbot-test");
setTestConfigRoot(tempConfigRoot);
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined;
testState.gatewayAuth = undefined;
testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined;
testState.canvasHostPort = undefined;
testState.legacyIssues = [];
testState.legacyParsed = {};
testState.migrationConfig = null;
testState.migrationChanges = [];
testState.cronEnabled = false;
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
testState.channelsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
agentCommand.mockClear();
embeddedRunMock.activeIds.clear();
embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear();
drainSystemEvents(resolveMainSessionKeyFromConfig());
resetAgentRunContextForTest();
const mod = await serverModulePromise;
mod.__resetModelCatalogCacheForTest();
piSdkMock.enabled = false;
piSdkMock.discoverCalls = 0;
piSdkMock.models = [];
}, 60_000);
function applyGatewaySkipEnv() {
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
}
async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
// Some tests intentionally use fake timers; ensure they don't leak into gateway suites.
vi.useRealTimers();
setLoggerOverride({ level: "silent", consoleLevel: "silent" });
if (!tempHome) {
throw new Error("resetGatewayTestState called before temp home was initialized");
}
applyGatewaySkipEnv();
tempConfigRoot = options.uniqueConfigRoot
? await fs.mkdtemp(path.join(tempHome, "clawdbot-test-"))
: path.join(tempHome, ".clawdbot-test");
setTestConfigRoot(tempConfigRoot);
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined;
testState.gatewayAuth = undefined;
testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined;
testState.canvasHostPort = undefined;
testState.legacyIssues = [];
testState.legacyParsed = {};
testState.migrationConfig = null;
testState.migrationChanges = [];
testState.cronEnabled = false;
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
testState.channelsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
agentCommand.mockClear();
embeddedRunMock.activeIds.clear();
embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear();
drainSystemEvents(resolveMainSessionKeyFromConfig());
resetAgentRunContextForTest();
const mod = await serverModulePromise;
mod.__resetModelCatalogCacheForTest();
piSdkMock.enabled = false;
piSdkMock.discoverCalls = 0;
piSdkMock.models = [];
}
async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
vi.useRealTimers();
resetLogger();
if (options.restoreEnv) {
afterEach(async () => {
vi.useRealTimers();
resetLogger();
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
@@ -163,45 +151,16 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher;
if (previousSkipCanvasHost === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST;
else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previousSkipCanvasHost;
}
if (options.restoreEnv && tempHome) {
await fs.rm(tempHome, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 25,
});
tempHome = undefined;
}
tempConfigRoot = undefined;
}
export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) {
const scope = options?.scope ?? "test";
if (scope === "suite") {
beforeAll(async () => {
await setupGatewayTestHome();
await resetGatewayTestState({ uniqueConfigRoot: true });
});
beforeEach(async () => {
await resetGatewayTestState({ uniqueConfigRoot: true });
}, 60_000);
afterEach(async () => {
await cleanupGatewayTestHome({ restoreEnv: false });
});
afterAll(async () => {
await cleanupGatewayTestHome({ restoreEnv: true });
});
return;
}
beforeEach(async () => {
await setupGatewayTestHome();
await resetGatewayTestState({ uniqueConfigRoot: false });
}, 60_000);
afterEach(async () => {
await cleanupGatewayTestHome({ restoreEnv: true });
if (tempHome) {
await fs.rm(tempHome, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 25,
});
tempHome = undefined;
}
tempConfigRoot = undefined;
});
}
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { WebClient } from "@slack/web-api";
import { readSlackMessages } from "./actions.js";
function createClient() {
return {
conversations: {
replies: vi.fn(async () => ({ messages: [], has_more: false })),
history: vi.fn(async () => ({ messages: [], has_more: false })),
},
} as unknown as WebClient & {
conversations: {
replies: ReturnType<typeof vi.fn>;
history: ReturnType<typeof vi.fn>;
};
};
}
describe("readSlackMessages", () => {
it("uses conversations.replies and drops the parent message", async () => {
const client = createClient();
client.conversations.replies.mockResolvedValueOnce({
messages: [{ ts: "171234.567" }, { ts: "171234.890" }, { ts: "171235.000" }],
has_more: true,
});
const result = await readSlackMessages("C1", {
client,
threadId: "171234.567",
token: "xoxb-test",
});
expect(client.conversations.replies).toHaveBeenCalledWith({
channel: "C1",
ts: "171234.567",
limit: undefined,
latest: undefined,
oldest: undefined,
});
expect(client.conversations.history).not.toHaveBeenCalled();
expect(result.messages.map((message) => message.ts)).toEqual(["171234.890", "171235.000"]);
});
it("uses conversations.history when threadId is missing", async () => {
const client = createClient();
client.conversations.history.mockResolvedValueOnce({
messages: [{ ts: "1" }],
has_more: false,
});
const result = await readSlackMessages("C1", {
client,
limit: 20,
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: 20,
latest: undefined,
oldest: undefined,
});
expect(client.conversations.replies).not.toHaveBeenCalled();
expect(result.messages.map((message) => message.ts)).toEqual(["1"]);
});
});
-20
View File
@@ -186,29 +186,9 @@ export async function readSlackMessages(
limit?: number;
before?: string;
after?: string;
threadId?: string;
} = {},
): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> {
const client = await getClient(opts);
// Use conversations.replies for thread messages, conversations.history for channel messages.
if (opts.threadId) {
const result = await client.conversations.replies({
channel: channelId,
ts: opts.threadId,
limit: opts.limit,
latest: opts.before,
oldest: opts.after,
});
return {
// conversations.replies includes the parent message; drop it for replies-only reads.
messages: (result.messages ?? []).filter(
(message) => (message as SlackMessageSummary)?.ts !== opts.threadId,
) as SlackMessageSummary[],
hasMore: Boolean(result.has_more),
};
}
const result = await client.conversations.history({
channel: channelId,
limit: opts.limit,
+13 -29
View File
@@ -5,28 +5,17 @@ import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import type { SlackFile } from "../types.js";
export type SlackResolvedMedia = {
path: string;
contentType?: string;
placeholder: string;
};
export function resolveSlackFilePlaceholder(files?: SlackFile[]): string | undefined {
if (!files || files.length === 0) return undefined;
const named = files.find((file) => file?.name?.trim());
if (named?.name) return `[Slack file: ${named.name}]`;
return "[Slack file]";
}
export async function resolveSlackMediaList(params: {
export async function resolveSlackMedia(params: {
files?: SlackFile[];
token: string;
maxBytes: number;
}): Promise<SlackResolvedMedia[]> {
}): Promise<{
path: string;
contentType?: string;
placeholder: string;
} | null> {
const files = params.files ?? [];
const resolved: SlackResolvedMedia[] = [];
for (const file of files) {
if (file.size && file.size > params.maxBytes) continue;
const url = file.url_private_download ?? file.url_private;
if (!url) continue;
try {
@@ -48,23 +37,22 @@ export async function resolveSlackMediaList(params: {
params.maxBytes,
);
const label = fetched.fileName ?? file.name;
resolved.push({
return {
path: saved.path,
contentType: saved.contentType,
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
});
};
} catch {
// Ignore download failures and fall through to the next file.
}
}
return resolved;
return null;
}
export type SlackThreadStarter = {
text?: string;
text: string;
userId?: string;
ts?: string;
files?: SlackFile[];
};
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
@@ -83,18 +71,14 @@ export async function resolveSlackThreadStarter(params: {
ts: params.threadTs,
limit: 1,
inclusive: true,
})) as {
messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }>;
};
})) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
const message = response?.messages?.[0];
const text = (message?.text ?? "").trim();
const hasFiles = Boolean(message?.files && message.files.length > 0);
if (!message || (!text && !hasFiles)) return null;
if (!message || !text) return null;
const starter: SlackThreadStarter = {
text: text || undefined,
text,
userId: message.user,
ts: message.ts,
files: message.files,
};
THREAD_STARTER_CACHE.set(cacheKey, starter);
return starter;
@@ -1,188 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../media.js", async () => {
const actual = await vi.importActual<typeof import("../media.js")>("../media.js");
return {
...actual,
resolveSlackMediaList: vi.fn(),
resolveSlackThreadStarter: vi.fn(),
};
});
import type { App } from "@slack/bolt";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { RuntimeEnv } from "../../../runtime.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import { createSlackMonitorContext } from "../context.js";
import { resolveSlackMediaList, resolveSlackThreadStarter } from "../media.js";
import { prepareSlackMessage } from "./prepare.js";
const account: ResolvedSlackAccount = {
accountId: "default",
enabled: true,
botTokenSource: "config",
appTokenSource: "config",
config: {},
} as ResolvedSlackAccount;
const createContext = () => {
const slackCtx = createSlackMonitorContext({
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
channels: { slack: { enabled: true } },
} as ClawdbotConfig,
accountId: "default",
botToken: "token",
app: { client: {} } as App,
runtime: {} as RuntimeEnv,
botUserId: "B1",
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
sessionScope: "per-sender",
mainKey: "main",
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupDmEnabled: true,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: "open",
useAccessGroups: false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "off",
threadHistoryScope: "thread",
threadInheritParent: false,
slashCommand: {
enabled: false,
name: "clawd",
sessionPrefix: "slack:slash",
ephemeral: true,
},
textLimit: 4000,
ackReactionScope: "off",
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
slackCtx.resolveUserName = async () => ({ name: "Alice" });
return slackCtx;
};
const mediaMock = vi.mocked(resolveSlackMediaList);
const starterMock = vi.mocked(resolveSlackThreadStarter);
beforeEach(() => {
mediaMock.mockReset();
starterMock.mockReset();
mediaMock.mockResolvedValue([]);
starterMock.mockResolvedValue(null);
});
describe("prepareSlackMessage thread media", () => {
it("hydrates root files for thread replies without attachments", async () => {
const ctx = createContext();
starterMock.mockResolvedValueOnce({
text: "",
userId: "U2",
ts: "171234.000",
files: [{ name: "root.pdf" }],
});
mediaMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
path: "/tmp/root.pdf",
contentType: "application/pdf",
placeholder: "[Slack file: root.pdf]",
},
]);
const message: SlackMessageEvent = {
type: "message",
channel: "C1",
channel_type: "channel",
text: "",
user: "U1",
ts: "171234.111",
thread_ts: "171234.000",
} as SlackMessageEvent;
const prepared = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message", wasMentioned: true },
});
expect(prepared).not.toBeNull();
expect(prepared?.ctxPayload.RawBody).toBe("[Slack file: root.pdf]");
expect(prepared?.ctxPayload.MediaPath).toBe("/tmp/root.pdf");
expect(prepared?.ctxPayload.MediaPaths).toEqual(["/tmp/root.pdf"]);
});
it("emits MediaPaths for multiple attachments", async () => {
const ctx = createContext();
mediaMock.mockResolvedValueOnce([
{
path: "/tmp/a.png",
contentType: "image/png",
placeholder: "[Slack file: a.png]",
},
{
path: "/tmp/b.pdf",
contentType: "application/pdf",
placeholder: "[Slack file: b.pdf]",
},
]);
const message: SlackMessageEvent = {
type: "message",
channel: "C1",
channel_type: "channel",
text: "hi",
user: "U1",
ts: "171234.111",
files: [{ name: "a.png" }, { name: "b.pdf" }],
} as SlackMessageEvent;
const prepared = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message", wasMentioned: true },
});
expect(prepared?.ctxPayload.MediaPath).toBe("/tmp/a.png");
expect(prepared?.ctxPayload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.pdf"]);
expect(prepared?.ctxPayload.MediaTypes).toEqual(["image/png", "application/pdf"]);
expect(prepared?.ctxPayload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.pdf"]);
});
it("keeps file-only messages when downloads fail", async () => {
const ctx = createContext();
mediaMock.mockResolvedValueOnce([]);
const message: SlackMessageEvent = {
type: "message",
channel: "C1",
channel_type: "channel",
text: "",
user: "U1",
ts: "171234.111",
files: [{ name: "doc.txt" }],
} as SlackMessageEvent;
const prepared = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message", wasMentioned: true },
});
expect(prepared).not.toBeNull();
expect(prepared?.ctxPayload.RawBody).toBe("[Slack file: doc.txt]");
expect(prepared?.ctxPayload.MediaPath).toBeUndefined();
});
});
+40 -81
View File
@@ -39,11 +39,7 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
import { resolveSlackChannelConfig } from "../channel-config.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
import {
resolveSlackFilePlaceholder,
resolveSlackMediaList,
resolveSlackThreadStarter,
} from "../media.js";
import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
import type { PreparedSlackMessage } from "./types.js";
@@ -294,7 +290,11 @@ export async function prepareSlackMessage(params: {
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
if (ctx.historyLimit > 0) {
const pendingText = (message.text ?? "").trim();
const fallbackFile = resolveSlackFilePlaceholder(message.files) ?? "";
const fallbackFile = message.files?.[0]?.name
? `[Slack file: ${message.files[0].name}]`
: message.files?.length
? "[Slack file]"
: "";
const pendingBody = pendingText || fallbackFile;
if (pendingBody) {
recordPendingHistoryEntry({
@@ -313,80 +313,13 @@ export async function prepareSlackMessage(params: {
return null;
}
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
const mediaList = await resolveSlackMediaList({
const media = await resolveSlackMedia({
files: message.files,
token: ctx.botToken,
maxBytes: ctx.mediaMaxBytes,
});
const messageFilePlaceholder = resolveSlackFilePlaceholder(message.files);
let threadStarter: Awaited<ReturnType<typeof resolveSlackThreadStarter>> = null;
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
let threadStarterMediaList: Awaited<ReturnType<typeof resolveSlackMediaList>> = [];
if (isThreadReply && threadTs) {
threadStarter = await resolveSlackThreadStarter({
channelId: message.channel,
threadTs,
client: ctx.app.client,
});
if (threadStarter?.text) {
const starterUser = threadStarter.userId
? await ctx.resolveUserName(threadStarter.userId)
: null;
const starterName = starterUser?.name ?? threadStarter.userId ?? "Unknown";
const starterWithId = `${threadStarter.text}\n[slack message id: ${
threadStarter.ts ?? threadTs
} channel: ${message.channel}]`;
threadStarterBody = formatThreadStarterEnvelope({
channel: "Slack",
author: starterName,
timestamp: threadStarter.ts ? Math.round(Number(threadStarter.ts) * 1000) : undefined,
body: starterWithId,
envelope: envelopeOptions,
});
const snippet = threadStarter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
} else {
threadLabel = `Slack thread ${roomLabel}`;
}
if (!message.files?.length && threadStarter?.files?.length) {
threadStarterMediaList = await resolveSlackMediaList({
files: threadStarter.files,
token: ctx.botToken,
maxBytes: ctx.mediaMaxBytes,
});
if (threadStarterMediaList.length > 0) {
logVerbose(
`slack: hydrated thread starter files (${threadStarterMediaList.length}) from root message`,
);
}
}
}
const threadStarterFilePlaceholder =
!message.files?.length && threadStarter?.files?.length
? resolveSlackFilePlaceholder(threadStarter.files)
: undefined;
const effectiveMediaList = mediaList.length > 0 ? mediaList : threadStarterMediaList;
const effectiveMedia = effectiveMediaList[0];
const rawBody =
(message.text ?? "").trim() ||
messageFilePlaceholder ||
threadStarterFilePlaceholder ||
effectiveMedia?.placeholder ||
"";
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
if (!rawBody) return null;
const mediaPaths = effectiveMediaList.map((entry) => entry.path);
const mediaTypes = effectiveMediaList
.map((entry) => entry.contentType)
.filter(Boolean) as string[];
const ackReaction = resolveAckReaction(cfg, route.agentId);
const ackReactionValue = ackReaction ?? "";
@@ -420,6 +353,7 @@ export async function prepareSlackMessage(params: {
)
: null;
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Slack DM from ${senderName}`
@@ -446,6 +380,7 @@ export async function prepareSlackMessage(params: {
const storePath = resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
@@ -496,6 +431,33 @@ export async function prepareSlackMessage(params: {
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
if (isThreadReply && threadTs) {
const starter = await resolveSlackThreadStarter({
channelId: message.channel,
threadTs,
client: ctx.app.client,
});
if (starter?.text) {
const starterUser = starter.userId ? await ctx.resolveUserName(starter.userId) : null;
const starterName = starterUser?.name ?? starter.userId ?? "Unknown";
const starterWithId = `${starter.text}\n[slack message id: ${starter.ts ?? threadTs} channel: ${message.channel}]`;
threadStarterBody = formatThreadStarterEnvelope({
channel: "Slack",
author: starterName,
timestamp: starter.ts ? Math.round(Number(starter.ts) * 1000) : undefined,
body: starterWithId,
envelope: envelopeOptions,
});
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
} else {
threadLabel = `Slack thread ${roomLabel}`;
}
}
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
RawBody: rawBody,
@@ -521,12 +483,9 @@ export async function prepareSlackMessage(params: {
ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
MediaPath: effectiveMedia?.path,
MediaType: effectiveMedia?.contentType,
MediaUrl: effectiveMedia?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const,
OriginatingTo: slackTo,
+9 -85
View File
@@ -13,7 +13,6 @@ import {
detectBrowserOpenSupport,
formatControlUiSshHint,
openUrl,
openUrlInBackground,
probeGatewayReachable,
waitForGatewayReachable,
resolveControlUiLinks,
@@ -283,11 +282,6 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
"Control UI",
);
let controlUiOpened = false;
let controlUiOpenHint: string | undefined;
let seededInBackground = false;
let hatchChoice: "tui" | "web" | "later" | null = null;
if (!opts.skipUi && gatewayProbe.ok) {
if (hasBootstrap) {
await prompter.note(
@@ -299,27 +293,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
].join("\n"),
"Start TUI (best option!)",
);
await prompter.note(
[
"Gateway token: shared auth for the Gateway + Control UI.",
"Stored in: ~/.clawdbot/clawdbot.json (gateway.auth.token) or CLAWDBOT_GATEWAY_TOKEN.",
"Web UI stores a copy in this browser's localStorage (clawdbot.control.settings.v1).",
`Get the tokenized link anytime: ${formatCliCommand("clawdbot dashboard --no-open")}`,
].join("\n"),
"Token",
);
hatchChoice = (await prompter.select({
message: "How do you want to hatch your bot?",
options: [
{ value: "tui", label: "Hatch in TUI (recommended)" },
{ value: "web", label: "Open the Web UI" },
{ value: "later", label: "Do this later" },
],
initialValue: "tui",
})) as "tui" | "web" | "later";
if (hatchChoice === "tui") {
const wantsTui = await prompter.confirm({
message: "Do you want to hatch your bot now?",
initialValue: true,
});
if (wantsTui) {
await runTui({
url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
@@ -328,52 +306,6 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
deliver: false,
message: "Wake up, my friend!",
});
if (settings.authMode === "token" && settings.gatewayToken) {
seededInBackground = await openUrlInBackground(authedUrl);
}
if (seededInBackground) {
await prompter.note(
`Web UI seeded in the background. Open later with: ${formatCliCommand(
"clawdbot dashboard --no-open",
)}`,
"Web UI",
);
}
} else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.gatewayToken,
});
}
} else {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
basePath: controlUiBasePath,
token: settings.gatewayToken,
});
}
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
controlUiOpened
? "Opened in your browser. Keep that tab to control Clawdbot."
: "Copy/paste this URL in a browser on this machine to control Clawdbot.",
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
"Dashboard ready",
);
} else {
await prompter.note(
`When you're ready: ${formatCliCommand("clawdbot dashboard --no-open")}`,
"Later",
);
}
} else {
const browserSupport = await detectBrowserOpenSupport();
@@ -410,10 +342,9 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
);
const shouldOpenControlUi =
!opts.skipUi &&
settings.authMode === "token" &&
Boolean(settings.gatewayToken) &&
hatchChoice === null;
!opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken);
let controlUiOpened = false;
let controlUiOpenHint: string | undefined;
if (shouldOpenControlUi) {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
@@ -475,16 +406,9 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
"Web search (optional)",
);
await prompter.note(
'What now: https://clawd.bot/showcase ("What People Are Building").',
"What now",
);
await prompter.outro(
controlUiOpened
? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot."
: seededInBackground
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above."
: "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.",
: "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.",
);
}
+7 -6
View File
@@ -177,19 +177,19 @@ describe("runOnboardingWizard", () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
const select: WizardPrompter["select"] = vi.fn(async (opts) => {
if (opts.message === "How do you want to hatch your bot?") return "tui";
return "quickstart";
const confirm: WizardPrompter["confirm"] = vi.fn(async (opts) => {
if (opts.message === "Do you want to hatch your bot now?") return true;
return opts.initialValue ?? false;
});
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select,
select: vi.fn(async () => "quickstart"),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""),
confirm: vi.fn(async () => false),
confirm,
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
@@ -267,7 +267,8 @@ describe("runOnboardingWizard", () => {
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
expect(calls.length).toBeGreaterThan(0);
expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true);
const lastCall = calls[calls.length - 1];
expect(lastCall?.[1]).toBe("Web search (optional)");
} finally {
if (prevBraveKey === undefined) {
delete process.env.BRAVE_API_KEY;