Compare commits

..
Author SHA1 Message Date
Peter Steinberger 5b24bf7ac1 fix: improve matrix direct room resolution (#1436) (thanks @sibbl) 2026-01-23 05:34:37 +00:00
Sebastian Schubotz a61c8a2317 fix(matrix): improve error handling and fallback logic in resolveDirectRoomId 2026-01-23 05:25:37 +00:00
Sebastian Schubotz c717258a8f fix(matrix): fix broken import again 2026-01-23 05:25:37 +00:00
Stefan Galescu 7b40d1b261 feat(slack): add dm-specific replyToMode configuration (#1442)
Adds support for separate replyToMode settings for DMs vs channels:

- Add channels.slack.dm.replyToMode for DM-specific threading
- Keep channels.slack.replyToMode as default for channels
- Add resolveSlackReplyToMode helper to centralize logic
- Pass chatType through threading resolution chain

Usage:
```json5
{
  channels: {
    slack: {
      replyToMode: "off",     // channels
      dm: {
        replyToMode: "all"    // DMs always thread
      }
    }
  }
}
```

When dm.replyToMode is set, DMs use that mode; channels use the
top-level replyToMode. Backward compatible when not configured.
2026-01-23 05:13:23 +00:00
Peter Steinberger 2c10c601a8 test: harden docker onboarding waits 2026-01-23 05:10:59 +00:00
Travis Irby 578ac9f1a9 hydrate files from thread root message on replies
When replying to a Slack thread, files attached to the root message were
  not being fetched. The existing `resolveSlackThreadStarter()` fetched the
  root message text via `conversations.replies` but ignored the `files[]`
  array in the response.

  Changes:
  - Add `files` to `SlackThreadStarter` type and extract from API response
  - Download thread starter files when the reply message has no attachments
  - Add verbose log for thread starter file hydration

  Fixes issue where asking about a PDF in a thread reply would fail because
  the model never received the file content from the root message.
2026-01-23 05:10:36 +00:00
NeoandPeter Steinberger 2accb47e4d fix: follow soul.md more closely (#1434)
* Agents: honor SOUL.md persona guidance

* fix: harden SOUL.md detection (#1434) (thanks @neooriginal)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-23 05:00:13 +00:00
Tak hoffman b65916e0d1 CLI: fix Windows gateway startup 2026-01-23 04:47:01 +00:00
Peter Steinberger 9207840db4 docs: note #1482 in changelog 2026-01-23 04:38:08 +00:00
Peter Steinberger 784468d6c3 fix: harden BlueBubbles voice memos (#1477) (thanks @Nicell) 2026-01-23 04:38:08 +00:00
Clawd 02b5f403db 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:34:19 +00:00
Peter Steinberger 5d0d9e6323 feat: refine onboarding hatch flow 2026-01-23 04:32:23 +00:00
Peter Steinberger 64be2b2cd1 test: speed up gateway suite setup 2026-01-23 04:28:02 +00:00
dd2400fb2a fix: read Slack thread replies for message reads (#1450) (#1450)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Rodrigo Uroz <rodrigouroz@users.noreply.github.com>
2026-01-23 04:17:45 +00:00
Peter Steinberger 5d001cb953 refactor: add config logging helpers 2026-01-23 04:16:39 +00:00
42 changed files with 1292 additions and 690 deletions

No files matched your search

+5 -1
View File
@@ -9,11 +9,12 @@ Docs: https://docs.clawd.bot
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster - 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. - 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. - 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) Thanks @Nicell. - BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting - 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 /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - 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. - 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). - 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. - Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
@@ -32,12 +33,15 @@ Docs: https://docs.clawd.bot
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. - Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c. - Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
- Agents: make tool summaries more readable and only show optional params when set. - Agents: make tool summaries more readable and only show optional params when set.
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436) Thanks @sibbl.
- CLI: prefer `~` for home paths in output. - CLI: prefer `~` for home paths in output.
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. - Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact. - Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. - 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. - 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. - 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.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status. - 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). - Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
+20 -1
View File
@@ -304,7 +304,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"policy": "pairing", "policy": "pairing",
"allowFrom": ["U123", "U456", "*"], "allowFrom": ["U123", "U456", "*"],
"groupEnabled": false, "groupEnabled": false,
"groupChannels": ["G123"] "groupChannels": ["G123"],
"replyToMode": "all"
}, },
"channels": { "channels": {
"C123": { "allow": true, "requireMention": true }, "C123": { "allow": true, "requireMention": true },
@@ -361,6 +362,24 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### DM-specific threading
You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`:
```json5
{
channels: {
slack: {
replyToMode: "off", // default for channels
dm: {
replyToMode: "all" // DMs always thread
}
}
}
}
```
When `dm.replyToMode` is set, DMs use that mode; channels use the top-level `replyToMode`. If `dm.replyToMode` is not set, both DMs and channels use the top-level setting.
### Manual threading tags ### Manual threading tags
For fine-grained control, use these tags in agent responses: For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). - `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
saveMatrixCredentials, saveMatrixCredentials,
credentialsMatchConfig, credentialsMatchConfig,
touchMatrixCredentials, touchMatrixCredentials,
} = await import("./credentials.js"); } = await import("../credentials.js");
const cached = loadMatrixCredentials(env); const cached = loadMatrixCredentials(env);
const cachedCredentials = const cachedCredentials =
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "matrix-bot-sdk";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
beforeEach(async () => {
vi.resetModules();
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
});
describe("resolveMatrixRoomId", () => {
it("uses m.direct when available", async () => {
const userId = "@user:example.org";
const client = {
getAccountData: vi.fn().mockResolvedValue({
[userId]: ["!room:example.org"],
}),
getJoinedRooms: vi.fn(),
getJoinedRoomMembers: vi.fn(),
setAccountData: vi.fn(),
} as unknown as MatrixClient;
const roomId = await resolveMatrixRoomId(client, userId);
expect(roomId).toBe("!room:example.org");
expect(client.getJoinedRooms).not.toHaveBeenCalled();
expect(client.setAccountData).not.toHaveBeenCalled();
});
it("falls back to joined rooms and persists m.direct", async () => {
const userId = "@fallback:example.org";
const roomId = "!room:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi.fn().mockResolvedValue([
"@bot:example.org",
userId,
]),
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalledWith(
EventType.Direct,
expect.objectContaining({ [userId]: [roomId] }),
);
});
it("continues when a room member lookup fails", async () => {
const userId = "@continue:example.org";
const roomId = "!good:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const getJoinedRoomMembers = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce(["@bot:example.org", userId]);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
getJoinedRoomMembers,
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalled();
});
it("allows larger rooms when no 1:1 match exists", async () => {
const userId = "@group:example.org";
const roomId = "!group:example.org";
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi.fn().mockResolvedValue([
"@bot:example.org",
userId,
"@extra:example.org",
]),
setAccountData: vi.fn().mockResolvedValue(undefined),
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
});
});
describe("normalizeThreadId", () => {
it("returns null for empty thread ids", () => {
expect(normalizeThreadId(" ")).toBeNull();
expect(normalizeThreadId("$thread")).toBe("$thread");
});
});
+88 -10
View File
@@ -16,22 +16,100 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> { const directRoomCache = new Map<string, string>();
async function persistDirectRoom(
client: MatrixClient,
userId: string,
roomId: string,
): Promise<void> {
let directContent: MatrixDirectAccountData | null = null;
try {
directContent = (await client.getAccountData(
EventType.Direct,
)) as MatrixDirectAccountData | null;
} catch {
// Ignore fetch errors and fall back to an empty map.
}
const existing =
directContent && !Array.isArray(directContent) ? directContent : {};
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
if (current[0] === roomId) return;
const next = [roomId, ...current.filter((id) => id !== roomId)];
try {
await client.setAccountData(EventType.Direct, {
...existing,
[userId]: next,
});
} catch {
// Ignore persistence errors.
}
}
async function resolveDirectRoomId(
client: MatrixClient,
userId: string,
): Promise<string> {
const trimmed = userId.trim(); const trimmed = userId.trim();
if (!trimmed.startsWith("@")) { if (!trimmed.startsWith("@")) {
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); throw new Error(
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
);
} }
// matrix-bot-sdk: use getAccountData to retrieve m.direct
const cached = directRoomCache.get(trimmed);
if (cached) return cached;
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
try { try {
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null; const directContent = (await client.getAccountData(
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; EventType.Direct,
if (list.length > 0) return list[0]; )) as MatrixDirectAccountData | null;
const list = Array.isArray(directContent?.[trimmed])
? directContent[trimmed]
: [];
if (list.length > 0) {
directRoomCache.set(trimmed, list[0]);
return list[0];
}
} catch { } catch {
// Ignore errors, try fetching from server // Ignore and fall back.
} }
throw new Error(
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`, // 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
); // Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
let fallbackRoom: string | null = null;
try {
const rooms = await client.getJoinedRooms();
for (const roomId of rooms) {
let members: string[];
try {
members = await client.getJoinedRoomMembers(roomId);
} catch {
continue;
}
if (!members.includes(trimmed)) continue;
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) {
directRoomCache.set(trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
if (!fallbackRoom) {
fallbackRoom = roomId;
}
}
} catch {
// Ignore and fall back.
}
if (fallbackRoom) {
directRoomCache.set(trimmed, fallbackRoom);
await persistDirectRoom(client, trimmed, fallbackRoom);
return fallbackRoom;
}
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
} }
export async function resolveMatrixRoomId( export async function resolveMatrixRoomId(
+3 -2
View File
@@ -19,6 +19,7 @@ import {
readStringParam, readStringParam,
resolveDefaultSlackAccountId, resolveDefaultSlackAccountId,
resolveSlackAccount, resolveSlackAccount,
resolveSlackReplyToMode,
resolveSlackGroupRequireMention, resolveSlackGroupRequireMention,
buildSlackThreadingToolContext, buildSlackThreadingToolContext,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@@ -162,8 +163,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveRequireMention: resolveSlackGroupRequireMention, resolveRequireMention: resolveSlackGroupRequireMention,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId }) => resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowTagsWhenOff: true, allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
+11 -14
View File
@@ -46,7 +46,7 @@ TRASH
local needle="$1" local needle="$1"
local timeout_s="${2:-45}" local timeout_s="${2:-45}"
local needle_compact local needle_compact
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")" needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")"
local start_s local start_s
start_s="$(date +%s)" start_s="$(date +%s)"
while true; do while true; do
@@ -61,17 +61,12 @@ TRASH
let text = \"\"; let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
if (text.length > 20000) text = text.slice(-20000); if (text.length > 20000) text = text.slice(-20000);
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\"); const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\");
const haystack = sanitize(text); const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\");
const safeNeedle = sanitize(needle); const haystack = compact(text);
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]); const compactNeedle = compact(needle);
let escaped = \"\"; if (!compactNeedle) process.exit(1);
for (const ch of safeNeedle) { process.exit(haystack.includes(compactNeedle) ? 0 : 1);
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
}
const pattern = escaped.split(\"\").join(\".*\");
const re = new RegExp(pattern, \"i\");
process.exit(re.test(haystack) ? 0 : 1);
"; then "; then
return 0 return 0
fi fi
@@ -260,9 +255,11 @@ TRASH
send_skills_flow() { send_skills_flow() {
# Select skills section and skip optional installs. # Select skills section and skip optional installs.
send $'"'"'\r'"'"' 1.2 wait_for_log "Where will the Gateway run?" 60 || true
send $'"'"'\r'"'"' 0.6
# Configure skills now? -> No # Configure skills now? -> No
send $'"'"'n\r'"'"' 1.5 wait_for_log "Configure skills now?" 60 || true
send $'"'"'n\r'"'"' 0.8
send "" 1.0 send "" 1.0
} }
+5 -1
View File
@@ -115,7 +115,11 @@ if (!shouldBuild()) {
runNode(); runNode();
} else { } else {
logRunner("Building TypeScript (dist is stale)."); logRunner("Building TypeScript (dist is stale).");
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], { 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, {
cwd, cwd,
env, env,
stdio: "inherit", stdio: "inherit",
+14
View File
@@ -237,6 +237,20 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Bravo"); expect(prompt).toContain("Bravo");
}); });
it("adds SOUL guidance when a soul file is present", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
contextFiles: [
{ path: "./SOUL.md", content: "Persona" },
{ path: "dir\\SOUL.md", content: "Persona Windows" },
],
});
expect(prompt).toContain(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
});
it("summarizes the message tool when available", () => { it("summarizes the message tool when available", () => {
const prompt = buildAgentSystemPrompt({ const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd", workspaceDir: "/tmp/clawd",
+12 -6
View File
@@ -517,12 +517,18 @@ export function buildAgentSystemPrompt(params: {
const contextFiles = params.contextFiles ?? []; const contextFiles = params.contextFiles ?? [];
if (contextFiles.length > 0) { if (contextFiles.length > 0) {
lines.push( const hasSoulFile = contextFiles.some((file) => {
"# Project Context", const normalizedPath = file.path.trim().replace(/\\/g, "/");
"", const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
"The following project context files have been loaded:", return baseName.toLowerCase() === "soul.md";
"", });
); lines.push("# Project Context", "", "The following project context files have been loaded:");
if (hasSoulFile) {
lines.push(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
}
lines.push("");
for (const file of contextFiles) { for (const file of contextFiles) {
lines.push(`## ${file.path}`, "", file.content, ""); lines.push(`## ${file.path}`, "", file.content, "");
} }
+14
View File
@@ -357,6 +357,20 @@ describe("handleSlackAction", () => {
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); 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 () => { it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([ listSlackPins.mockResolvedValueOnce([
+2
View File
@@ -214,11 +214,13 @@ export async function handleSlackAction(
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before"); const before = readStringParam(params, "before");
const after = readStringParam(params, "after"); const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await readSlackMessages(channelId, { const result = await readSlackMessages(channelId, {
...readOpts, ...readOpts,
limit, limit,
before: before ?? undefined, before: before ?? undefined,
after: after ?? undefined, after: after ?? undefined,
threadId: threadId ?? undefined,
}); });
const messages = result.messages.map((message) => const messages = result.messages.map((message) =>
withNormalizedTimestamp( withNormalizedTimestamp(
+1
View File
@@ -136,6 +136,7 @@ export async function runReplyAgent(params: {
followupRun.run.config, followupRun.run.config,
replyToChannel, replyToChannel,
sessionCtx.AccountId, sessionCtx.AccountId,
sessionCtx.ChatType,
); );
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
const cfg = followupRun.run.config; const cfg = followupRun.run.config;
+1
View File
@@ -204,6 +204,7 @@ export function createFollowupRunner(params: {
queued.run.config, queued.run.config,
replyToChannel, replyToChannel,
queued.originatingAccountId, queued.originatingAccountId,
queued.originatingChatType,
); );
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
+1
View File
@@ -358,6 +358,7 @@ export async function runPreparedReply(
originatingTo: ctx.OriginatingTo, originatingTo: ctx.OriginatingTo,
originatingAccountId: ctx.AccountId, originatingAccountId: ctx.AccountId,
originatingThreadId: ctx.MessageThreadId, originatingThreadId: ctx.MessageThreadId,
originatingChatType: ctx.ChatType,
run: { run: {
agentId, agentId,
agentDir, agentDir,
+2
View File
@@ -39,6 +39,8 @@ export type FollowupRun = {
originatingAccountId?: string; originatingAccountId?: string;
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */ /** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
originatingThreadId?: string | number; originatingThreadId?: string | number;
/** Chat type for context-aware threading (e.g., DM vs channel). */
originatingChatType?: string;
run: { run: {
agentId: string; agentId: string;
agentDir: string; agentDir: string;
@@ -31,6 +31,33 @@ describe("resolveReplyToMode", () => {
expect(resolveReplyToMode(cfg, "discord")).toBe("first"); expect(resolveReplyToMode(cfg, "discord")).toBe("first");
expect(resolveReplyToMode(cfg, "slack")).toBe("all"); expect(resolveReplyToMode(cfg, "slack")).toBe("all");
}); });
it("uses dm-specific replyToMode for Slack DMs when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
});
it("falls back to top-level replyToMode when dm.replyToMode is not configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
dm: { enabled: true },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
});
}); });
describe("createReplyToModeFilter", () => { describe("createReplyToModeFilter", () => {
+2
View File
@@ -9,12 +9,14 @@ export function resolveReplyToMode(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
channel?: OriginatingChannelType, channel?: OriginatingChannelType,
accountId?: string | null, accountId?: string | null,
chatType?: string | null,
): ReplyToMode { ): ReplyToMode {
const provider = normalizeChannelId(channel); const provider = normalizeChannelId(channel);
if (!provider) return "all"; if (!provider) return "all";
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({ const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
cfg, cfg,
accountId, accountId,
chatType,
}); });
return resolved ?? "all"; return resolved ?? "all";
} }
+3 -3
View File
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount } from "../slack/accounts.js"; import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js"; import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveRequireMention: resolveSlackGroupRequireMention, resolveRequireMention: resolveSlackGroupRequireMention,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId }) => resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowTagsWhenOff: true, allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
@@ -0,0 +1,34 @@
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,6 +133,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
limit, limit,
before: readStringParam(params, "before"), before: readStringParam(params, "before"),
after: readStringParam(params, "after"), after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}, },
cfg, cfg,
+1
View File
@@ -198,6 +198,7 @@ export type ChannelThreadingAdapter = {
resolveReplyToMode?: (params: { resolveReplyToMode?: (params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
accountId?: string | null; accountId?: string | null;
chatType?: string | null;
}) => "off" | "first" | "all"; }) => "off" | "first" | "all";
allowTagsWhenOff?: boolean; allowTagsWhenOff?: boolean;
buildToolContext?: (params: { buildToolContext?: (params: {
+6 -2
View File
@@ -94,9 +94,13 @@ export function buildParseArgv(params: {
: baseArgv[0]?.endsWith("clawdbot") : baseArgv[0]?.endsWith("clawdbot")
? baseArgv.slice(1) ? baseArgv.slice(1)
: baseArgv; : baseArgv;
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? ""; const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
const looksLikeNode = const looksLikeNode =
normalizedArgv.length >= 2 && (executable === "node" || executable === "bun"); normalizedArgv.length >= 2 &&
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
if (looksLikeNode) return normalizedArgv; if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv]; return ["node", programName || "clawdbot", ...normalizedArgv];
} }
+13
View File
@@ -217,6 +217,19 @@ 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( export async function ensureWorkspaceAndSessions(
workspaceDir: string, workspaceDir: string,
runtime: RuntimeEnv, runtime: RuntimeEnv,
+2
View File
@@ -17,6 +17,8 @@ export type SlackDmConfig = {
groupEnabled?: boolean; groupEnabled?: boolean;
/** Optional allowlist for group DM channels (ids or slugs). */ /** Optional allowlist for group DM channels (ids or slugs). */
groupChannels?: Array<string | number>; groupChannels?: Array<string | number>;
/** Control reply threading for DMs (off|first|all). Overrides top-level replyToMode for DMs. */
replyToMode?: ReplyToMode;
}; };
export type SlackChannelConfig = { export type SlackChannelConfig = {
+333 -430
View File
@@ -49,469 +49,372 @@ function parseSseDataLines(text: string): string[] {
} }
describe("OpenAI-compatible HTTP API (e2e)", () => { describe("OpenAI-compatible HTTP API (e2e)", () => {
it("is disabled by default (requires config)", { timeout: 120_000 }, async () => { it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
const port = await getFreePort(); {
const server = await startServerWithDefaultConfig(port); const port = await getFreePort();
try { const server = await startServerWithDefaultConfig(port);
const res = await postChatCompletions(port, { try {
model: "clawdbot", const res = await postChatCompletions(port, {
messages: [{ role: "user", content: "hi" }], 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,
}); });
expect(res.status).toBe(404); try {
} finally { const res = await postChatCompletions(port, {
await server.close({ reason: "test done" }); model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
} }
}); });
it("can be disabled via config (404)", async () => { it("handles request validation and routing", 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 port = await getFreePort();
const server = await startServer(port); const server = await startServer(port);
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads } as never);
};
try { try {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { {
method: "GET", const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
headers: { authorization: "Bearer secret" }, method: "GET",
}); headers: { authorization: "Bearer secret" },
expect(res.status).toBe(405); });
} finally { expect(res.status).toBe(405);
await server.close({ reason: "test done" }); await res.text();
} }
});
it("rejects missing auth", async () => { {
const port = await getFreePort(); const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
const server = await startServer(port); method: "POST",
try { headers: { "content-type": "application/json" },
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
method: "POST", });
headers: { "content-type": "application/json" }, expect(res.status).toBe(401);
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), await res.text();
}); }
expect(res.status).toBe(401);
} finally {
await server.close({ reason: "test done" });
}
});
it("routes to a specific agent via header", async () => { {
agentCommand.mockResolvedValueOnce({ mockAgentOnce([{ text: "hello" }]);
payloads: [{ text: "hello" }], const res = await postChatCompletions(
} as never); port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{ "x-clawdbot-agent-id": "beta" },
);
expect(res.status).toBe(200);
const port = await getFreePort(); expect(agentCommand).toHaveBeenCalledTimes(1);
const server = await startServer(port); const [opts] = agentCommand.mock.calls[0] ?? [];
try { expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
const res = await postChatCompletions( /^agent:beta:/,
port, );
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] }, await res.text();
{ "x-clawdbot-agent-id": "beta" }, }
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1); {
const [opts] = agentCommand.mock.calls[0] ?? []; mockAgentOnce([{ text: "hello" }]);
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( const res = await postChatCompletions(port, {
/^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", model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }], messages: [{ role: "user", content: "hi" }],
}, });
{ "x-clawdbot-agent-id": "alpha" }, expect(res.status).toBe(200);
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? []; const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:alpha:/, /^agent:beta:/,
); );
} finally { await res.text();
await server.close({ reason: "test done" }); }
}
});
it("honors x-clawdbot-session-key override", async () => { {
agentCommand.mockResolvedValueOnce({ mockAgentOnce([{ text: "hello" }]);
payloads: [{ text: "hello" }], const res = await postChatCompletions(
} as never); port,
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 [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
"agent:beta:openai:custom",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("uses OpenAI user for a stable session key", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
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", model: "clawdbot:beta",
content: [ messages: [{ role: "user", content: "hi" }],
{ type: "text", text: "hello" },
{ type: "input_text", text: "world" },
],
}, },
], { "x-clawdbot-agent-id": "alpha" },
}); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? []; expect(agentCommand).toHaveBeenCalledTimes(1);
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld"); 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",
);
}
} finally { } finally {
await server.close({ reason: "test done" }); await server.close({ reason: "test done" });
} }
}); });
it("includes conversation history when multiple messages are provided", async () => { it("streams SSE chunks when stream=true", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "I am Claude" }],
} as never);
const port = await getFreePort(); const port = await getFreePort();
const server = await startServer(port); const server = await startServer(port);
try { try {
const res = await postChatCompletions(port, { {
model: "clawdbot", agentCommand.mockReset();
messages: [ agentCommand.mockImplementationOnce(async (opts: unknown) => {
{ role: "system", content: "You are a helpful assistant." }, const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
{ role: "user", content: "Hello, who are you?" }, emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
{ role: "assistant", content: "I am Claude." }, emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
{ role: "user", content: "What did I just ask you?" }, return { payloads: [{ text: "hello" }] } as never;
], });
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? []; const res = await postChatCompletions(port, {
const message = (opts as { message?: string } | undefined)?.message ?? ""; stream: true,
expect(message).toContain(HISTORY_CONTEXT_MARKER); model: "clawdbot",
expect(message).toContain("User: Hello, who are you?"); messages: [{ role: "user", content: "hi" }],
expect(message).toContain("Assistant: I am Claude."); });
expect(message).toContain(CURRENT_MESSAGE_MARKER); expect(res.status).toBe(200);
expect(message).toContain("User: What did I just ask you?"); expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
} finally {
await server.close({ reason: "test done" });
}
});
it("does not include history markers for single message", async () => { const text = await res.text();
agentCommand.mockResolvedValueOnce({ const data = parseSseDataLines(text);
payloads: [{ text: "hello" }], expect(data[data.length - 1]).toBe("[DONE]");
} as never);
const port = await getFreePort(); const jsonChunks = data
const server = await startServer(port); .filter((d) => d !== "[DONE]")
try { .map((d) => JSON.parse(d) as Record<string, unknown>);
const res = await postChatCompletions(port, { expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
model: "clawdbot", const allContent = jsonChunks
messages: [ .flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
{ role: "system", content: "You are a helpful assistant." }, .map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
{ role: "user", content: "Hello" }, .filter((v): v is string => typeof v === "string")
], .join("");
}); expect(allContent).toBe("hello");
expect(res.status).toBe(200); }
const [opts] = agentCommand.mock.calls[0] ?? []; {
const message = (opts as { message?: string } | undefined)?.message ?? ""; agentCommand.mockReset();
expect(message).not.toContain(HISTORY_CONTEXT_MARKER); agentCommand.mockImplementationOnce(async (opts: unknown) => {
expect(message).not.toContain(CURRENT_MESSAGE_MARKER); const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
expect(message).toBe("Hello"); emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
} finally { emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
await server.close({ reason: "test done" }); return { payloads: [{ text: "hihi" }] } as never;
} });
});
it("treats developer role same as system role", async () => { const repeatedRes = await postChatCompletions(port, {
agentCommand.mockResolvedValueOnce({ stream: true,
payloads: [{ text: "hello" }], model: "clawdbot",
} as never); 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 port = await getFreePort(); {
const server = await startServer(port); agentCommand.mockReset();
try { agentCommand.mockResolvedValueOnce({
const res = await postChatCompletions(port, { payloads: [{ text: "hello" }],
model: "clawdbot", } as never);
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 fallbackRes = await postChatCompletions(port, {
const extraSystemPrompt = stream: true,
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; model: "clawdbot",
expect(extraSystemPrompt).toBe("You are a helpful assistant."); messages: [{ role: "user", content: "hi" }],
} finally { });
await server.close({ reason: "test done" }); expect(fallbackRes.status).toBe(200);
} const fallbackText = await fallbackRes.text();
}); expect(fallbackText).toContain("[DONE]");
expect(fallbackText).toContain("hello");
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 { } finally {
await server.close({ reason: "test done" }); await server.close({ reason: "test done" });
} }
+82 -71
View File
@@ -1,95 +1,106 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { import {
connectOk, connectOk,
getFreePort,
installGatewayTestHooks, installGatewayTestHooks,
onceMessage, onceMessage,
startServerWithClient, startGatewayServer,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks(); installGatewayTestHooks({ scope: "suite" });
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = []; let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
afterEach(async () => { beforeAll(async () => {
for (const { server, ws } of servers) { previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
try { delete process.env.CLAWDBOT_GATEWAY_TOKEN;
ws.close(); port = await getFreePort();
await server.close(); server = await startGatewayServer(port);
} 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", () => { describe("gateway config.apply", () => {
it("writes config, stores sentinel, and schedules restart", async () => { it("writes config, stores sentinel, and schedules restart", async () => {
const result = await startServerWithClient(); const ws = await openClient();
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 { try {
const raw = await fs.readFile(sentinelPath, "utf-8"); const id = "req-1";
const parsed = JSON.parse(raw) as { payload?: { kind?: string } }; ws.send(
expect(parsed.payload?.kind).toBe("config-apply"); JSON.stringify({
} catch { type: "req",
// File may not exist if signal delivery is mocked, verify response was ok instead 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); 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 () => { it("rejects invalid raw config", async () => {
const result = await startServerWithClient(); const ws = await openClient();
servers.push(result); try {
const { ws } = result; const id = "req-2";
await connectOk(ws); ws.send(
JSON.stringify({
const id = "req-2"; type: "req",
ws.send( id,
JSON.stringify({ method: "config.apply",
type: "req", params: {
id, raw: "{",
method: "config.apply", },
params: { }),
raw: "{", );
}, const res = await onceMessage<{ ok: boolean; error?: unknown }>(
}), ws,
); (o) => o.type === "res" && o.id === id,
const res = await onceMessage<{ ok: boolean; error?: unknown }>( );
ws, expect(res.ok).toBe(false);
(o) => o.type === "res" && o.id === id, } finally {
); ws.close();
expect(res.ok).toBe(false); }
}); });
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ import {
waitForSystemEvent, waitForSystemEvent,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks(); installGatewayTestHooks({ scope: "suite" });
async function yieldToEventLoop() { async function yieldToEventLoop() {
// Avoid relying on timers (fake timers can leak between tests). // Avoid relying on timers (fake timers can leak between tests).
+33 -31
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, test } from "vitest"; import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { import {
@@ -21,12 +21,35 @@ import {
} from "./test-helpers.js"; } from "./test-helpers.js";
import { buildDeviceAuthPayload } from "./device-auth.js"; import { buildDeviceAuthPayload } from "./device-auth.js";
installGatewayTestHooks(); 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;
};
describe("gateway server health/presence", () => { describe("gateway server health/presence", () => {
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => { test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
const { server, ws } = await startServerWithClient(); const ws = await openClient();
await connectOk(ws);
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1"); const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1"); const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
@@ -51,7 +74,6 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(presence.payload)).toBe(true); expect(Array.isArray(presence.payload)).toBe(true);
ws.close(); ws.close();
await server.close();
}); });
test("broadcasts heartbeat events and serves last-heartbeat", async () => { test("broadcasts heartbeat events and serves last-heartbeat", async () => {
@@ -76,8 +98,7 @@ describe("gateway server health/presence", () => {
payload?: unknown; payload?: unknown;
}; };
const { server, ws } = await startServerWithClient(); const ws = await openClient();
await connectOk(ws);
const waitHeartbeat = onceMessage<EventFrame>( const waitHeartbeat = onceMessage<EventFrame>(
ws, ws,
@@ -117,12 +138,10 @@ describe("gateway server health/presence", () => {
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false); expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
ws.close(); ws.close();
await server.close();
}); });
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => { test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient(); const ws = await openClient();
await connectOk(ws);
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence"); const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
ws.send( ws.send(
@@ -140,12 +159,10 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(evt.payload?.presence)).toBe(true); expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close(); ws.close();
await server.close();
}); });
test("agent events stream with seq", { timeout: 8000 }, async () => { test("agent events stream with seq", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient(); const ws = await openClient();
await connectOk(ws);
const runId = randomUUID(); const runId = randomUUID();
const evtPromise = onceMessage( const evtPromise = onceMessage(
@@ -163,7 +180,6 @@ describe("gateway server health/presence", () => {
expect(evt.payload.data.msg).toBe("hi"); expect(evt.payload.data.msg).toBe("hi");
ws.close(); ws.close();
await server.close();
}); });
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => { test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
@@ -177,16 +193,7 @@ describe("gateway server health/presence", () => {
}); });
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => { test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
const port = await getFreePort(); const clients = await Promise.all([openClient(), openClient(), openClient()]);
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) => const waits = clients.map((c) =>
onceMessage(c, (o) => o.type === "event" && o.event === "presence"), onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
); );
@@ -204,7 +211,6 @@ describe("gateway server health/presence", () => {
expect(typeof evt.seq).toBe("number"); expect(typeof evt.seq).toBe("number");
} }
for (const c of clients) c.close(); for (const c of clients) c.close();
await server.close();
}); });
test("presence includes client fingerprint", async () => { test("presence includes client fingerprint", async () => {
@@ -222,8 +228,7 @@ describe("gateway server health/presence", () => {
signedAtMs, signedAtMs,
token: null, token: null,
}); });
const { server, ws } = await startServerWithClient(); const ws = await openClient({
await connectOk(ws, {
role, role,
scopes, scopes,
client: { client: {
@@ -264,13 +269,11 @@ describe("gateway server health/presence", () => {
expect(clientEntry?.modelIdentifier).toBe("iPad16,6"); expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
ws.close(); ws.close();
await server.close();
}); });
test("cli connections are not tracked as instances", async () => { test("cli connections are not tracked as instances", async () => {
const { server, ws } = await startServerWithClient();
const cliId = `cli-${randomUUID()}`; const cliId = `cli-${randomUUID()}`;
await connectOk(ws, { const ws = await openClient({
client: { client: {
id: GATEWAY_CLIENT_NAMES.CLI, id: GATEWAY_CLIENT_NAMES.CLI,
version: "dev", version: "dev",
@@ -294,6 +297,5 @@ describe("gateway server health/presence", () => {
expect(entries.some((e) => e.instanceId === cliId)).toBe(false); expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
ws.close(); ws.close();
await server.close();
}); });
}); });
+13 -7
View File
@@ -1,9 +1,21 @@
import { test } from "vitest"; import { afterAll, beforeAll, test } from "vitest";
import WebSocket from "ws"; import WebSocket from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.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( function connectReq(
ws: WebSocket, ws: WebSocket,
params: { clientId: string; platform: string; token?: string; password?: string }, params: { clientId: string; platform: string; token?: string; password?: string },
@@ -43,8 +55,6 @@ function connectReq(
} }
test("accepts clawdbot-ios as a valid gateway client id", async () => { 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}`); const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -61,12 +71,9 @@ test("accepts clawdbot-ios as a valid gateway client id", async () => {
} }
ws.close(); ws.close();
await server.close();
}); });
test("accepts clawdbot-android as a valid gateway client id", async () => { 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}`); const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -83,5 +90,4 @@ test("accepts clawdbot-android as a valid gateway client id", async () => {
} }
ws.close(); ws.close();
await server.close();
}); });
@@ -1,14 +1,16 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { import {
connectOk, connectOk,
embeddedRunMock, embeddedRunMock,
getFreePort,
installGatewayTestHooks, installGatewayTestHooks,
piSdkMock, piSdkMock,
rpcReq, rpcReq,
startServerWithClient, startGatewayServer,
testState, testState,
writeSessionStore, writeSessionStore,
} from "./test-helpers.js"; } from "./test-helpers.js";
@@ -39,7 +41,31 @@ vi.mock("../auto-reply/reply/abort.js", async () => {
}; };
}); });
installGatewayTestHooks(); 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 };
};
describe("gateway server sessions", () => { describe("gateway server sessions", () => {
beforeEach(() => { beforeEach(() => {
@@ -98,8 +124,7 @@ describe("gateway server sessions", () => {
}, },
}); });
const { server, ws } = await startServerWithClient(); const { ws, hello } = await openClient();
const hello = await connectOk(ws);
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual( expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
expect.arrayContaining([ expect.arrayContaining([
"sessions.list", "sessions.list",
@@ -336,7 +361,6 @@ describe("gateway server sessions", () => {
); );
ws.close(); ws.close();
await server.close();
}); });
test("sessions.preview returns transcript previews", async () => { test("sessions.preview returns transcript previews", async () => {
@@ -365,8 +389,7 @@ describe("gateway server sessions", () => {
}, },
}); });
const { server, ws } = await startServerWithClient(); const { ws } = await openClient();
await connectOk(ws);
const preview = await rpcReq<{ const preview = await rpcReq<{
previews: Array<{ previews: Array<{
key: string; key: string;
@@ -383,7 +406,6 @@ describe("gateway server sessions", () => {
expect(entry?.items[1]?.text).toContain("call weather"); expect(entry?.items[1]?.text).toContain("call weather");
ws.close(); ws.close();
await server.close();
}); });
test("sessions.delete rejects main and aborts active runs", async () => { test("sessions.delete rejects main and aborts active runs", async () => {
@@ -415,8 +437,7 @@ describe("gateway server sessions", () => {
embeddedRunMock.activeIds.add("sess-active"); embeddedRunMock.activeIds.add("sess-active");
embeddedRunMock.waitResults.set("sess-active", true); embeddedRunMock.waitResults.set("sess-active", true);
const { server, ws } = await startServerWithClient(); const { ws } = await openClient();
await connectOk(ws);
const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" }); const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" });
expect(mainDelete.ok).toBe(false); expect(mainDelete.ok).toBe(false);
@@ -439,6 +460,5 @@ describe("gateway server sessions", () => {
expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]); expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]);
ws.close(); ws.close();
await server.close();
}); });
}); });
+112 -71
View File
@@ -3,7 +3,7 @@ import { type AddressInfo, createServer } from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, expect, vi } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js"; import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
@@ -75,67 +75,79 @@ export async function writeSessionStore(params: {
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
} }
export function installGatewayTestHooks() { async function setupGatewayTestHome() {
beforeEach(async () => { previousHome = process.env.HOME;
// Some tests intentionally use fake timers; ensure they don't leak into gateway suites. previousUserProfile = process.env.USERPROFILE;
vi.useRealTimers(); previousStateDir = process.env.CLAWDBOT_STATE_DIR;
setLoggerOverride({ level: "silent", consoleLevel: "silent" }); previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH;
previousHome = process.env.HOME; previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER;
previousUserProfile = process.env.USERPROFILE; previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
previousStateDir = process.env.CLAWDBOT_STATE_DIR; previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST;
previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-"));
previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER; process.env.HOME = tempHome;
previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; process.env.USERPROFILE = tempHome;
previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST; process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-")); delete process.env.CLAWDBOT_CONFIG_PATH;
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);
afterEach(async () => { function applyGatewaySkipEnv() {
vi.useRealTimers(); process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1";
resetLogger(); 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) {
if (previousHome === undefined) delete process.env.HOME; if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome; else process.env.HOME = previousHome;
if (previousUserProfile === undefined) delete process.env.USERPROFILE; if (previousUserProfile === undefined) delete process.env.USERPROFILE;
@@ -151,16 +163,45 @@ export function installGatewayTestHooks() {
else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher; else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher;
if (previousSkipCanvasHost === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST; if (previousSkipCanvasHost === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST;
else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previousSkipCanvasHost; else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previousSkipCanvasHost;
if (tempHome) { }
await fs.rm(tempHome, { if (options.restoreEnv && tempHome) {
recursive: true, await fs.rm(tempHome, {
force: true, recursive: true,
maxRetries: 20, force: true,
retryDelay: 25, maxRetries: 20,
}); retryDelay: 25,
tempHome = undefined; });
} tempHome = undefined;
tempConfigRoot = 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 });
}); });
} }
+1
View File
@@ -230,6 +230,7 @@ export {
listSlackAccountIds, listSlackAccountIds,
resolveDefaultSlackAccountId, resolveDefaultSlackAccountId,
resolveSlackAccount, resolveSlackAccount,
resolveSlackReplyToMode,
type ResolvedSlackAccount, type ResolvedSlackAccount,
} from "../slack/accounts.js"; } from "../slack/accounts.js";
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
+10
View File
@@ -107,3 +107,13 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco
.map((accountId) => resolveSlackAccount({ cfg, accountId })) .map((accountId) => resolveSlackAccount({ cfg, accountId }))
.filter((account) => account.enabled); .filter((account) => account.enabled);
} }
export function resolveSlackReplyToMode(
account: ResolvedSlackAccount,
chatType?: string | null,
): "off" | "first" | "all" {
if (chatType === "direct" && account.dm?.replyToMode !== undefined) {
return account.dm.replyToMode;
}
return account.replyToMode ?? "off";
}
+68
View File
@@ -0,0 +1,68 @@
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,9 +186,29 @@ export async function readSlackMessages(
limit?: number; limit?: number;
before?: string; before?: string;
after?: string; after?: string;
threadId?: string;
} = {}, } = {},
): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> { ): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> {
const client = await getClient(opts); 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({ const result = await client.conversations.history({
channel: channelId, channel: channelId,
limit: opts.limit, limit: opts.limit,
+3 -1
View File
@@ -53,6 +53,7 @@ export type SlackThreadStarter = {
text: string; text: string;
userId?: string; userId?: string;
ts?: string; ts?: string;
files?: SlackFile[];
}; };
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>(); const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
@@ -71,7 +72,7 @@ export async function resolveSlackThreadStarter(params: {
ts: params.threadTs, ts: params.threadTs,
limit: 1, limit: 1,
inclusive: true, inclusive: true,
})) as { messages?: Array<{ text?: string; user?: string; ts?: string }> }; })) as { messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }> };
const message = response?.messages?.[0]; const message = response?.messages?.[0];
const text = (message?.text ?? "").trim(); const text = (message?.text ?? "").trim();
if (!message || !text) return null; if (!message || !text) return null;
@@ -79,6 +80,7 @@ export async function resolveSlackThreadStarter(params: {
text, text,
userId: message.user, userId: message.user,
ts: message.ts, ts: message.ts,
files: message.files,
}; };
THREAD_STARTER_CACHE.set(cacheKey, starter); THREAD_STARTER_CACHE.set(cacheKey, starter);
return starter; return starter;
+20 -3
View File
@@ -434,6 +434,7 @@ export async function prepareSlackMessage(params: {
let threadStarterBody: string | undefined; let threadStarterBody: string | undefined;
let threadLabel: string | undefined; let threadLabel: string | undefined;
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null;
if (isThreadReply && threadTs) { if (isThreadReply && threadTs) {
const starter = await resolveSlackThreadStarter({ const starter = await resolveSlackThreadStarter({
channelId: message.channel, channelId: message.channel,
@@ -453,11 +454,27 @@ export async function prepareSlackMessage(params: {
}); });
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80); const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`; threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
// If current message has no files but thread starter does, fetch starter's files
if (!media && starter.files && starter.files.length > 0) {
threadStarterMedia = await resolveSlackMedia({
files: starter.files,
token: ctx.botToken,
maxBytes: ctx.mediaMaxBytes,
});
if (threadStarterMedia) {
logVerbose(
`slack: hydrated thread starter file ${threadStarterMedia.placeholder} from root message`,
);
}
}
} else { } else {
threadLabel = `Slack thread ${roomLabel}`; threadLabel = `Slack thread ${roomLabel}`;
} }
} }
// Use thread starter media if current message has none
const effectiveMedia = media ?? threadStarterMedia;
const ctxPayload = finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
RawBody: rawBody, RawBody: rawBody,
@@ -483,9 +500,9 @@ export async function prepareSlackMessage(params: {
ThreadLabel: threadLabel, ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? effectiveWasMentioned : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
MediaPath: media?.path, MediaPath: effectiveMedia?.path,
MediaType: media?.contentType, MediaType: effectiveMedia?.contentType,
MediaUrl: media?.path, MediaUrl: effectiveMedia?.path,
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const, OriginatingChannel: "slack" as const,
OriginatingTo: slackTo, OriginatingTo: slackTo,
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
const emptyCfg = {} as ClawdbotConfig;
describe("buildSlackThreadingToolContext", () => {
it("uses top-level replyToMode by default", () => {
const cfg = {
channels: {
slack: { replyToMode: "first" },
},
} as ClawdbotConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "channel" },
});
expect(result.replyToMode).toBe("first");
});
it("uses dm.replyToMode for direct messages when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
},
} as ClawdbotConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "direct" },
});
expect(result.replyToMode).toBe("all");
});
it("uses top-level replyToMode for channels even when dm.replyToMode is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
},
} as ClawdbotConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "channel" },
});
expect(result.replyToMode).toBe("off");
});
it("falls back to top-level when dm.replyToMode is not set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
dm: { enabled: true },
},
},
} as ClawdbotConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "direct" },
});
expect(result.replyToMode).toBe("first");
});
it("uses all mode when ThreadLabel is present", () => {
const cfg = {
channels: {
slack: { replyToMode: "off" },
},
} as ClawdbotConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "channel", ThreadLabel: "some-thread" },
});
expect(result.replyToMode).toBe("all");
});
it("defaults to off when no replyToMode is configured", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: { ChatType: "direct" },
});
expect(result.replyToMode).toBe("off");
});
});
+6 -6
View File
@@ -3,7 +3,7 @@ import type {
ChannelThreadingToolContext, ChannelThreadingToolContext,
} from "../channels/plugins/types.js"; } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveSlackAccount } from "./accounts.js"; import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
export function buildSlackThreadingToolContext(params: { export function buildSlackThreadingToolContext(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
@@ -11,11 +11,11 @@ export function buildSlackThreadingToolContext(params: {
context: ChannelThreadingContext; context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean }; hasRepliedRef?: { value: boolean };
}): ChannelThreadingToolContext { }): ChannelThreadingToolContext {
const configuredReplyToMode = const account = resolveSlackAccount({
resolveSlackAccount({ cfg: params.cfg,
cfg: params.cfg, accountId: params.accountId,
accountId: params.accountId, });
}).replyToMode ?? "off"; const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType);
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode; const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId; const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
return { return {
+85 -9
View File
@@ -13,6 +13,7 @@ import {
detectBrowserOpenSupport, detectBrowserOpenSupport,
formatControlUiSshHint, formatControlUiSshHint,
openUrl, openUrl,
openUrlInBackground,
probeGatewayReachable, probeGatewayReachable,
waitForGatewayReachable, waitForGatewayReachable,
resolveControlUiLinks, resolveControlUiLinks,
@@ -282,6 +283,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
"Control UI", "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 (!opts.skipUi && gatewayProbe.ok) {
if (hasBootstrap) { if (hasBootstrap) {
await prompter.note( await prompter.note(
@@ -293,11 +299,27 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
].join("\n"), ].join("\n"),
"Start TUI (best option!)", "Start TUI (best option!)",
); );
const wantsTui = await prompter.confirm({ await prompter.note(
message: "Do you want to hatch your bot now?", [
initialValue: true, "Gateway token: shared auth for the Gateway + Control UI.",
}); "Stored in: ~/.clawdbot/clawdbot.json (gateway.auth.token) or CLAWDBOT_GATEWAY_TOKEN.",
if (wantsTui) { "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") {
await runTui({ await runTui({
url: links.wsUrl, url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined, token: settings.authMode === "token" ? settings.gatewayToken : undefined,
@@ -306,6 +328,52 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
deliver: false, deliver: false,
message: "Wake up, my friend!", 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 { } else {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
@@ -342,9 +410,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
); );
const shouldOpenControlUi = const shouldOpenControlUi =
!opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken); !opts.skipUi &&
let controlUiOpened = false; settings.authMode === "token" &&
let controlUiOpenHint: string | undefined; Boolean(settings.gatewayToken) &&
hatchChoice === null;
if (shouldOpenControlUi) { if (shouldOpenControlUi) {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) { if (browserSupport.ok) {
@@ -406,9 +475,16 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
"Web search (optional)", "Web search (optional)",
); );
await prompter.note(
'What now: https://clawd.bot/showcase ("What People Are Building").',
"What now",
);
await prompter.outro( await prompter.outro(
controlUiOpened controlUiOpened
? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot." ? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot."
: "Onboarding complete. Use the tokenized dashboard link above 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.",
); );
} }
+6 -7
View File
@@ -177,19 +177,19 @@ describe("runOnboardingWizard", () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}"); await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
const confirm: WizardPrompter["confirm"] = vi.fn(async (opts) => { const select: WizardPrompter["select"] = vi.fn(async (opts) => {
if (opts.message === "Do you want to hatch your bot now?") return true; if (opts.message === "How do you want to hatch your bot?") return "tui";
return opts.initialValue ?? false; return "quickstart";
}); });
const prompter: WizardPrompter = { const prompter: WizardPrompter = {
intro: vi.fn(async () => {}), intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}), outro: vi.fn(async () => {}),
note: vi.fn(async () => {}), note: vi.fn(async () => {}),
select: vi.fn(async () => "quickstart"), select,
multiselect: vi.fn(async () => []), multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""), text: vi.fn(async () => ""),
confirm, confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
}; };
@@ -267,8 +267,7 @@ describe("runOnboardingWizard", () => {
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
expect(calls.length).toBeGreaterThan(0); expect(calls.length).toBeGreaterThan(0);
const lastCall = calls[calls.length - 1]; expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true);
expect(lastCall?.[1]).toBe("Web search (optional)");
} finally { } finally {
if (prevBraveKey === undefined) { if (prevBraveKey === undefined) {
delete process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY;