Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c197c38c1 | ||
|
|
559494ee8a |
No files matched your search
+1
-4
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user