Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b24bf7ac1 | ||
|
|
a61c8a2317 | ||
|
|
c717258a8f | ||
|
|
7b40d1b261 | ||
|
|
2c10c601a8 | ||
|
|
578ac9f1a9 | ||
|
|
2accb47e4d | ||
|
|
b65916e0d1 | ||
|
|
9207840db4 | ||
|
|
784468d6c3 | ||
|
|
02b5f403db | ||
|
|
5d0d9e6323 | ||
|
|
64be2b2cd1 | ||
|
|
dd2400fb2a | ||
|
|
5d001cb953 |
No files matched your search
+5
-1
@@ -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
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477) 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 /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
@@ -32,12 +33,15 @@ Docs: https://docs.clawd.bot
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||
- 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).
|
||||
|
||||
|
||||
+20
-1
@@ -304,7 +304,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
||||
"policy": "pairing",
|
||||
"allowFrom": ["U123", "U456", "*"],
|
||||
"groupEnabled": false,
|
||||
"groupChannels": ["G123"]
|
||||
"groupChannels": ["G123"],
|
||||
"replyToMode": "all"
|
||||
},
|
||||
"channels": {
|
||||
"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`).
|
||||
|
||||
### 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
|
||||
For fine-grained control, use these tags in agent responses:
|
||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("./credentials.js");
|
||||
} = await import("../credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -16,22 +16,100 @@ export function normalizeThreadId(raw?: string | number | null): string | 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();
|
||||
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 {
|
||||
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||
if (list.length > 0) return list[0];
|
||||
const directContent = (await client.getAccountData(
|
||||
EventType.Direct,
|
||||
)) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed])
|
||||
? directContent[trimmed]
|
||||
: [];
|
||||
if (list.length > 0) {
|
||||
directRoomCache.set(trimmed, list[0]);
|
||||
return list[0];
|
||||
}
|
||||
} 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(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readStringParam,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
resolveSlackGroupRequireMention,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -162,8 +163,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ TRASH
|
||||
local needle="$1"
|
||||
local timeout_s="${2:-45}"
|
||||
local needle_compact
|
||||
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
|
||||
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")"
|
||||
local start_s
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
@@ -61,17 +61,12 @@ TRASH
|
||||
let text = \"\";
|
||||
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||
if (text.length > 20000) text = text.slice(-20000);
|
||||
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
|
||||
const haystack = sanitize(text);
|
||||
const safeNeedle = sanitize(needle);
|
||||
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
|
||||
let escaped = \"\";
|
||||
for (const ch of safeNeedle) {
|
||||
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
|
||||
}
|
||||
const pattern = escaped.split(\"\").join(\".*\");
|
||||
const re = new RegExp(pattern, \"i\");
|
||||
process.exit(re.test(haystack) ? 0 : 1);
|
||||
const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\");
|
||||
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\");
|
||||
const haystack = compact(text);
|
||||
const compactNeedle = compact(needle);
|
||||
if (!compactNeedle) process.exit(1);
|
||||
process.exit(haystack.includes(compactNeedle) ? 0 : 1);
|
||||
"; then
|
||||
return 0
|
||||
fi
|
||||
@@ -260,9 +255,11 @@ TRASH
|
||||
|
||||
send_skills_flow() {
|
||||
# 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
|
||||
send $'"'"'n\r'"'"' 1.5
|
||||
wait_for_log "Configure skills now?" 60 || true
|
||||
send $'"'"'n\r'"'"' 0.8
|
||||
send "" 1.0
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,11 @@ if (!shouldBuild()) {
|
||||
runNode();
|
||||
} else {
|
||||
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,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -237,6 +237,20 @@ describe("buildAgentSystemPrompt", () => {
|
||||
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", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
|
||||
@@ -517,12 +517,18 @@ export function buildAgentSystemPrompt(params: {
|
||||
|
||||
const contextFiles = params.contextFiles ?? [];
|
||||
if (contextFiles.length > 0) {
|
||||
lines.push(
|
||||
"# Project Context",
|
||||
"",
|
||||
"The following project context files have been loaded:",
|
||||
"",
|
||||
);
|
||||
const hasSoulFile = contextFiles.some((file) => {
|
||||
const normalizedPath = file.path.trim().replace(/\\/g, "/");
|
||||
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
|
||||
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) {
|
||||
lines.push(`## ${file.path}`, "", file.content, "");
|
||||
}
|
||||
|
||||
@@ -357,6 +357,20 @@ describe("handleSlackAction", () => {
|
||||
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("passes threadId through to readSlackMessages", async () => {
|
||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
readSlackMessages.mockClear();
|
||||
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
||||
|
||||
await handleSlackAction(
|
||||
{ action: "readMessages", channelId: "C1", threadId: "12345.6789" },
|
||||
cfg,
|
||||
);
|
||||
|
||||
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
|
||||
expect(opts?.threadId).toBe("12345.6789");
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to pin payloads", async () => {
|
||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
listSlackPins.mockResolvedValueOnce([
|
||||
|
||||
@@ -214,11 +214,13 @@ export async function handleSlackAction(
|
||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||
const before = readStringParam(params, "before");
|
||||
const after = readStringParam(params, "after");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const result = await readSlackMessages(channelId, {
|
||||
...readOpts,
|
||||
limit,
|
||||
before: before ?? undefined,
|
||||
after: after ?? undefined,
|
||||
threadId: threadId ?? undefined,
|
||||
});
|
||||
const messages = result.messages.map((message) =>
|
||||
withNormalizedTimestamp(
|
||||
|
||||
@@ -136,6 +136,7 @@ export async function runReplyAgent(params: {
|
||||
followupRun.run.config,
|
||||
replyToChannel,
|
||||
sessionCtx.AccountId,
|
||||
sessionCtx.ChatType,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||
const cfg = followupRun.run.config;
|
||||
|
||||
@@ -204,6 +204,7 @@ export function createFollowupRunner(params: {
|
||||
queued.run.config,
|
||||
replyToChannel,
|
||||
queued.originatingAccountId,
|
||||
queued.originatingChatType,
|
||||
);
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||
|
||||
@@ -358,6 +358,7 @@ export async function runPreparedReply(
|
||||
originatingTo: ctx.OriginatingTo,
|
||||
originatingAccountId: ctx.AccountId,
|
||||
originatingThreadId: ctx.MessageThreadId,
|
||||
originatingChatType: ctx.ChatType,
|
||||
run: {
|
||||
agentId,
|
||||
agentDir,
|
||||
|
||||
@@ -39,6 +39,8 @@ export type FollowupRun = {
|
||||
originatingAccountId?: string;
|
||||
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
||||
originatingThreadId?: string | number;
|
||||
/** Chat type for context-aware threading (e.g., DM vs channel). */
|
||||
originatingChatType?: string;
|
||||
run: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
|
||||
@@ -31,6 +31,33 @@ describe("resolveReplyToMode", () => {
|
||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||
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", () => {
|
||||
|
||||
@@ -9,12 +9,14 @@ export function resolveReplyToMode(
|
||||
cfg: ClawdbotConfig,
|
||||
channel?: OriginatingChannelType,
|
||||
accountId?: string | null,
|
||||
chatType?: string | null,
|
||||
): ReplyToMode {
|
||||
const provider = normalizeChannelId(channel);
|
||||
if (!provider) return "all";
|
||||
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId,
|
||||
chatType,
|
||||
});
|
||||
return resolved ?? "all";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { resolveIMessageAccount } from "../imessage/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 { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
allowTagsWhenOff: true,
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
threadId: readStringParam(params, "threadId"),
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
|
||||
@@ -198,6 +198,7 @@ export type ChannelThreadingAdapter = {
|
||||
resolveReplyToMode?: (params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
chatType?: string | null;
|
||||
}) => "off" | "first" | "all";
|
||||
allowTagsWhenOff?: boolean;
|
||||
buildToolContext?: (params: {
|
||||
|
||||
+6
-2
@@ -94,9 +94,13 @@ export function buildParseArgv(params: {
|
||||
: baseArgv[0]?.endsWith("clawdbot")
|
||||
? baseArgv.slice(1)
|
||||
: baseArgv;
|
||||
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
|
||||
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
|
||||
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;
|
||||
return ["node", programName || "clawdbot", ...normalizedArgv];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
workspaceDir: string,
|
||||
runtime: RuntimeEnv,
|
||||
|
||||
@@ -17,6 +17,8 @@ export type SlackDmConfig = {
|
||||
groupEnabled?: boolean;
|
||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||
groupChannels?: Array<string | number>;
|
||||
/** Control reply threading for DMs (off|first|all). Overrides top-level replyToMode for DMs. */
|
||||
replyToMode?: ReplyToMode;
|
||||
};
|
||||
|
||||
export type SlackChannelConfig = {
|
||||
|
||||
+333
-430
@@ -49,469 +49,372 @@ function parseSseDataLines(text: string): string[] {
|
||||
}
|
||||
|
||||
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
it("is disabled by default (requires config)", { timeout: 120_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServerWithDefaultConfig(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
|
||||
{
|
||||
const port = await getFreePort();
|
||||
const server = await startServerWithDefaultConfig(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port, {
|
||||
openAiChatCompletionsEnabled: false,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
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("can be disabled via config (404)", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port, {
|
||||
openAiChatCompletionsEnabled: false,
|
||||
});
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-POST", async () => {
|
||||
it("handles request validation and routing", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "GET",
|
||||
headers: { authorization: "Bearer secret" },
|
||||
});
|
||||
expect(res.status).toBe(405);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
{
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "GET",
|
||||
headers: { authorization: "Bearer secret" },
|
||||
});
|
||||
expect(res.status).toBe(405);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
it("rejects missing auth", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
{
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
it("routes to a specific agent via header", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
||||
{ "x-clawdbot-agent-id": "beta" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
||||
{ "x-clawdbot-agent-id": "beta" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||
/^agent:beta:/,
|
||||
);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||
/^agent:beta:/,
|
||||
);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("routes to a specific agent via model (no custom headers)", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot:beta",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||
/^agent:beta:/,
|
||||
);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers explicit header agent over model agent", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot:beta",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
{ "x-clawdbot-agent-id": "alpha" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||
/^agent:alpha:/,
|
||||
);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||
/^agent:beta:/,
|
||||
);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
it("honors x-clawdbot-session-key override", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
||||
{
|
||||
"x-clawdbot-agent-id": "beta",
|
||||
"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: [
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "input_text", text: "world" },
|
||||
],
|
||||
model: "clawdbot:beta",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
{ "x-clawdbot-agent-id": "alpha" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||
/^agent:alpha:/,
|
||||
);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
||||
{
|
||||
"x-clawdbot-agent-id": "beta",
|
||||
"x-clawdbot-session-key": "agent:beta:openai:custom",
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
|
||||
"agent:beta:openai:custom",
|
||||
);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
user: "alice",
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
||||
"openai-user:alice",
|
||||
);
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "input_text", text: "world" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "I am Claude" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello, who are you?" },
|
||||
{ role: "assistant", content: "I am Claude." },
|
||||
{ role: "user", content: "What did I just ask you?" },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(message).toContain("User: Hello, who are you?");
|
||||
expect(message).toContain("Assistant: I am Claude.");
|
||||
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
||||
expect(message).toContain("User: What did I just ask you?");
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
|
||||
expect(message).toBe("Hello");
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "developer", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const extraSystemPrompt =
|
||||
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "ok" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "What's the weather?" },
|
||||
{ role: "assistant", content: "Checking the weather." },
|
||||
{ role: "tool", content: "Sunny, 70F." },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(message).toContain("User: What's the weather?");
|
||||
expect(message).toContain("Assistant: Checking the weather.");
|
||||
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
||||
expect(message).toContain("Tool: Sunny, 70F.");
|
||||
await res.text();
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: false,
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
expect(json.object).toBe("chat.completion");
|
||||
expect(Array.isArray(json.choices)).toBe(true);
|
||||
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
|
||||
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
||||
expect(msg.role).toBe("assistant");
|
||||
expect(msg.content).toBe("hello");
|
||||
}
|
||||
|
||||
{
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "system", content: "yo" }],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const missingUserJson = (await res.json()) as Record<string, unknown>;
|
||||
expect((missingUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
|
||||
"invalid_request_error",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes conversation history when multiple messages are provided", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "I am Claude" }],
|
||||
} as never);
|
||||
|
||||
it("streams SSE chunks when stream=true", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello, who are you?" },
|
||||
{ role: "assistant", content: "I am Claude." },
|
||||
{ role: "user", content: "What did I just ask you?" },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
{
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
|
||||
return { payloads: [{ text: "hello" }] } as never;
|
||||
});
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(message).toContain("User: Hello, who are you?");
|
||||
expect(message).toContain("Assistant: I am Claude.");
|
||||
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
||||
expect(message).toContain("User: What did I just ask you?");
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
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");
|
||||
|
||||
it("does not include history markers for single message", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
const text = await res.text();
|
||||
const data = parseSseDataLines(text);
|
||||
expect(data[data.length - 1]).toBe("[DONE]");
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const 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");
|
||||
}
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
|
||||
expect(message).toBe("Hello");
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
{
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
||||
return { payloads: [{ text: "hihi" }] } as never;
|
||||
});
|
||||
|
||||
it("treats developer role same as system role", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
const repeatedRes = await postChatCompletions(port, {
|
||||
stream: true,
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(repeatedRes.status).toBe(200);
|
||||
const repeatedText = await repeatedRes.text();
|
||||
const repeatedData = parseSseDataLines(repeatedText);
|
||||
const repeatedChunks = repeatedData
|
||||
.filter((d) => d !== "[DONE]")
|
||||
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
||||
const repeatedContent = repeatedChunks
|
||||
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
||||
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.join("");
|
||||
expect(repeatedContent).toBe("hihi");
|
||||
}
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "developer", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
{
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const extraSystemPrompt =
|
||||
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes tool output when it is the latest message", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
} as never);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "What's the weather?" },
|
||||
{ role: "assistant", content: "Checking the weather." },
|
||||
{ role: "tool", content: "Sunny, 70F." },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
||||
expect(message).toContain("User: What's the weather?");
|
||||
expect(message).toContain("Assistant: Checking the weather.");
|
||||
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
||||
expect(message).toContain("Tool: Sunny, 70F.");
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a non-streaming OpenAI chat.completion response", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: false,
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
expect(json.object).toBe("chat.completion");
|
||||
expect(Array.isArray(json.choices)).toBe(true);
|
||||
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
|
||||
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
||||
expect(msg.role).toBe("assistant");
|
||||
expect(msg.content).toBe("hello");
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a user message", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "system", content: "yo" }],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
|
||||
"invalid_request_error",
|
||||
);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("streams SSE chunks when stream=true (delta events)", async () => {
|
||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
|
||||
return { payloads: [{ text: "hello" }] } as never;
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: true,
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
|
||||
const text = await res.text();
|
||||
const data = parseSseDataLines(text);
|
||||
expect(data[data.length - 1]).toBe("[DONE]");
|
||||
|
||||
const jsonChunks = data
|
||||
.filter((d) => d !== "[DONE]")
|
||||
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
||||
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
|
||||
const allContent = jsonChunks
|
||||
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
||||
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.join("");
|
||||
expect(allContent).toBe("hello");
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves repeated identical deltas when streaming SSE", async () => {
|
||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
||||
return { payloads: [{ text: "hihi" }] } as never;
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: true,
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
const data = parseSseDataLines(text);
|
||||
const jsonChunks = data
|
||||
.filter((d) => d !== "[DONE]")
|
||||
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
||||
const allContent = jsonChunks
|
||||
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
||||
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.join("");
|
||||
expect(allContent).toBe("hihi");
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("streams SSE chunks when stream=true (fallback when no deltas)", async () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
} as never);
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: true,
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
expect(text).toContain("[DONE]");
|
||||
expect(text).toContain("hello");
|
||||
const fallbackRes = await postChatCompletions(port, {
|
||||
stream: true,
|
||||
model: "clawdbot",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(fallbackRes.status).toBe(200);
|
||||
const fallbackText = await fallbackRes.text();
|
||||
expect(fallbackText).toContain("[DONE]");
|
||||
expect(fallbackText).toContain("hello");
|
||||
}
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
|
||||
@@ -1,95 +1,106 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
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 {
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
startServerWithClient,
|
||||
startGatewayServer,
|
||||
} 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 () => {
|
||||
for (const { server, ws } of servers) {
|
||||
try {
|
||||
ws.close();
|
||||
await server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
servers.length = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
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 () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
await connectOk(ws);
|
||||
return ws;
|
||||
};
|
||||
|
||||
describe("gateway config.apply", () => {
|
||||
it("writes config, stores sentinel, and schedules restart", async () => {
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
const id = "req-1";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "config.apply",
|
||||
params: {
|
||||
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
restartDelayMs: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
// Verify sentinel file was created (restart was scheduled)
|
||||
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
|
||||
|
||||
// Wait for file to be written
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const ws = await openClient();
|
||||
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
|
||||
const id = "req-1";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "config.apply",
|
||||
params: {
|
||||
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
restartDelayMs: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
// Verify sentinel file was created (restart was scheduled)
|
||||
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
|
||||
|
||||
// Wait for file to be written
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(sentinelPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
|
||||
expect(parsed.payload?.kind).toBe("config-apply");
|
||||
} catch {
|
||||
// File may not exist if signal delivery is mocked, verify response was ok instead
|
||||
expect(res.ok).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid raw config", async () => {
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
const id = "req-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "config.apply",
|
||||
params: {
|
||||
raw: "{",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
const ws = await openClient();
|
||||
try {
|
||||
const id = "req-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "config.apply",
|
||||
params: {
|
||||
raw: "{",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
waitForSystemEvent,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
async function yieldToEventLoop() {
|
||||
// Avoid relying on timers (fake timers can leak between tests).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import {
|
||||
@@ -21,12 +21,35 @@ import {
|
||||
} from "./test-helpers.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", () => {
|
||||
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const ws = await openClient();
|
||||
|
||||
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
|
||||
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);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
|
||||
@@ -76,8 +98,7 @@ describe("gateway server health/presence", () => {
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const ws = await openClient();
|
||||
|
||||
const waitHeartbeat = onceMessage<EventFrame>(
|
||||
ws,
|
||||
@@ -117,12 +138,10 @@ describe("gateway server health/presence", () => {
|
||||
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const ws = await openClient();
|
||||
|
||||
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
|
||||
ws.send(
|
||||
@@ -140,12 +159,10 @@ describe("gateway server health/presence", () => {
|
||||
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const ws = await openClient();
|
||||
|
||||
const runId = randomUUID();
|
||||
const evtPromise = onceMessage(
|
||||
@@ -163,7 +180,6 @@ describe("gateway server health/presence", () => {
|
||||
expect(evt.payload.data.msg).toBe("hi");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
||||
@@ -177,16 +193,7 @@ describe("gateway server health/presence", () => {
|
||||
});
|
||||
|
||||
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const mkClient = async () => {
|
||||
const c = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => c.once("open", resolve));
|
||||
await connectOk(c);
|
||||
return c;
|
||||
};
|
||||
|
||||
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
|
||||
const clients = await Promise.all([openClient(), openClient(), openClient()]);
|
||||
const waits = clients.map((c) =>
|
||||
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
|
||||
);
|
||||
@@ -204,7 +211,6 @@ describe("gateway server health/presence", () => {
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
}
|
||||
for (const c of clients) c.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("presence includes client fingerprint", async () => {
|
||||
@@ -222,8 +228,7 @@ describe("gateway server health/presence", () => {
|
||||
signedAtMs,
|
||||
token: null,
|
||||
});
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
const ws = await openClient({
|
||||
role,
|
||||
scopes,
|
||||
client: {
|
||||
@@ -264,13 +269,11 @@ describe("gateway server health/presence", () => {
|
||||
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("cli connections are not tracked as instances", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const cliId = `cli-${randomUUID()}`;
|
||||
await connectOk(ws, {
|
||||
const ws = await openClient({
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CLI,
|
||||
version: "dev",
|
||||
@@ -294,6 +297,5 @@ describe("gateway server health/presence", () => {
|
||||
expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
import { test } from "vitest";
|
||||
import { afterAll, beforeAll, test } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js";
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function connectReq(
|
||||
ws: WebSocket,
|
||||
params: { clientId: string; platform: string; token?: string; password?: string },
|
||||
@@ -43,8 +55,6 @@ function connectReq(
|
||||
}
|
||||
|
||||
test("accepts clawdbot-ios as a valid gateway client id", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
@@ -61,12 +71,9 @@ test("accepts clawdbot-ios as a valid gateway client id", async () => {
|
||||
}
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("accepts clawdbot-android as a valid gateway client id", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
@@ -83,5 +90,4 @@ test("accepts clawdbot-android as a valid gateway client id", async () => {
|
||||
}
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
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 {
|
||||
connectOk,
|
||||
embeddedRunMock,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
piSdkMock,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
startGatewayServer,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} 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", () => {
|
||||
beforeEach(() => {
|
||||
@@ -98,8 +124,7 @@ describe("gateway server sessions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const hello = await connectOk(ws);
|
||||
const { ws, hello } = await openClient();
|
||||
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
|
||||
expect.arrayContaining([
|
||||
"sessions.list",
|
||||
@@ -336,7 +361,6 @@ describe("gateway server sessions", () => {
|
||||
);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("sessions.preview returns transcript previews", async () => {
|
||||
@@ -365,8 +389,7 @@ describe("gateway server sessions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const { ws } = await openClient();
|
||||
const preview = await rpcReq<{
|
||||
previews: Array<{
|
||||
key: string;
|
||||
@@ -383,7 +406,6 @@ describe("gateway server sessions", () => {
|
||||
expect(entry?.items[1]?.text).toContain("call weather");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("sessions.delete rejects main and aborts active runs", async () => {
|
||||
@@ -415,8 +437,7 @@ describe("gateway server sessions", () => {
|
||||
embeddedRunMock.activeIds.add("sess-active");
|
||||
embeddedRunMock.waitResults.set("sess-active", true);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const { ws } = await openClient();
|
||||
|
||||
const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" });
|
||||
expect(mainDelete.ok).toBe(false);
|
||||
@@ -439,6 +460,5 @@ describe("gateway server sessions", () => {
|
||||
expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { type AddressInfo, createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, expect, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
export function installGatewayTestHooks() {
|
||||
beforeEach(async () => {
|
||||
// Some tests intentionally use fake timers; ensure they don't leak into gateway suites.
|
||||
vi.useRealTimers();
|
||||
setLoggerOverride({ level: "silent", consoleLevel: "silent" });
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH;
|
||||
previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER;
|
||||
previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
|
||||
previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
|
||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||
tempConfigRoot = path.join(tempHome, ".clawdbot-test");
|
||||
setTestConfigRoot(tempConfigRoot);
|
||||
sessionStoreSaveDelayMs.value = 0;
|
||||
testTailnetIPv4.value = undefined;
|
||||
testState.gatewayBind = undefined;
|
||||
testState.gatewayAuth = undefined;
|
||||
testState.gatewayControlUi = undefined;
|
||||
testState.hooksConfig = undefined;
|
||||
testState.canvasHostPort = undefined;
|
||||
testState.legacyIssues = [];
|
||||
testState.legacyParsed = {};
|
||||
testState.migrationConfig = null;
|
||||
testState.migrationChanges = [];
|
||||
testState.cronEnabled = false;
|
||||
testState.cronStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.agentConfig = undefined;
|
||||
testState.agentsConfig = undefined;
|
||||
testState.bindingsConfig = undefined;
|
||||
testState.channelsConfig = undefined;
|
||||
testState.allowFrom = undefined;
|
||||
testIsNixMode.value = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
agentCommand.mockClear();
|
||||
embeddedRunMock.activeIds.clear();
|
||||
embeddedRunMock.abortCalls = [];
|
||||
embeddedRunMock.waitCalls = [];
|
||||
embeddedRunMock.waitResults.clear();
|
||||
drainSystemEvents(resolveMainSessionKeyFromConfig());
|
||||
resetAgentRunContextForTest();
|
||||
const mod = await serverModulePromise;
|
||||
mod.__resetModelCatalogCacheForTest();
|
||||
piSdkMock.enabled = false;
|
||||
piSdkMock.discoverCalls = 0;
|
||||
piSdkMock.models = [];
|
||||
}, 60_000);
|
||||
async function setupGatewayTestHome() {
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH;
|
||||
previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER;
|
||||
previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
|
||||
previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
|
||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
resetLogger();
|
||||
function applyGatewaySkipEnv() {
|
||||
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||
}
|
||||
|
||||
async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
||||
// Some tests intentionally use fake timers; ensure they don't leak into gateway suites.
|
||||
vi.useRealTimers();
|
||||
setLoggerOverride({ level: "silent", consoleLevel: "silent" });
|
||||
if (!tempHome) {
|
||||
throw new Error("resetGatewayTestState called before temp home was initialized");
|
||||
}
|
||||
applyGatewaySkipEnv();
|
||||
tempConfigRoot = options.uniqueConfigRoot
|
||||
? await fs.mkdtemp(path.join(tempHome, "clawdbot-test-"))
|
||||
: path.join(tempHome, ".clawdbot-test");
|
||||
setTestConfigRoot(tempConfigRoot);
|
||||
sessionStoreSaveDelayMs.value = 0;
|
||||
testTailnetIPv4.value = undefined;
|
||||
testState.gatewayBind = undefined;
|
||||
testState.gatewayAuth = undefined;
|
||||
testState.gatewayControlUi = undefined;
|
||||
testState.hooksConfig = undefined;
|
||||
testState.canvasHostPort = undefined;
|
||||
testState.legacyIssues = [];
|
||||
testState.legacyParsed = {};
|
||||
testState.migrationConfig = null;
|
||||
testState.migrationChanges = [];
|
||||
testState.cronEnabled = false;
|
||||
testState.cronStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.agentConfig = undefined;
|
||||
testState.agentsConfig = undefined;
|
||||
testState.bindingsConfig = undefined;
|
||||
testState.channelsConfig = undefined;
|
||||
testState.allowFrom = undefined;
|
||||
testIsNixMode.value = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
agentCommand.mockClear();
|
||||
embeddedRunMock.activeIds.clear();
|
||||
embeddedRunMock.abortCalls = [];
|
||||
embeddedRunMock.waitCalls = [];
|
||||
embeddedRunMock.waitResults.clear();
|
||||
drainSystemEvents(resolveMainSessionKeyFromConfig());
|
||||
resetAgentRunContextForTest();
|
||||
const mod = await serverModulePromise;
|
||||
mod.__resetModelCatalogCacheForTest();
|
||||
piSdkMock.enabled = false;
|
||||
piSdkMock.discoverCalls = 0;
|
||||
piSdkMock.models = [];
|
||||
}
|
||||
|
||||
async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
|
||||
vi.useRealTimers();
|
||||
resetLogger();
|
||||
if (options.restoreEnv) {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
@@ -151,16 +163,45 @@ export function installGatewayTestHooks() {
|
||||
else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher;
|
||||
if (previousSkipCanvasHost === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST;
|
||||
else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previousSkipCanvasHost;
|
||||
if (tempHome) {
|
||||
await fs.rm(tempHome, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
retryDelay: 25,
|
||||
});
|
||||
tempHome = undefined;
|
||||
}
|
||||
tempConfigRoot = undefined;
|
||||
}
|
||||
if (options.restoreEnv && tempHome) {
|
||||
await fs.rm(tempHome, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
retryDelay: 25,
|
||||
});
|
||||
tempHome = undefined;
|
||||
}
|
||||
tempConfigRoot = undefined;
|
||||
}
|
||||
|
||||
export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) {
|
||||
const scope = options?.scope ?? "test";
|
||||
if (scope === "suite") {
|
||||
beforeAll(async () => {
|
||||
await setupGatewayTestHome();
|
||||
await resetGatewayTestState({ uniqueConfigRoot: true });
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await resetGatewayTestState({ uniqueConfigRoot: true });
|
||||
}, 60_000);
|
||||
afterEach(async () => {
|
||||
await cleanupGatewayTestHome({ restoreEnv: false });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await cleanupGatewayTestHome({ restoreEnv: true });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupGatewayTestHome();
|
||||
await resetGatewayTestState({ uniqueConfigRoot: false });
|
||||
}, 60_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupGatewayTestHome({ restoreEnv: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ export {
|
||||
listSlackAccountIds,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
type ResolvedSlackAccount,
|
||||
} from "../slack/accounts.js";
|
||||
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
|
||||
|
||||
@@ -107,3 +107,13 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco
|
||||
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
||||
.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";
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -186,9 +186,29 @@ export async function readSlackMessages(
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
threadId?: string;
|
||||
} = {},
|
||||
): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> {
|
||||
const client = await getClient(opts);
|
||||
|
||||
// Use conversations.replies for thread messages, conversations.history for channel messages.
|
||||
if (opts.threadId) {
|
||||
const result = await client.conversations.replies({
|
||||
channel: channelId,
|
||||
ts: opts.threadId,
|
||||
limit: opts.limit,
|
||||
latest: opts.before,
|
||||
oldest: opts.after,
|
||||
});
|
||||
return {
|
||||
// conversations.replies includes the parent message; drop it for replies-only reads.
|
||||
messages: (result.messages ?? []).filter(
|
||||
(message) => (message as SlackMessageSummary)?.ts !== opts.threadId,
|
||||
) as SlackMessageSummary[],
|
||||
hasMore: Boolean(result.has_more),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await client.conversations.history({
|
||||
channel: channelId,
|
||||
limit: opts.limit,
|
||||
|
||||
@@ -53,6 +53,7 @@ export type SlackThreadStarter = {
|
||||
text: string;
|
||||
userId?: string;
|
||||
ts?: string;
|
||||
files?: SlackFile[];
|
||||
};
|
||||
|
||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
||||
@@ -71,7 +72,7 @@ export async function resolveSlackThreadStarter(params: {
|
||||
ts: params.threadTs,
|
||||
limit: 1,
|
||||
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 text = (message?.text ?? "").trim();
|
||||
if (!message || !text) return null;
|
||||
@@ -79,6 +80,7 @@ export async function resolveSlackThreadStarter(params: {
|
||||
text,
|
||||
userId: message.user,
|
||||
ts: message.ts,
|
||||
files: message.files,
|
||||
};
|
||||
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
||||
return starter;
|
||||
|
||||
@@ -434,6 +434,7 @@ export async function prepareSlackMessage(params: {
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null;
|
||||
if (isThreadReply && threadTs) {
|
||||
const starter = await resolveSlackThreadStarter({
|
||||
channelId: message.channel,
|
||||
@@ -453,11 +454,27 @@ export async function prepareSlackMessage(params: {
|
||||
});
|
||||
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||
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 {
|
||||
threadLabel = `Slack thread ${roomLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use thread starter media if current message has none
|
||||
const effectiveMedia = media ?? threadStarterMedia;
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: rawBody,
|
||||
@@ -483,9 +500,9 @@ export async function prepareSlackMessage(params: {
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
MediaPath: effectiveMedia?.path,
|
||||
MediaType: effectiveMedia?.contentType,
|
||||
MediaUrl: effectiveMedia?.path,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "slack" as const,
|
||||
OriginatingTo: slackTo,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ChannelThreadingToolContext,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
|
||||
|
||||
export function buildSlackThreadingToolContext(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -11,11 +11,11 @@ export function buildSlackThreadingToolContext(params: {
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef?: { value: boolean };
|
||||
}): ChannelThreadingToolContext {
|
||||
const configuredReplyToMode =
|
||||
resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).replyToMode ?? "off";
|
||||
const account = resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType);
|
||||
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
|
||||
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
detectBrowserOpenSupport,
|
||||
formatControlUiSshHint,
|
||||
openUrl,
|
||||
openUrlInBackground,
|
||||
probeGatewayReachable,
|
||||
waitForGatewayReachable,
|
||||
resolveControlUiLinks,
|
||||
@@ -282,6 +283,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
let controlUiOpened = false;
|
||||
let controlUiOpenHint: string | undefined;
|
||||
let seededInBackground = false;
|
||||
let hatchChoice: "tui" | "web" | "later" | null = null;
|
||||
|
||||
if (!opts.skipUi && gatewayProbe.ok) {
|
||||
if (hasBootstrap) {
|
||||
await prompter.note(
|
||||
@@ -293,11 +299,27 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
].join("\n"),
|
||||
"Start TUI (best option!)",
|
||||
);
|
||||
const wantsTui = await prompter.confirm({
|
||||
message: "Do you want to hatch your bot now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (wantsTui) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Gateway token: shared auth for the Gateway + Control UI.",
|
||||
"Stored in: ~/.clawdbot/clawdbot.json (gateway.auth.token) or CLAWDBOT_GATEWAY_TOKEN.",
|
||||
"Web UI stores a copy in this browser's localStorage (clawdbot.control.settings.v1).",
|
||||
`Get the tokenized link anytime: ${formatCliCommand("clawdbot dashboard --no-open")}`,
|
||||
].join("\n"),
|
||||
"Token",
|
||||
);
|
||||
|
||||
hatchChoice = (await prompter.select({
|
||||
message: "How do you want to hatch your bot?",
|
||||
options: [
|
||||
{ value: "tui", label: "Hatch in TUI (recommended)" },
|
||||
{ value: "web", label: "Open the Web UI" },
|
||||
{ value: "later", label: "Do this later" },
|
||||
],
|
||||
initialValue: "tui",
|
||||
})) as "tui" | "web" | "later";
|
||||
|
||||
if (hatchChoice === "tui") {
|
||||
await runTui({
|
||||
url: links.wsUrl,
|
||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||
@@ -306,6 +328,52 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
deliver: false,
|
||||
message: "Wake up, my friend!",
|
||||
});
|
||||
if (settings.authMode === "token" && settings.gatewayToken) {
|
||||
seededInBackground = await openUrlInBackground(authedUrl);
|
||||
}
|
||||
if (seededInBackground) {
|
||||
await prompter.note(
|
||||
`Web UI seeded in the background. Open later with: ${formatCliCommand(
|
||||
"clawdbot dashboard --no-open",
|
||||
)}`,
|
||||
"Web UI",
|
||||
);
|
||||
}
|
||||
} else if (hatchChoice === "web") {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
controlUiOpened = await openUrl(authedUrl);
|
||||
if (!controlUiOpened) {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
await prompter.note(
|
||||
[
|
||||
`Dashboard link (with token): ${authedUrl}`,
|
||||
controlUiOpened
|
||||
? "Opened in your browser. Keep that tab to control Clawdbot."
|
||||
: "Copy/paste this URL in a browser on this machine to control Clawdbot.",
|
||||
controlUiOpenHint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Dashboard ready",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
`When you're ready: ${formatCliCommand("clawdbot dashboard --no-open")}`,
|
||||
"Later",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
@@ -342,9 +410,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
);
|
||||
|
||||
const shouldOpenControlUi =
|
||||
!opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken);
|
||||
let controlUiOpened = false;
|
||||
let controlUiOpenHint: string | undefined;
|
||||
!opts.skipUi &&
|
||||
settings.authMode === "token" &&
|
||||
Boolean(settings.gatewayToken) &&
|
||||
hatchChoice === null;
|
||||
if (shouldOpenControlUi) {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
@@ -406,9 +475,16 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
"Web search (optional)",
|
||||
);
|
||||
|
||||
await prompter.note(
|
||||
'What now: https://clawd.bot/showcase ("What People Are Building").',
|
||||
"What now",
|
||||
);
|
||||
|
||||
await prompter.outro(
|
||||
controlUiOpened
|
||||
? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot."
|
||||
: "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.",
|
||||
);
|
||||
}
|
||||
@@ -177,19 +177,19 @@ describe("runOnboardingWizard", () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
|
||||
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
|
||||
|
||||
const confirm: WizardPrompter["confirm"] = vi.fn(async (opts) => {
|
||||
if (opts.message === "Do you want to hatch your bot now?") return true;
|
||||
return opts.initialValue ?? false;
|
||||
const select: WizardPrompter["select"] = vi.fn(async (opts) => {
|
||||
if (opts.message === "How do you want to hatch your bot?") return "tui";
|
||||
return "quickstart";
|
||||
});
|
||||
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select: vi.fn(async () => "quickstart"),
|
||||
select,
|
||||
multiselect: vi.fn(async () => []),
|
||||
text: vi.fn(async () => ""),
|
||||
confirm,
|
||||
confirm: vi.fn(async () => false),
|
||||
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;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall?.[1]).toBe("Web search (optional)");
|
||||
expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true);
|
||||
} finally {
|
||||
if (prevBraveKey === undefined) {
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
|
||||
Reference in new issue
Block a user