Compare commits

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

This follows the existing asVoice pattern used by Telegram.

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

No files matched your search

+1 -12
View File
@@ -6,17 +6,14 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster - Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell. - BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477) Thanks @Nicell.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405) - Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla. - UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli. - Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
@@ -30,26 +27,18 @@ Docs: https://docs.clawd.bot
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky. - Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. - Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Agents: surface concrete API error details instead of generic AI service errors. - Agents: surface concrete API error details instead of generic AI service errors.
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. - Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. - Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c. - Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
- Agents: make tool summaries more readable and only show optional params when set. - Agents: make tool summaries more readable and only show optional params when set.
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
- CLI: prefer `~` for home paths in output. - CLI: prefer `~` for home paths in output.
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. - Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact. - Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. - Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff. - Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. - Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment). - Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
## 2026.1.21-2 ## 2026.1.21-2
@@ -102,9 +102,7 @@ struct DebugSettings: View {
} }
} }
Text( Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
"It will only attach to an existing Gateway.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -248,11 +248,12 @@ final class GatewayProcessManager {
guard let linkId else { guard let linkId else {
return "port \(port), health probe succeeded, \(instanceText)" return "port \(port), health probe succeeded, \(instanceText)"
} }
let linked = snap.channels[linkId]?.linked ?? false let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age" let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label = let label =
snap.channelLabels?[linkId] ?? linkId.flatMap { snap.channelLabels?[$0] } ??
linkId.capitalized linkId?.capitalized ??
"channel"
let linkText = linked ? "linked" : "not linked" let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
} }
+1 -69
View File
@@ -304,8 +304,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"policy": "pairing", "policy": "pairing",
"allowFrom": ["U123", "U456", "*"], "allowFrom": ["U123", "U456", "*"],
"groupEnabled": false, "groupEnabled": false,
"groupChannels": ["G123"], "groupChannels": ["G123"]
"replyToMode": "all"
}, },
"channels": { "channels": {
"C123": { "allow": true, "requireMention": true }, "C123": { "allow": true, "requireMention": true },
@@ -362,73 +361,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Per-chat-type threading
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
```json5
{
channels: {
slack: {
replyToMode: "off", // default for channels
replyToModeByChatType: {
direct: "all", // DMs always thread
group: "first" // group DMs/MPIM thread first reply
},
}
}
}
```
Supported chat types:
- `direct`: 1:1 DMs (Slack `im`)
- `group`: group DMs / MPIMs (Slack `mpim`)
- `channel`: standard channels (public/private)
Precedence:
1) `replyToModeByChatType.<chatType>`
2) `replyToMode`
3) Provider default (`off`)
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
Examples:
Thread DMs only:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" }
}
}
}
```
Thread group DMs but keep channels in the root:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { group: "first" }
}
}
}
```
Make channels thread, keep DMs in the root:
```json5
{
channels: {
slack: {
replyToMode: "first",
replyToModeByChatType: { direct: "off", group: "off" }
}
}
}
```
### Manual threading tags ### Manual threading tags
For fine-grained control, use these tags in agent responses: For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). - `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
+1 -1
View File
@@ -129,7 +129,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
enabled: true, enabled: true,
maxBytes: 20971520, maxBytes: 20971520,
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
// Optional CLI fallback (Whisper binary): // Optional CLI fallback (Whisper binary):
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] } // { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
], ],
+2 -2
View File
@@ -1865,7 +1865,7 @@ Note: `applyPatch` is only under `tools.exec`.
- Each `models[]` entry: - Each `models[]` entry:
- Provider entry (`type: "provider"` or omitted): - Provider entry (`type: "provider"` or omitted):
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc). - `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
- `model`: model id override (required for image; defaults to `gpt-4o-mini-transcribe`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video). - `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
- `profile` / `preferredProfile`: auth profile selection. - `profile` / `preferredProfile`: auth profile selection.
- CLI entry (`type: "cli"`): - CLI entry (`type: "cli"`):
- `command`: executable to run. - `command`: executable to run.
@@ -1890,7 +1890,7 @@ Example:
rules: [{ action: "allow", match: { chatType: "direct" } }] rules: [{ action: "allow", match: { chatType: "direct" } }]
}, },
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] } { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
] ]
}, },
+3 -19
View File
@@ -6,7 +6,7 @@ read_when:
# Audio / Voice Notes — 2026-01-17 # Audio / Voice Notes — 2026-01-17
## What works ## What works
- **Media understanding (audio)**: If audio understanding is enabled (or auto‑detected), Clawdbot: - **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
1) Locates the first audio attachment (local path or URL) and downloads it if needed. 1) Locates the first audio attachment (local path or URL) and downloads it if needed.
2) Enforces `maxBytes` before sending to each model entry. 2) Enforces `maxBytes` before sending to each model entry.
3) Runs the first eligible model entry in order (provider or CLI). 3) Runs the first eligible model entry in order (provider or CLI).
@@ -15,21 +15,6 @@ read_when:
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work. - **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body. - **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
## Auto-detection (default)
If you **don’t configure models** and `tools.media.audio.enabled` is **not** set to `false`,
Clawdbot auto-detects in this order and stops at the first working option:
1) **Local CLIs** (if installed)
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
- `whisper-cli` (from `whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
- `whisper` (Python CLI; downloads models automatically)
2) **Gemini CLI** (`gemini`) using `read_many_files`
3) **Provider keys** (OpenAI → Groq → Deepgram → Google)
To disable auto-detection, set `tools.media.audio.enabled: false`.
To customize, set `tools.media.audio.models`.
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
## Config examples ## Config examples
### Provider + CLI fallback (OpenAI + Whisper CLI) ### Provider + CLI fallback (OpenAI + Whisper CLI)
@@ -41,7 +26,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
enabled: true, enabled: true,
maxBytes: 20971520, maxBytes: 20971520,
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
{ {
type: "cli", type: "cli",
command: "whisper", command: "whisper",
@@ -69,7 +54,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
] ]
}, },
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" } { provider: "openai", model: "whisper-1" }
] ]
} }
} }
@@ -98,7 +83,6 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`. - Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried. - Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output. - Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`). - Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
- Transcript is available to templates as `{{Transcript}}`. - Transcript is available to templates as `{{Transcript}}`.
- CLI stdout is capped (5MB); keep CLI output concise. - CLI stdout is capped (5MB); keep CLI output concise.
+14 -25
View File
@@ -6,7 +6,7 @@ read_when:
--- ---
# Media Understanding (Inbound) — 2026-01-17 # Media Understanding (Inbound) — 2026-01-17
Clawdbot can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual. Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
## Goals ## Goals
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing. - Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
@@ -88,11 +88,6 @@ Each `models[]` entry can be **provider** or **CLI**:
} }
``` ```
CLI templates can also use:
- `{{MediaDir}}` (directory containing the media file)
- `{{OutputDir}}` (scratch dir created for this run)
- `{{OutputBase}}` (scratch file base path, no extension)
## Defaults and limits ## Defaults and limits
Recommended defaults: Recommended defaults:
- `maxChars`: **500** for image/video (short, command‑friendly) - `maxChars`: **500** for image/video (short, command‑friendly)
@@ -109,22 +104,17 @@ Rules:
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the - If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability. **active reply model** when its provider supports the capability.
### Auto-detect media understanding (default) ### Auto-enable audio (when keys exist)
If `tools.media.<capability>.enabled` is **not** set to `false` and you haven’t If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
configured models, Clawdbot auto-detects in this order and **stops at the first audio provider keys configured, Clawdbot will **auto-enable audio transcription**
working option**: even when you haven’t listed models explicitly.
1) **Local CLIs** (audio only; if installed) Providers checked (in order):
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens) 1) OpenAI
- `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model) 2) Groq
- `whisper` (Python CLI; downloads models automatically) 3) Deepgram
2) **Gemini CLI** (`gemini`) using `read_many_files`
3) **Provider keys**
- Audio: OpenAI → Groq → Deepgram → Google
- Image: OpenAI → Anthropic → Google → MiniMax
- Video: Google
To disable auto-detection, set: To disable this behavior, set:
```json5 ```json5
{ {
tools: { tools: {
@@ -136,7 +126,6 @@ To disable auto-detection, set:
} }
} }
``` ```
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
## Capabilities (optional) ## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared If you set `capabilities`, the entry only runs for those media types. For shared
@@ -153,7 +142,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
| Capability | Provider integration | Notes | | Capability | Provider integration | Notes |
|------------|----------------------|-------| |------------|----------------------|-------|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. | | Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq, Deepgram, Google | Provider transcription (Whisper/Deepgram/Gemini). | | Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
| Video | Google (Gemini API) | Provider video understanding. | | Video | Google (Gemini API) | Provider video understanding. |
## Recommended providers ## Recommended providers
@@ -162,8 +151,8 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`. - Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
**Audio** **Audio**
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`. - `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`. - CLI fallback: `whisper` binary.
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram). - Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
**Video** **Video**
@@ -220,7 +209,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
audio: { audio: {
enabled: true, enabled: true,
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
{ {
type: "cli", type: "cli",
command: "whisper", command: "whisper",
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
saveMatrixCredentials, saveMatrixCredentials,
credentialsMatchConfig, credentialsMatchConfig,
touchMatrixCredentials, touchMatrixCredentials,
} = await import("../credentials.js"); } = await import("./credentials.js");
const cached = loadMatrixCredentials(env); const cached = loadMatrixCredentials(env);
const cachedCredentials = const cachedCredentials =
@@ -1,102 +0,0 @@
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");
});
});
+10 -88
View File
@@ -16,100 +16,22 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
const directRoomCache = new Map<string, string>(); async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
async function persistDirectRoom(
client: MatrixClient,
userId: string,
roomId: string,
): Promise<void> {
let directContent: MatrixDirectAccountData | null = null;
try {
directContent = (await client.getAccountData(
EventType.Direct,
)) as MatrixDirectAccountData | null;
} catch {
// Ignore fetch errors and fall back to an empty map.
}
const existing =
directContent && !Array.isArray(directContent) ? directContent : {};
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
if (current[0] === roomId) return;
const next = [roomId, ...current.filter((id) => id !== roomId)];
try {
await client.setAccountData(EventType.Direct, {
...existing,
[userId]: next,
});
} catch {
// Ignore persistence errors.
}
}
async function resolveDirectRoomId(
client: MatrixClient,
userId: string,
): Promise<string> {
const trimmed = userId.trim(); const trimmed = userId.trim();
if (!trimmed.startsWith("@")) { if (!trimmed.startsWith("@")) {
throw new Error( throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
);
} }
// matrix-bot-sdk: use getAccountData to retrieve m.direct
const cached = directRoomCache.get(trimmed);
if (cached) return cached;
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
try { try {
const directContent = (await client.getAccountData( const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
EventType.Direct, const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
)) as MatrixDirectAccountData | null; if (list.length > 0) return list[0];
const list = Array.isArray(directContent?.[trimmed])
? directContent[trimmed]
: [];
if (list.length > 0) {
directRoomCache.set(trimmed, list[0]);
return list[0];
}
} catch { } catch {
// Ignore and fall back. // Ignore errors, try fetching from server
} }
throw new Error(
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user. `No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle. );
let fallbackRoom: string | null = null;
try {
const rooms = await client.getJoinedRooms();
for (const roomId of rooms) {
let members: string[];
try {
members = await client.getJoinedRoomMembers(roomId);
} catch {
continue;
}
if (!members.includes(trimmed)) continue;
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) {
directRoomCache.set(trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
if (!fallbackRoom) {
fallbackRoom = roomId;
}
}
} catch {
// Ignore and fall back.
}
if (fallbackRoom) {
directRoomCache.set(trimmed, fallbackRoom);
await persistDirectRoom(client, trimmed, fallbackRoom);
return fallbackRoom;
}
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
} }
export async function resolveMatrixRoomId( export async function resolveMatrixRoomId(
+6 -8
View File
@@ -14,12 +14,11 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key"; // Skip if no OpenAI API key
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY); const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const liveEnabled = HAS_OPENAI_KEY && process.env.CLAWDBOT_LIVE_TEST === "1"; const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
const describeLive = liveEnabled ? describe : describe.skip;
describe("memory plugin e2e", () => { describeWithKey("memory plugin e2e", () => {
let tmpDir: string; let tmpDir: string;
let dbPath: string; let dbPath: string;
@@ -160,7 +159,7 @@ describe("memory plugin e2e", () => {
}); });
// Live tests that require OpenAI API key and actually use LanceDB // Live tests that require OpenAI API key and actually use LanceDB
describeLive("memory plugin live tests", () => { describeWithKey("memory plugin live tests", () => {
let tmpDir: string; let tmpDir: string;
let dbPath: string; let dbPath: string;
@@ -177,7 +176,6 @@ describeLive("memory plugin live tests", () => {
test("memory tools work end-to-end", async () => { test("memory tools work end-to-end", async () => {
const { default: memoryPlugin } = await import("./index.js"); const { default: memoryPlugin } = await import("./index.js");
const liveApiKey = process.env.OPENAI_API_KEY ?? "";
// Mock plugin API // Mock plugin API
const registeredTools: any[] = []; const registeredTools: any[] = [];
@@ -193,7 +191,7 @@ describeLive("memory plugin live tests", () => {
config: {}, config: {},
pluginConfig: { pluginConfig: {
embedding: { embedding: {
apiKey: liveApiKey, apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small", model: "text-embedding-3-small",
}, },
dbPath, dbPath,
+2 -3
View File
@@ -19,7 +19,6 @@ import {
readStringParam, readStringParam,
resolveDefaultSlackAccountId, resolveDefaultSlackAccountId,
resolveSlackAccount, resolveSlackAccount,
resolveSlackReplyToMode,
resolveSlackGroupRequireMention, resolveSlackGroupRequireMention,
buildSlackThreadingToolContext, buildSlackThreadingToolContext,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@@ -163,8 +162,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveRequireMention: resolveSlackGroupRequireMention, resolveRequireMention: resolveSlackGroupRequireMention,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true, allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
+14 -11
View File
@@ -46,7 +46,7 @@ TRASH
local needle="$1" local needle="$1"
local timeout_s="${2:-45}" local timeout_s="${2:-45}"
local needle_compact local needle_compact
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")" needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
local start_s local start_s
start_s="$(date +%s)" start_s="$(date +%s)"
while true; do while true; do
@@ -61,12 +61,17 @@ TRASH
let text = \"\"; let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
if (text.length > 20000) text = text.slice(-20000); if (text.length > 20000) text = text.slice(-20000);
const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\"); const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\"); const haystack = sanitize(text);
const haystack = compact(text); const safeNeedle = sanitize(needle);
const compactNeedle = compact(needle); const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
if (!compactNeedle) process.exit(1); let escaped = \"\";
process.exit(haystack.includes(compactNeedle) ? 0 : 1); 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);
"; then "; then
return 0 return 0
fi fi
@@ -255,11 +260,9 @@ TRASH
send_skills_flow() { send_skills_flow() {
# Select skills section and skip optional installs. # Select skills section and skip optional installs.
wait_for_log "Where will the Gateway run?" 60 || true send $'"'"'\r'"'"' 1.2
send $'"'"'\r'"'"' 0.6
# Configure skills now? -> No # Configure skills now? -> No
wait_for_log "Configure skills now?" 60 || true send $'"'"'n\r'"'"' 1.5
send $'"'"'n\r'"'"' 0.8
send "" 1.0 send "" 1.0
} }
+1 -5
View File
@@ -115,11 +115,7 @@ if (!shouldBuild()) {
runNode(); runNode();
} else { } else {
logRunner("Building TypeScript (dist is stale)."); logRunner("Building TypeScript (dist is stale).");
const pnpmArgs = ["exec", compiler, ...projectArgs]; const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm";
const buildArgs =
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
const build = spawn(buildCmd, buildArgs, {
cwd, cwd,
env, env,
stdio: "inherit", stdio: "inherit",
@@ -1,31 +0,0 @@
import { afterEach, expect, test, vi } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry";
afterEach(() => {
resetProcessRegistryForTests();
vi.resetModules();
vi.clearAllMocks();
});
test("exec falls back when PTY spawn fails", async () => {
vi.doMock("@lydell/node-pty", () => ({
spawn: () => {
const err = new Error("spawn EBADF");
(err as NodeJS.ErrnoException).code = "EBADF";
throw err;
},
}));
const { createExecTool } = await import("./bash-tools.exec");
const tool = createExecTool({ allowBackground: false });
const result = await tool.execute("toolcall", {
command: "printf ok",
pty: true,
});
expect(result.details.status).toBe("completed");
const text = result.content?.[0]?.text ?? "";
expect(text).toContain("ok");
expect(text).toContain("PTY spawn failed");
});
+38 -53
View File
@@ -26,7 +26,7 @@ import {
resolveShellEnvFallbackTimeoutMs, resolveShellEnvFallbackTimeoutMs,
} from "../infra/shell-env.js"; } from "../infra/shell-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo, logWarn } from "../logger.js"; import { logInfo } from "../logger.js";
import { import {
type ProcessSession, type ProcessSession,
type SessionStdin, type SessionStdin,
@@ -381,56 +381,41 @@ async function runExecProcess(opts: {
) as ChildProcessWithoutNullStreams; ) as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} else if (opts.usePty) { } else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig(); const ptyModule = (await import("@lydell/node-pty")) as unknown as {
try { spawn?: PtySpawn;
const ptyModule = (await import("@lydell/node-pty")) as unknown as { default?: { spawn?: PtySpawn };
spawn?: PtySpawn; };
default?: { spawn?: PtySpawn }; const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
}; if (!spawnPty) {
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn; throw new Error("PTY support is unavailable (node-pty spawn not found).");
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
pty = spawnPty(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// ignore EOF errors
}
},
};
} catch (err) {
const errText = String(err);
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning);
child = spawn(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
}) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
} }
const { shell, args: shellArgs } = getShellConfig();
pty = spawnPty(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// ignore EOF errors
}
},
};
} else { } else {
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
child = spawn(shell, [...shellArgs, opts.command], { child = spawn(shell, [...shellArgs, opts.command], {
@@ -1335,7 +1320,7 @@ export function createExecTool(
const effectiveTimeout = const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
const usePty = params.pty === true && !sandbox; const usePty = params.pty === true && !sandbox;
const run = await runExecProcess({ const run = await runExecProcess({
command: params.command, command: params.command,
@@ -1375,7 +1360,7 @@ export function createExecTool(
{ {
type: "text", type: "text",
text: text:
`${getWarningText()}` + `${warningText}` +
`Command still running (session ${run.session.id}, pid ${ `Command still running (session ${run.session.id}, pid ${
run.session.pid ?? "n/a" run.session.pid ?? "n/a"
}). ` + }). ` +
@@ -1425,7 +1410,7 @@ export function createExecTool(
content: [ content: [
{ {
type: "text", type: "text",
text: `${getWarningText()}${outcome.aggregated || "(no output)"}`, text: `${warningText}${outcome.aggregated || "(no output)"}`,
}, },
], ],
details: { details: {
@@ -0,0 +1,235 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
resolveGatewayPort: () => 18789,
};
});
import { emitAgentEvent } from "../infra/agent-events.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
describe("clawdbot-tools: subagents", () => {
beforeEach(() => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
});
it("sessions_spawn runs cleanup via lifecycle events", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as {
message?: string;
sessionKey?: string;
channel?: string;
timeout?: number;
lane?: string;
};
// Only capture the first agent call (subagent spawn, not main agent trigger)
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = params?.sessionKey ?? "";
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
return {
runId,
status: "accepted",
acceptedAt: 1000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
// Return "ok" with timing info for the child run
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete") {
const params = request.params as { key?: string } | undefined;
deletedKey = params?.key;
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call1", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "delete",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1234,
endedAt: 2345,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000);
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
// First call: subagent spawn
const first = agentCalls[0]?.params as
| {
lane?: string;
deliver?: boolean;
sessionKey?: string;
channel?: string;
}
| undefined;
expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false);
expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
// Second call: main agent trigger with announce message
const second = agentCalls[1]?.params as
| {
sessionKey?: string;
message?: string;
deliver?: boolean;
}
| undefined;
expect(second?.sessionKey).toBe("discord:group:req");
expect(second?.deliver).toBe(true);
expect(second?.message).toContain("background task");
// No direct send to external channel (main agent handles delivery)
const sendCalls = calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
// Session should be deleted since cleanup=delete
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn announces with requester accountId", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
if (params?.lane === "subagent") {
childRunId = runId;
}
return {
runId,
status: "accepted",
acceptedAt: 4000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "main",
agentChannel: "whatsapp",
agentAccountId: "kev",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call2", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "keep",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1000,
endedAt: 2000,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const announceParams = agentCalls[1]?.params as
| { accountId?: string; channel?: string; deliver?: boolean }
| undefined;
expect(announceParams?.deliver).toBe(true);
expect(announceParams?.channel).toBe("whatsapp");
expect(announceParams?.accountId).toBe("kev");
});
});
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
import { emitAgentEvent } from "../infra/agent-events.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -121,195 +120,4 @@ describe("clawdbot-tools: subagents", () => {
}); });
expect(callGatewayMock).not.toHaveBeenCalled(); expect(callGatewayMock).not.toHaveBeenCalled();
}); });
it("sessions_spawn runs cleanup via lifecycle events", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as {
message?: string;
sessionKey?: string;
channel?: string;
timeout?: number;
lane?: string;
};
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = params?.sessionKey ?? "";
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
return {
runId,
status: "accepted",
acceptedAt: 1000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete") {
const params = request.params as { key?: string } | undefined;
deletedKey = params?.key;
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call1", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "delete",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1234,
endedAt: 2345,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000);
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const first = agentCalls[0]?.params as
| {
lane?: string;
deliver?: boolean;
sessionKey?: string;
channel?: string;
}
| undefined;
expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false);
expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
const second = agentCalls[1]?.params as
| {
sessionKey?: string;
message?: string;
deliver?: boolean;
}
| undefined;
expect(second?.sessionKey).toBe("discord:group:req");
expect(second?.deliver).toBe(true);
expect(second?.message).toContain("background task");
const sendCalls = calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn announces with requester accountId", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
if (params?.lane === "subagent") {
childRunId = runId;
}
return {
runId,
status: "accepted",
acceptedAt: 4000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "main",
agentChannel: "whatsapp",
agentAccountId: "kev",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call2", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "keep",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1000,
endedAt: 2000,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const announceParams = agentCalls[1]?.params as
| { accountId?: string; channel?: string; deliver?: boolean }
| undefined;
expect(announceParams?.deliver).toBe(true);
expect(announceParams?.channel).toBe("whatsapp");
expect(announceParams?.accountId).toBe("kev");
});
}); });
-20
View File
@@ -1,5 +1,3 @@
import { EventEmitter } from "node:events";
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox";
import type { SessionManager } from "@mariozechner/pi-coding-agent"; import type { SessionManager } from "@mariozechner/pi-coding-agent";
@@ -186,28 +184,10 @@ export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider:
} }
} }
// Event emitter for unhandled compaction failures that escape try-catch blocks.
// Listeners can use this to trigger session recovery with retry.
const compactionFailureEmitter = new EventEmitter();
export type CompactionFailureListener = (reason: string) => void;
/**
* Register a listener for unhandled compaction failures.
* Called when auto-compaction fails in a way that escapes the normal try-catch,
* e.g., when the summarization request itself exceeds the model's token limit.
* Returns an unsubscribe function.
*/
export function onUnhandledCompactionFailure(cb: CompactionFailureListener): () => void {
compactionFailureEmitter.on("failure", cb);
return () => compactionFailureEmitter.off("failure", cb);
}
registerUnhandledRejectionHandler((reason) => { registerUnhandledRejectionHandler((reason) => {
const message = describeUnknownError(reason); const message = describeUnknownError(reason);
if (!isCompactionFailureError(message)) return false; if (!isCompactionFailureError(message)) return false;
log.error(`Auto-compaction failed (unhandled): ${message}`); log.error(`Auto-compaction failed (unhandled): ${message}`);
compactionFailureEmitter.emit("failure", message);
return true; return true;
}); });
+3 -3
View File
@@ -9,7 +9,7 @@ import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
* - <invoke name="...">...</invoke> blocks * - <invoke name="...">...</invoke> blocks
* - </minimax:tool_call> closing tags * - </minimax:tool_call> closing tags
*/ */
export function stripMinimaxToolCallXml(text: string): string { function stripMinimaxToolCallXml(text: string): string {
if (!text) return text; if (!text) return text;
if (!/minimax:tool_call/i.test(text)) return text; if (!/minimax:tool_call/i.test(text)) return text;
@@ -28,7 +28,7 @@ export function stripMinimaxToolCallXml(text: string): string {
* downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should * downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should
* not be shown to users. * not be shown to users.
*/ */
export function stripDowngradedToolCallText(text: string): string { function stripDowngradedToolCallText(text: string): string {
if (!text) return text; if (!text) return text;
if (!/\[Tool (?:Call|Result)/i.test(text)) return text; if (!/\[Tool (?:Call|Result)/i.test(text)) return text;
@@ -165,7 +165,7 @@ export function stripDowngradedToolCallText(text: string): string {
* This is a safety net for cases where the model outputs <think> tags * This is a safety net for cases where the model outputs <think> tags
* that slip through other filtering mechanisms. * that slip through other filtering mechanisms.
*/ */
export function stripThinkingTagsFromText(text: string): string { function stripThinkingTagsFromText(text: string): string {
if (!text) return text; if (!text) return text;
// Quick check to avoid regex overhead when no tags present. // Quick check to avoid regex overhead when no tags present.
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text; if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
@@ -3,15 +3,7 @@ import { describe, expect, it } from "vitest";
import { __testing } from "./compaction-safeguard.js"; import { __testing } from "./compaction-safeguard.js";
const { const { collectToolFailures, formatToolFailuresSection } = __testing;
collectToolFailures,
formatToolFailuresSection,
computeAdaptiveChunkRatio,
isOversizedForSummary,
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,
} = __testing;
describe("compaction-safeguard tool failures", () => { describe("compaction-safeguard tool failures", () => {
it("formats tool failures with meta and summary", () => { it("formats tool failures with meta and summary", () => {
@@ -104,107 +96,3 @@ describe("compaction-safeguard tool failures", () => {
expect(section).toBe(""); expect(section).toBe("");
}); });
}); });
describe("computeAdaptiveChunkRatio", () => {
const CONTEXT_WINDOW = 200_000;
it("returns BASE_CHUNK_RATIO for normal messages", () => {
// Small messages: 1000 tokens each, well under 10% of context
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(1000), timestamp: Date.now() },
{
role: "assistant",
content: [{ type: "text", text: "y".repeat(1000) }],
timestamp: Date.now(),
},
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
expect(ratio).toBe(BASE_CHUNK_RATIO);
});
it("reduces ratio when average message > 10% of context", () => {
// Large messages: ~50K tokens each (25% of context)
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(50_000 * 4), timestamp: Date.now() },
{
role: "assistant",
content: [{ type: "text", text: "y".repeat(50_000 * 4) }],
timestamp: Date.now(),
},
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
expect(ratio).toBeLessThan(BASE_CHUNK_RATIO);
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
});
it("respects MIN_CHUNK_RATIO floor", () => {
// Very large messages that would push ratio below minimum
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(150_000 * 4), timestamp: Date.now() },
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
});
it("handles empty message array", () => {
const ratio = computeAdaptiveChunkRatio([], CONTEXT_WINDOW);
expect(ratio).toBe(BASE_CHUNK_RATIO);
});
it("handles single huge message", () => {
// Single massive message
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(180_000 * 4), timestamp: Date.now() },
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
expect(ratio).toBeLessThanOrEqual(BASE_CHUNK_RATIO);
});
});
describe("isOversizedForSummary", () => {
const CONTEXT_WINDOW = 200_000;
it("returns false for small messages", () => {
const msg: AgentMessage = {
role: "user",
content: "Hello, world!",
timestamp: Date.now(),
};
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(false);
});
it("returns true for messages > 50% of context", () => {
// Message with ~120K tokens (60% of 200K context)
// After safety margin (1.2x), effective is 144K which is > 100K (50%)
const msg: AgentMessage = {
role: "user",
content: "x".repeat(120_000 * 4),
timestamp: Date.now(),
};
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(true);
});
it("applies safety margin", () => {
// Message at exactly 50% of context before margin
// After SAFETY_MARGIN (1.2), it becomes 60% which is > 50%
const halfContextChars = (CONTEXT_WINDOW * 0.5) / SAFETY_MARGIN;
const msg: AgentMessage = {
role: "user",
content: "x".repeat(Math.floor(halfContextChars * 4)),
timestamp: Date.now(),
};
// With safety margin applied, this should be at the boundary
// The function checks if tokens * SAFETY_MARGIN > contextWindow * 0.5
const isOversized = isOversizedForSummary(msg, CONTEXT_WINDOW);
// Due to token estimation, this could be either true or false at the boundary
expect(typeof isOversized).toBe("boolean");
});
});
@@ -4,9 +4,7 @@ import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
const BASE_CHUNK_RATIO = 0.4; const MAX_CHUNK_RATIO = 0.4;
const MIN_CHUNK_RATIO = 0.15;
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
const FALLBACK_SUMMARY = const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated."; "Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS = const TURN_PREFIX_INSTRUCTIONS =
@@ -162,38 +160,6 @@ function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessag
return chunks; return chunks;
} }
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
async function summarizeChunks(params: { async function summarizeChunks(params: {
messages: AgentMessage[]; messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>; model: NonNullable<ExtensionContext["model"]>;
@@ -226,78 +192,6 @@ async function summarizeChunks(params: {
return summary ?? "No prior history."; return summary ?? "No prior history.";
} }
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export default function compactionSafeguardExtension(api: ExtensionAPI): void { export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => { api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event; const { preparation, customInstructions, signal } = event;
@@ -339,35 +233,29 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
1, 1,
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS), Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
); );
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * MAX_CHUNK_RATIO));
// Use adaptive chunk ratio based on message sizes
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens)); const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
const historySummary = await summarizeWithFallback({ const historySummary = await summarizeChunks({
messages: preparation.messagesToSummarize, messages: preparation.messagesToSummarize,
model, model,
apiKey, apiKey,
signal, signal,
reserveTokens, reserveTokens,
maxChunkTokens, maxChunkTokens,
contextWindow: contextWindowTokens,
customInstructions, customInstructions,
previousSummary: preparation.previousSummary, previousSummary: preparation.previousSummary,
}); });
let summary = historySummary; let summary = historySummary;
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) { if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
const prefixSummary = await summarizeWithFallback({ const prefixSummary = await summarizeChunks({
messages: preparation.turnPrefixMessages, messages: preparation.turnPrefixMessages,
model, model,
apiKey, apiKey,
signal, signal,
reserveTokens, reserveTokens,
maxChunkTokens, maxChunkTokens,
contextWindow: contextWindowTokens,
customInstructions: TURN_PREFIX_INSTRUCTIONS, customInstructions: TURN_PREFIX_INSTRUCTIONS,
}); });
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`; summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
@@ -405,9 +293,4 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
export const __testing = { export const __testing = {
collectToolFailures, collectToolFailures,
formatToolFailuresSection, formatToolFailuresSection,
computeAdaptiveChunkRatio,
isOversizedForSummary,
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,
} as const; } as const;
-14
View File
@@ -237,20 +237,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Bravo"); expect(prompt).toContain("Bravo");
}); });
it("adds SOUL guidance when a soul file is present", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
contextFiles: [
{ path: "./SOUL.md", content: "Persona" },
{ path: "dir\\SOUL.md", content: "Persona Windows" },
],
});
expect(prompt).toContain(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
});
it("summarizes the message tool when available", () => { it("summarizes the message tool when available", () => {
const prompt = buildAgentSystemPrompt({ const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd", workspaceDir: "/tmp/clawd",
+6 -12
View File
@@ -517,18 +517,12 @@ export function buildAgentSystemPrompt(params: {
const contextFiles = params.contextFiles ?? []; const contextFiles = params.contextFiles ?? [];
if (contextFiles.length > 0) { if (contextFiles.length > 0) {
const hasSoulFile = contextFiles.some((file) => { lines.push(
const normalizedPath = file.path.trim().replace(/\\/g, "/"); "# Project Context",
const baseName = normalizedPath.split("/").pop() ?? normalizedPath; "",
return baseName.toLowerCase() === "soul.md"; "The following project context files have been loaded:",
}); "",
lines.push("# Project Context", "", "The following project context files have been loaded:"); );
if (hasSoulFile) {
lines.push(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
}
lines.push("");
for (const file of contextFiles) { for (const file of contextFiles) {
lines.push(`## ${file.path}`, "", file.content, ""); lines.push(`## ${file.path}`, "", file.content, "");
} }
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
describe("sanitizeTextContent", () => {
it("strips minimax tool call XML and downgraded markers", () => {
const input =
'Hello <invoke name="tool">payload</invoke></minimax:tool_call> ' +
"[Tool Call: foo (ID: 1)] world";
const result = sanitizeTextContent(input).trim();
expect(result).toBe("Hello world");
expect(result).not.toContain("invoke");
expect(result).not.toContain("Tool Call");
});
it("strips thinking tags", () => {
const input = "Before <think>secret</think> after";
const result = sanitizeTextContent(input).trim();
expect(result).toBe("Before after");
});
});
describe("extractAssistantText", () => {
it("sanitizes blocks without injecting newlines", () => {
const message = {
role: "assistant",
content: [
{ type: "text", text: "Hi " },
{ type: "text", text: "<think>secret</think>there" },
],
};
expect(extractAssistantText(message)).toBe("Hi there");
});
});
+3 -21
View File
@@ -1,10 +1,4 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { sanitizeUserFacingText } from "../pi-embedded-helpers.js";
import {
stripDowngradedToolCallText,
stripMinimaxToolCallXml,
stripThinkingTagsFromText,
} from "../pi-embedded-utils.js";
import { normalizeMainKey } from "../../routing/session-key.js"; import { normalizeMainKey } from "../../routing/session-key.js";
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other"; export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
@@ -106,15 +100,6 @@ export function stripToolMessages(messages: unknown[]): unknown[] {
}); });
} }
/**
* Sanitize text content to strip tool call markers and thinking tags.
* This ensures user-facing text doesn't leak internal tool representations.
*/
export function sanitizeTextContent(text: string): string {
if (!text) return text;
return stripThinkingTagsFromText(stripDowngradedToolCallText(stripMinimaxToolCallXml(text)));
}
export function extractAssistantText(message: unknown): string | undefined { export function extractAssistantText(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined; if (!message || typeof message !== "object") return undefined;
if ((message as { role?: unknown }).role !== "assistant") return undefined; if ((message as { role?: unknown }).role !== "assistant") return undefined;
@@ -125,13 +110,10 @@ export function extractAssistantText(message: unknown): string | undefined {
if (!block || typeof block !== "object") continue; if (!block || typeof block !== "object") continue;
if ((block as { type?: unknown }).type !== "text") continue; if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text; const text = (block as { text?: unknown }).text;
if (typeof text === "string") { if (typeof text === "string" && text.trim()) {
const sanitized = sanitizeTextContent(text); chunks.push(text);
if (sanitized.trim()) {
chunks.push(sanitized);
}
} }
} }
const joined = chunks.join("").trim(); const joined = chunks.join("").trim();
return joined ? sanitizeUserFacingText(joined) : undefined; return joined ? joined : undefined;
} }
-14
View File
@@ -357,20 +357,6 @@ describe("handleSlackAction", () => {
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
}); });
it("passes threadId through to readSlackMessages", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction(
{ action: "readMessages", channelId: "C1", threadId: "12345.6789" },
cfg,
);
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
expect(opts?.threadId).toBe("12345.6789");
});
it("adds normalized timestamps to pin payloads", async () => { it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([ listSlackPins.mockResolvedValueOnce([
-2
View File
@@ -214,13 +214,11 @@ export async function handleSlackAction(
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before"); const before = readStringParam(params, "before");
const after = readStringParam(params, "after"); const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await readSlackMessages(channelId, { const result = await readSlackMessages(channelId, {
...readOpts, ...readOpts,
limit, limit,
before: before ?? undefined, before: before ?? undefined,
after: after ?? undefined, after: after ?? undefined,
threadId: threadId ?? undefined,
}); });
const messages = result.messages.map((message) => const messages = result.messages.map((message) =>
withNormalizedTimestamp( withNormalizedTimestamp(
-1
View File
@@ -136,7 +136,6 @@ export async function runReplyAgent(params: {
followupRun.run.config, followupRun.run.config,
replyToChannel, replyToChannel,
sessionCtx.AccountId, sessionCtx.AccountId,
sessionCtx.ChatType,
); );
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
const cfg = followupRun.run.config; const cfg = followupRun.run.config;
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { extractMessageText } from "./commands-subagents.js";
describe("extractMessageText", () => {
it("preserves user text that looks like tool call markers", () => {
const message = {
role: "user",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
});
it("sanitizes assistant tool call markers", () => {
const message = {
role: "assistant",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toBe("Here ok");
});
});
+4 -11
View File
@@ -7,7 +7,6 @@ import {
extractAssistantText, extractAssistantText,
resolveInternalSessionKey, resolveInternalSessionKey,
resolveMainSessionAlias, resolveMainSessionAlias,
sanitizeTextContent,
stripToolMessages, stripToolMessages,
} from "../../agents/tools/sessions-helpers.js"; } from "../../agents/tools/sessions-helpers.js";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
@@ -107,14 +106,11 @@ function normalizeMessageText(text: string) {
return text.replace(/\s+/g, " ").trim(); return text.replace(/\s+/g, " ").trim();
} }
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null { function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
const role = typeof message.role === "string" ? message.role : ""; const role = typeof message.role === "string" ? message.role : "";
const shouldSanitize = role === "assistant";
const content = message.content; const content = message.content;
if (typeof content === "string") { if (typeof content === "string") {
const normalized = normalizeMessageText( const normalized = normalizeMessageText(content);
shouldSanitize ? sanitizeTextContent(content) : content,
);
return normalized ? { role, text: normalized } : null; return normalized ? { role, text: normalized } : null;
} }
if (!Array.isArray(content)) return null; if (!Array.isArray(content)) return null;
@@ -123,11 +119,8 @@ export function extractMessageText(message: ChatMessage): { role: string; text:
if (!block || typeof block !== "object") continue; if (!block || typeof block !== "object") continue;
if ((block as { type?: unknown }).type !== "text") continue; if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text; const text = (block as { text?: unknown }).text;
if (typeof text === "string") { if (typeof text === "string" && text.trim()) {
const value = shouldSanitize ? sanitizeTextContent(text) : text; chunks.push(text);
if (value.trim()) {
chunks.push(value);
}
} }
} }
const joined = normalizeMessageText(chunks.join(" ")); const joined = normalizeMessageText(chunks.join(" "));
-1
View File
@@ -204,7 +204,6 @@ export function createFollowupRunner(params: {
queued.run.config, queued.run.config,
replyToChannel, replyToChannel,
queued.originatingAccountId, queued.originatingAccountId,
queued.originatingChatType,
); );
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
-1
View File
@@ -358,7 +358,6 @@ export async function runPreparedReply(
originatingTo: ctx.OriginatingTo, originatingTo: ctx.OriginatingTo,
originatingAccountId: ctx.AccountId, originatingAccountId: ctx.AccountId,
originatingThreadId: ctx.MessageThreadId, originatingThreadId: ctx.MessageThreadId,
originatingChatType: ctx.ChatType,
run: { run: {
agentId, agentId,
agentDir, agentDir,
-2
View File
@@ -39,8 +39,6 @@ export type FollowupRun = {
originatingAccountId?: string; originatingAccountId?: string;
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */ /** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
originatingThreadId?: string | number; originatingThreadId?: string | number;
/** Chat type for context-aware threading (e.g., DM vs channel). */
originatingChatType?: string;
run: { run: {
agentId: string; agentId: string;
agentDir: string; agentDir: string;
@@ -31,46 +31,6 @@ describe("resolveReplyToMode", () => {
expect(resolveReplyToMode(cfg, "discord")).toBe("first"); expect(resolveReplyToMode(cfg, "discord")).toBe("first");
expect(resolveReplyToMode(cfg, "slack")).toBe("all"); expect(resolveReplyToMode(cfg, "slack")).toBe("all");
}); });
it("uses chat-type replyToMode overrides for Slack when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all", group: "first" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
});
it("falls back to top-level replyToMode when no chat-type override is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
});
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
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");
});
}); });
describe("createReplyToModeFilter", () => { describe("createReplyToModeFilter", () => {
-2
View File
@@ -9,14 +9,12 @@ export function resolveReplyToMode(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
channel?: OriginatingChannelType, channel?: OriginatingChannelType,
accountId?: string | null, accountId?: string | null,
chatType?: string | null,
): ReplyToMode { ): ReplyToMode {
const provider = normalizeChannelId(channel); const provider = normalizeChannelId(channel);
if (!provider) return "all"; if (!provider) return "all";
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({ const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
cfg, cfg,
accountId, accountId,
chatType,
}); });
return resolved ?? "all"; return resolved ?? "all";
} }
-3
View File
@@ -60,12 +60,9 @@ export type MsgContext = {
MediaPath?: string; MediaPath?: string;
MediaUrl?: string; MediaUrl?: string;
MediaType?: string; MediaType?: string;
MediaDir?: string;
MediaPaths?: string[]; MediaPaths?: string[];
MediaUrls?: string[]; MediaUrls?: string[];
MediaTypes?: string[]; MediaTypes?: string[];
OutputDir?: string;
OutputBase?: string;
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */ /** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
MediaRemoteHost?: string; MediaRemoteHost?: string;
Transcript?: string; Transcript?: string;
+3 -3
View File
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js"; import { resolveSlackAccount } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js"; import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveRequireMention: resolveSlackGroupRequireMention, resolveRequireMention: resolveSlackGroupRequireMention,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true, allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
+1 -1
View File
@@ -47,7 +47,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
"1) Discord Developer Portal → Applications → New Application", "1) Discord Developer Portal → Applications → New Application",
"2) Bot → Add Bot → Reset Token → copy token", "2) Bot → Add Bot → Reset Token → copy token",
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server", "3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
"Tip: enable Message Content Intent if you need message text. (Bot → Privileged Gateway Intents → Message Content Intent)", "Tip: enable Message Content Intent if you need message text.",
`Docs: ${formatDocsLink("/discord", "discord")}`, `Docs: ${formatDocsLink("/discord", "discord")}`,
].join("\n"), ].join("\n"),
"Discord bot token", "Discord bot token",
@@ -1,34 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSlackActions } from "./slack.actions.js";
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../../agents/tools/slack-actions.js", () => ({
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
}));
describe("slack actions adapter", () => {
it("forwards threadId for read", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
const actions = createSlackActions("slack");
await actions.handleAction?.({
channel: "slack",
action: "read",
cfg,
params: {
channelId: "C1",
threadId: "171234.567",
},
});
const [params] = handleSlackAction.mock.calls[0] ?? [];
expect(params).toMatchObject({
action: "readMessages",
channelId: "C1",
threadId: "171234.567",
});
});
});
-1
View File
@@ -133,7 +133,6 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
limit, limit,
before: readStringParam(params, "before"), before: readStringParam(params, "before"),
after: readStringParam(params, "after"), after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}, },
cfg, cfg,
-1
View File
@@ -198,7 +198,6 @@ export type ChannelThreadingAdapter = {
resolveReplyToMode?: (params: { resolveReplyToMode?: (params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
accountId?: string | null; accountId?: string | null;
chatType?: string | null;
}) => "off" | "first" | "all"; }) => "off" | "first" | "all";
allowTagsWhenOff?: boolean; allowTagsWhenOff?: boolean;
buildToolContext?: (params: { buildToolContext?: (params: {
+2 -6
View File
@@ -94,13 +94,9 @@ export function buildParseArgv(params: {
: baseArgv[0]?.endsWith("clawdbot") : baseArgv[0]?.endsWith("clawdbot")
? baseArgv.slice(1) ? baseArgv.slice(1)
: baseArgv; : baseArgv;
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase(); const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
const looksLikeNode = const looksLikeNode =
normalizedArgv.length >= 2 && normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
if (looksLikeNode) return normalizedArgv; if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv]; return ["node", programName || "clawdbot", ...normalizedArgv];
} }
+1 -1
View File
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
value: "anthropic", value: "anthropic",
label: "Anthropic", label: "Anthropic",
hint: "Claude Code CLI + API key", hint: "Claude Code CLI + API key",
choices: ["token", "claude-cli", "apiKey"], choices: ["claude-cli", "token", "apiKey"],
}, },
{ {
value: "minimax", value: "minimax",
+3 -14
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { openUrl, resolveBrowserOpenCommand, resolveControlUiLinks } from "./onboard-helpers.js"; import { openUrl, resolveBrowserOpenCommand, resolveControlUiLinks } from "./onboard-helpers.js";
@@ -21,17 +21,9 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
})); }));
afterEach(() => {
vi.unstubAllEnvs();
});
describe("openUrl", () => { describe("openUrl", () => {
it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => { it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => {
vi.stubEnv("VITEST", ""); vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.stubEnv("NODE_ENV", "");
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.stubEnv("VITEST", "");
vi.stubEnv("NODE_ENV", "development");
const url = const url =
"https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&response_type=code&redirect_uri=http%3A%2F%2Flocalhost"; "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&response_type=code&redirect_uri=http%3A%2F%2Flocalhost";
@@ -47,18 +39,15 @@ describe("openUrl", () => {
timeoutMs: 5_000, timeoutMs: 5_000,
windowsVerbatimArguments: true, windowsVerbatimArguments: true,
}); });
platformSpy.mockRestore();
}); });
}); });
describe("resolveBrowserOpenCommand", () => { describe("resolveBrowserOpenCommand", () => {
it("marks win32 commands as quoteUrl=true", async () => { it("marks win32 commands as quoteUrl=true", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const resolved = await resolveBrowserOpenCommand(); const resolved = await resolveBrowserOpenCommand();
expect(resolved.argv).toEqual(["cmd", "/c", "start", ""]); expect(resolved.argv).toEqual(["cmd", "/c", "start", ""]);
expect(resolved.quoteUrl).toBe(true); expect(resolved.quoteUrl).toBe(true);
platformSpy.mockRestore();
}); });
}); });
-20
View File
@@ -192,7 +192,6 @@ function resolveSshTargetHint(): string {
} }
export async function openUrl(url: string): Promise<boolean> { export async function openUrl(url: string): Promise<boolean> {
if (shouldSkipBrowserOpenInTests()) return false;
const resolved = await resolveBrowserOpenCommand(); const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv) return false; if (!resolved.argv) return false;
const quoteUrl = resolved.quoteUrl === true; const quoteUrl = resolved.quoteUrl === true;
@@ -218,20 +217,6 @@ export async function openUrl(url: string): Promise<boolean> {
} }
} }
export async function openUrlInBackground(url: string): Promise<boolean> {
if (shouldSkipBrowserOpenInTests()) return false;
if (process.platform !== "darwin") return false;
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv || resolved.command !== "open") return false;
const command = ["open", "-g", url];
try {
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
return true;
} catch {
return false;
}
}
export async function ensureWorkspaceAndSessions( export async function ensureWorkspaceAndSessions(
workspaceDir: string, workspaceDir: string,
runtime: RuntimeEnv, runtime: RuntimeEnv,
@@ -310,11 +295,6 @@ export async function detectBinary(name: string): Promise<boolean> {
} }
} }
function shouldSkipBrowserOpenInTests(): boolean {
if (process.env.VITEST) return true;
return process.env.NODE_ENV === "test";
}
export async function probeGatewayReachable(params: { export async function probeGatewayReachable(params: {
url: string; url: string;
token?: string; token?: string;
@@ -4,43 +4,18 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { rawDataToString } from "../infra/ws.js";
import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
const gatewayClientCalls: Array<{
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
onClose?: (code: number, reason: string) => void;
}> = [];
vi.mock("../gateway/client.js", () => ({
GatewayClient: class {
params: {
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
};
constructor(params: {
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
}) {
this.params = params;
gatewayClientCalls.push(params);
}
async request() {
return { ok: true };
}
start() {
queueMicrotask(() => this.params.onHelloOk?.());
}
stop() {}
},
}));
async function getFreePort(): Promise<number> { async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
@@ -66,6 +41,85 @@ async function getFreeGatewayPort(): Promise<number> {
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] }); return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] });
} }
async function onceMessage<T = unknown>(
ws: WebSocket,
filter: (obj: unknown) => boolean,
timeoutMs = 5000,
): Promise<T> {
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
const closeHandler = (code: number, reason: Buffer) => {
clearTimeout(timer);
ws.off("message", handler);
reject(new Error(`closed ${code}: ${rawDataToString(reason)}`));
};
const handler = (data: WebSocket.RawData) => {
const obj = JSON.parse(rawDataToString(data));
if (!filter(obj)) return;
clearTimeout(timer);
ws.off("message", handler);
ws.off("close", closeHandler);
resolve(obj as T);
};
ws.on("message", handler);
ws.once("close", closeHandler);
});
}
async function connectReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url);
await new Promise<void>((resolve) => ws.once("open", resolve));
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes: [],
signedAtMs,
token: params.token ?? null,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
ws.send(
JSON.stringify({
type: "req",
id: "c1",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: GATEWAY_CLIENT_NAMES.TEST,
displayName: "vitest",
version: "dev",
platform: process.platform,
mode: GATEWAY_CLIENT_MODES.TEST,
},
caps: [],
auth: params.token ? { token: params.token } : undefined,
device,
},
}),
);
const res = await onceMessage<{
type: "res";
id: string;
ok: boolean;
error?: { message?: string };
}>(ws, (o) => {
const obj = o as { type?: unknown; id?: unknown } | undefined;
return obj?.type === "res" && obj?.id === "c1";
});
ws.close();
return res;
}
const runtime = { const runtime = {
log: () => {}, log: () => {},
error: (msg: string) => { error: (msg: string) => {
@@ -98,6 +152,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
const stateDir = await fs.mkdtemp(path.join(tempHome, prefix)); const stateDir = await fs.mkdtemp(path.join(tempHome, prefix));
process.env.CLAWDBOT_STATE_DIR = stateDir; process.env.CLAWDBOT_STATE_DIR = stateDir;
delete process.env.CLAWDBOT_CONFIG_PATH; delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
return stateDir; return stateDir;
}; };
@@ -152,9 +207,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
runtime, runtime,
); );
const { resolveConfigPath } = await import("../config/paths.js"); const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const configPath = resolveConfigPath(process.env, stateDir); const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
const cfg = JSON.parse(await fs.readFile(configPath, "utf8")) as {
gateway?: { auth?: { mode?: string; token?: string } }; gateway?: { auth?: { mode?: string; token?: string } };
agents?: { defaults?: { workspace?: string } }; agents?: { defaults?: { workspace?: string } };
}; };
@@ -163,12 +217,25 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toBe(token); expect(cfg?.gateway?.auth?.token).toBe(token);
const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js"); const { startGatewayServer } = await import("../gateway/server.js");
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); const port = await getFreePort();
const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } }); const server = await startGatewayServer(port, {
expect(resNoToken.ok).toBe(false); bind: "loopback",
const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } }); controlUiEnabled: false,
expect(resToken.ok).toBe(true); });
try {
const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` });
expect(resNoToken.ok).toBe(false);
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
const resToken = await connectReq({
url: `ws://127.0.0.1:${port}`,
token,
});
expect(resToken.ok).toBe(true);
} finally {
await server.close({ reason: "non-interactive onboard auth test" });
}
await fs.rm(stateDir, { recursive: true, force: true }); await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000); }, 60_000);
@@ -177,37 +244,43 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
const stateDir = await initStateDir("state-remote-"); const stateDir = await initStateDir("state-remote-");
const port = await getFreePort(); const port = await getFreePort();
const token = "tok_remote_123"; const token = "tok_remote_123";
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const { startGatewayServer } = await import("../gateway/server.js");
await runNonInteractiveOnboarding( const server = await startGatewayServer(port, {
{ bind: "loopback",
nonInteractive: true, auth: { mode: "token", token },
mode: "remote", controlUiEnabled: false,
remoteUrl: `ws://127.0.0.1:${port}`, });
remoteToken: token,
authChoice: "skip",
json: true,
},
runtime,
);
const { resolveConfigPath } = await import("../config/config.js"); try {
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as { const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
gateway?: { mode?: string; remote?: { url?: string; token?: string } }; await runNonInteractiveOnboarding(
}; {
nonInteractive: true,
mode: "remote",
remoteUrl: `ws://127.0.0.1:${port}`,
remoteToken: token,
authChoice: "skip",
json: true,
},
runtime,
);
expect(cfg.gateway?.mode).toBe("remote"); const { resolveConfigPath } = await import("../config/config.js");
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
expect(cfg.gateway?.remote?.token).toBe(token); gateway?: { mode?: string; remote?: { url?: string; token?: string } };
};
gatewayClientCalls.length = 0; expect(cfg.gateway?.mode).toBe("remote");
const { callGateway } = await import("../gateway/call.js"); expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
const health = await callGateway<{ ok?: boolean }>({ method: "health" }); expect(cfg.gateway?.remote?.token).toBe(token);
expect(health?.ok).toBe(true);
const lastCall = gatewayClientCalls[gatewayClientCalls.length - 1];
expect(lastCall?.url).toBe(`ws://127.0.0.1:${port}`);
expect(lastCall?.token).toBe(token);
await fs.rm(stateDir, { recursive: true, force: true }); const { callGateway } = await import("../gateway/call.js");
const health = await callGateway<{ ok?: boolean }>({ method: "health" });
expect(health?.ok).toBe(true);
} finally {
await server.close({ reason: "non-interactive remote test complete" });
await fs.rm(stateDir, { recursive: true, force: true });
}
}, 60_000); }, 60_000);
it("auto-enables token auth when binding LAN and persists the token", async () => { it("auto-enables token auth when binding LAN and persists the token", async () => {
@@ -263,12 +336,27 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
const token = cfg.gateway?.auth?.token ?? ""; const token = cfg.gateway?.auth?.token ?? "";
expect(token.length).toBeGreaterThan(8); expect(token.length).toBeGreaterThan(8);
const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js"); const { startGatewayServer } = await import("../gateway/server.js");
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); const server = await startGatewayServer(port, {
const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } }); controlUiEnabled: false,
expect(resNoToken.ok).toBe(false); auth: {
const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } }); mode: "token",
expect(resToken.ok).toBe(true); token,
},
});
try {
const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` });
expect(resNoToken.ok).toBe(false);
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
const resToken = await connectReq({
url: `ws://127.0.0.1:${port}`,
token,
});
expect(resToken.ok).toBe(true);
} finally {
await server.close({ reason: "lan auto-token test complete" });
}
await fs.rm(stateDir, { recursive: true, force: true }); await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000); }, 60_000);
-7
View File
@@ -17,8 +17,6 @@ export type SlackDmConfig = {
groupEnabled?: boolean; groupEnabled?: boolean;
/** Optional allowlist for group DM channels (ids or slugs). */ /** Optional allowlist for group DM channels (ids or slugs). */
groupChannels?: Array<string | number>; groupChannels?: Array<string | number>;
/** @deprecated Prefer channels.slack.replyToModeByChatType.direct. */
replyToMode?: ReplyToMode;
}; };
export type SlackChannelConfig = { export type SlackChannelConfig = {
@@ -119,11 +117,6 @@ export type SlackAccountConfig = {
reactionAllowlist?: Array<string | number>; reactionAllowlist?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */ /** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode; replyToMode?: ReplyToMode;
/**
* Optional per-chat-type reply threading overrides.
* Example: { direct: "all", group: "first", channel: "off" }.
*/
replyToModeByChatType?: Partial<Record<"direct" | "group" | "channel", ReplyToMode>>;
/** Thread session behavior. */ /** Thread session behavior. */
thread?: SlackThreadConfig; thread?: SlackThreadConfig;
actions?: SlackActionConfig; actions?: SlackActionConfig;
-10
View File
@@ -248,7 +248,6 @@ export const SlackDmSchema = z
allowFrom: z.array(z.union([z.string(), z.number()])).optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(), groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(),
}) })
.strict() .strict()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
@@ -281,14 +280,6 @@ export const SlackThreadSchema = z
}) })
.strict(); .strict();
const SlackReplyToModeByChatTypeSchema = z
.object({
direct: ReplyToModeSchema.optional(),
group: ReplyToModeSchema.optional(),
channel: ReplyToModeSchema.optional(),
})
.strict();
export const SlackAccountSchema = z export const SlackAccountSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
@@ -316,7 +307,6 @@ export const SlackAccountSchema = z
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(), replyToMode: ReplyToModeSchema.optional(),
replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(),
thread: SlackThreadSchema.optional(), thread: SlackThreadSchema.optional(),
actions: z actions: z
.object({ .object({
@@ -36,7 +36,6 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
}); });
beforeEach(() => { beforeEach(() => {
vi.useRealTimers();
sendMock.mockReset().mockResolvedValue(undefined); sendMock.mockReset().mockResolvedValue(undefined);
updateLastRouteMock.mockReset(); updateLastRouteMock.mockReset();
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
+1 -2
View File
@@ -7,8 +7,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
const MAX_ASSISTANT_NAME = 50; const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200; const MAX_ASSISTANT_AVATAR = 200;
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = { export const DEFAULT_ASSISTANT_IDENTITY = {
agentId: "main",
name: "Assistant", name: "Assistant",
avatar: "A", avatar: "A",
}; };
+448 -361
View File
@@ -1,23 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js"; import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let enabledServer: Awaited<ReturnType<typeof startServer>>;
let enabledPort: number;
beforeAll(async () => {
enabledPort = await getFreePort();
enabledServer = await startServer(enabledPort);
});
afterAll(async () => {
await enabledServer.close({ reason: "openai http enabled suite done" });
});
async function startServerWithDefaultConfig(port: number) { async function startServerWithDefaultConfig(port: number) {
const { startGatewayServer } = await import("./server.js"); const { startGatewayServer } = await import("./server.js");
@@ -61,372 +49,471 @@ function parseSseDataLines(text: string): string[] {
} }
describe("OpenAI-compatible HTTP API (e2e)", () => { describe("OpenAI-compatible HTTP API (e2e)", () => {
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => { it("is disabled by default (requires config)", { timeout: 120_000 }, async () => {
{ const port = await getFreePort();
const port = await getFreePort(); const server = await startServerWithDefaultConfig(port);
const server = await startServerWithDefaultConfig(port); try {
try { const res = await postChatCompletions(port, {
const res = await postChatCompletions(port, { model: "clawdbot",
model: "clawdbot", messages: [{ role: "user", content: "hi" }],
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
}
{
const port = await getFreePort();
const server = await startServer(port, {
openAiChatCompletionsEnabled: false,
}); });
try { expect(res.status).toBe(404);
const res = await postChatCompletions(port, { } finally {
model: "clawdbot", await server.close({ reason: "test done" });
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
} }
}); });
it("handles request validation and routing", async () => { it("can be disabled via config (404)", async () => {
const port = enabledPort; const port = await getFreePort();
const mockAgentOnce = (payloads: Array<{ text: string }>) => { const server = await startServer(port, {
agentCommand.mockReset(); openAiChatCompletionsEnabled: false,
agentCommand.mockResolvedValueOnce({ payloads } as never); });
};
try { try {
{ const res = await postChatCompletions(port, {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { model: "clawdbot",
method: "GET", messages: [{ role: "user", content: "hi" }],
headers: { authorization: "Bearer secret" }, });
}); expect(res.status).toBe(404);
expect(res.status).toBe(405); } finally {
await res.text(); await server.close({ reason: "test done" });
} }
});
{ it("rejects non-POST", async () => {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { const port = await getFreePort();
method: "POST", const server = await startServer(port);
headers: { "content-type": "application/json" }, try {
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
}); method: "GET",
expect(res.status).toBe(401); headers: { authorization: "Bearer secret" },
await res.text(); });
} expect(res.status).toBe(405);
} finally {
await server.close({ reason: "test done" });
}
});
{ it("rejects missing auth", async () => {
mockAgentOnce([{ text: "hello" }]); const port = await getFreePort();
const res = await postChatCompletions( const server = await startServer(port);
port, try {
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] }, const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
{ "x-clawdbot-agent-id": "beta" }, method: "POST",
); headers: { "content-type": "application/json" },
expect(res.status).toBe(200); body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(res.status).toBe(401);
} finally {
await server.close({ reason: "test done" });
}
});
expect(agentCommand).toHaveBeenCalledTimes(1); it("routes to a specific agent via header", async () => {
const [opts] = agentCommand.mock.calls[0] ?? []; agentCommand.mockResolvedValueOnce({
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( payloads: [{ text: "hello" }],
/^agent:beta:/, } as never);
);
await res.text();
}
{ const port = await getFreePort();
mockAgentOnce([{ text: "hello" }]); const server = await startServer(port);
const res = await postChatCompletions(port, { try {
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{ "x-clawdbot-agent-id": "beta" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
it("routes to a specific agent via model (no custom headers)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
it("prefers explicit header agent over model agent", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(
port,
{
model: "clawdbot:beta", model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }], messages: [{ role: "user", content: "hi" }],
}); },
expect(res.status).toBe(200); { "x-clawdbot-agent-id": "alpha" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? []; const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/, /^agent:alpha:/,
); );
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
},
{ "x-clawdbot-agent-id": "alpha" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:alpha:/,
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{
"x-clawdbot-agent-id": "beta",
"x-clawdbot-session-key": "agent:beta:openai:custom",
},
);
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
"agent:beta:openai:custom",
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
user: "alice",
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openai-user:alice",
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "input_text", text: "world" },
],
},
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
await res.text();
}
{
mockAgentOnce([{ text: "I am Claude" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
await res.text();
}
{
mockAgentOnce([{ text: "ok" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
stream: false,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.object).toBe("chat.completion");
expect(Array.isArray(json.choices)).toBe(true);
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
expect(msg.role).toBe("assistant");
expect(msg.content).toBe("hello");
}
{
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "system", content: "yo" }],
});
expect(res.status).toBe(400);
const missingUserJson = (await res.json()) as Record<string, unknown>;
expect((missingUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
}
} finally { } finally {
// shared server await server.close({ reason: "test done" });
} }
}); });
it("streams SSE chunks when stream=true", async () => { it("honors x-clawdbot-session-key override", async () => {
const port = enabledPort; agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try { try {
{ const res = await postChatCompletions(
agentCommand.mockReset(); port,
agentCommand.mockImplementationOnce(async (opts: unknown) => { { model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; {
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } }); "x-clawdbot-agent-id": "beta",
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } }); "x-clawdbot-session-key": "agent:beta:openai:custom",
return { payloads: [{ text: "hello" }] } as never; },
}); );
expect(res.status).toBe(200);
const res = await postChatCompletions(port, { const [opts] = agentCommand.mock.calls[0] ?? [];
stream: true, expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
model: "clawdbot", "agent:beta:openai:custom",
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");
}
{
agentCommand.mockReset();
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
return { payloads: [{ text: "hihi" }] } as never;
});
const 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");
}
{
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const fallbackRes = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(fallbackRes.status).toBe(200);
const fallbackText = await fallbackRes.text();
expect(fallbackText).toContain("[DONE]");
expect(fallbackText).toContain("hello");
}
} finally { } finally {
// shared server await server.close({ reason: "test done" });
}
});
it("uses OpenAI user for a stable session key", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
user: "alice",
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openai-user:alice",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("extracts user message text from array content", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "input_text", text: "world" },
],
},
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
} finally {
await server.close({ reason: "test done" });
}
});
it("includes conversation history when multiple messages are provided", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "I am Claude" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
} finally {
await server.close({ reason: "test done" });
}
});
it("does not include history markers for single message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("treats developer role same as system role", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
} finally {
await server.close({ reason: "test done" });
}
});
it("includes tool output when it is the latest message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
} finally {
await server.close({ reason: "test done" });
}
});
it("returns a non-streaming OpenAI chat.completion response", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: false,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.object).toBe("chat.completion");
expect(Array.isArray(json.choices)).toBe(true);
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
expect(msg.role).toBe("assistant");
expect(msg.content).toBe("hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("requires a user message", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "system", content: "yo" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true (delta events)", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
return { payloads: [{ text: "hello" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
const text = await res.text();
const data = parseSseDataLines(text);
expect(data[data.length - 1]).toBe("[DONE]");
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("preserves repeated identical deltas when streaming SSE", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
return { payloads: [{ text: "hihi" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const text = await res.text();
const data = parseSseDataLines(text);
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hihi");
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true (fallback when no deltas)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain("[DONE]");
expect(text).toContain("hello");
} finally {
await server.close({ reason: "test done" });
} }
}); });
}); });
+11 -20
View File
@@ -1,23 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js"; import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let enabledServer: Awaited<ReturnType<typeof startServer>>;
let enabledPort: number;
beforeAll(async () => {
enabledPort = await getFreePort();
enabledServer = await startServer(enabledPort);
});
afterAll(async () => {
await enabledServer.close({ reason: "openresponses enabled suite done" });
});
async function startServerWithDefaultConfig(port: number) { async function startServerWithDefaultConfig(port: number) {
const { startGatewayServer } = await import("./server.js"); const { startGatewayServer } = await import("./server.js");
@@ -84,7 +72,7 @@ async function ensureResponseConsumed(res: Response) {
describe("OpenResponses HTTP API (e2e)", () => { describe("OpenResponses HTTP API (e2e)", () => {
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => { it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
const port = await getFreePort(); const port = await getFreePort();
const _server = await startServerWithDefaultConfig(port); const server = await startServerWithDefaultConfig(port);
try { try {
const res = await postResponses(port, { const res = await postResponses(port, {
model: "clawdbot", model: "clawdbot",
@@ -93,7 +81,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
await ensureResponseConsumed(res); await ensureResponseConsumed(res);
} finally { } finally {
// shared server await server.close({ reason: "test done" });
} }
const disabledPort = await getFreePort(); const disabledPort = await getFreePort();
@@ -113,7 +101,8 @@ describe("OpenResponses HTTP API (e2e)", () => {
}); });
it("handles OpenResponses request parsing and validation", async () => { it("handles OpenResponses request parsing and validation", async () => {
const port = enabledPort; const port = await getFreePort();
const server = await startServer(port);
const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => { const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => {
agentCommand.mockReset(); agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads, meta } as never); agentCommand.mockResolvedValueOnce({ payloads, meta } as never);
@@ -417,12 +406,14 @@ describe("OpenResponses HTTP API (e2e)", () => {
); );
await ensureResponseConsumed(resNoUser); await ensureResponseConsumed(resNoUser);
} finally { } finally {
// shared server await server.close({ reason: "test done" });
} }
}); });
it("streams OpenResponses SSE events", async () => { it("streams OpenResponses SSE events", async () => {
const port = enabledPort; const port = await getFreePort();
const server = await startServer(port);
try { try {
agentCommand.mockReset(); agentCommand.mockReset();
agentCommand.mockImplementationOnce(async (opts: unknown) => { agentCommand.mockImplementationOnce(async (opts: unknown) => {
@@ -498,7 +489,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(event.event).toBe(parsed.type); expect(event.event).toBe(parsed.type);
} }
} finally { } finally {
// shared server await server.close({ reason: "test done" });
} }
}); });
}); });
@@ -1,10 +1,9 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { import {
agentCommand, agentCommand,
connectOk, connectOk,
@@ -15,22 +14,7 @@ import {
writeSessionStore, writeSessionStore,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
const registryState = vi.hoisted(() => ({ const registryState = vi.hoisted(() => ({
registry: { registry: {
@@ -59,11 +43,6 @@ vi.mock("./server-plugins.js", async () => {
}; };
}); });
const setRegistry = (registry: PluginRegistry) => {
registryState.registry = registry;
setActivePluginRegistry(registry);
};
const BASE_IMAGE_PNG = const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII="; "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
@@ -163,7 +142,7 @@ const defaultRegistry = createRegistry([
describe("gateway server agent", () => { describe("gateway server agent", () => {
test("agent marks implicit delivery when lastTo is stale", async () => { test("agent marks implicit delivery when lastTo is stale", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
testState.allowFrom = ["+436769770569"]; testState.allowFrom = ["+436769770569"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -177,6 +156,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -192,11 +175,14 @@ describe("gateway server agent", () => {
expect(call.to).toBe("+1555"); expect(call.to).toBe("+1555");
expect(call.deliveryTargetMode).toBe("implicit"); expect(call.deliveryTargetMode).toBe("implicit");
expect(call.sessionId).toBe("sess-main-stale"); expect(call.sessionId).toBe("sess-main-stale");
ws.close();
await server.close();
testState.allowFrom = undefined; testState.allowFrom = undefined;
}); });
test("agent forwards sessionKey to agentCommand", async () => { test("agent forwards sessionKey to agentCommand", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -207,6 +193,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "agent:main:subagent:abc", sessionKey: "agent:main:subagent:abc",
@@ -221,10 +211,13 @@ describe("gateway server agent", () => {
expectChannels(call, "webchat"); expectChannels(call, "webchat");
expect(call.deliver).toBe(false); expect(call.deliver).toBe(false);
expect(call.to).toBeUndefined(); expect(call.to).toBeUndefined();
ws.close();
await server.close();
}); });
test("agent derives sessionKey from agentId", async () => { test("agent derives sessionKey from agentId", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
testState.agentsConfig = { list: [{ id: "ops" }] }; testState.agentsConfig = { list: [{ id: "ops" }] };
@@ -237,6 +230,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
agentId: "ops", agentId: "ops",
@@ -248,10 +245,16 @@ describe("gateway server agent", () => {
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionKey).toBe("agent:ops:main"); expect(call.sessionKey).toBe("agent:ops:main");
expect(call.sessionId).toBe("sess-ops"); expect(call.sessionId).toBe("sess-ops");
ws.close();
await server.close();
}); });
test("agent rejects unknown reply channel", async () => { test("agent rejects unknown reply channel", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
replyChannel: "unknown-channel", replyChannel: "unknown-channel",
@@ -262,11 +265,18 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
ws.close();
await server.close();
}); });
test("agent rejects mismatched agentId and sessionKey", async () => { test("agent rejects mismatched agentId and sessionKey", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
testState.agentsConfig = { list: [{ id: "ops" }] }; testState.agentsConfig = { list: [{ id: "ops" }] };
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
agentId: "ops", agentId: "ops",
@@ -278,10 +288,13 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
ws.close();
await server.close();
}); });
test("agent forwards accountId to agentCommand", async () => { test("agent forwards accountId to agentCommand", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -296,6 +309,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -312,11 +329,14 @@ describe("gateway server agent", () => {
expect(call.accountId).toBe("kev"); expect(call.accountId).toBe("kev");
const runContext = call.runContext as { accountId?: string } | undefined; const runContext = call.runContext as { accountId?: string } | undefined;
expect(runContext?.accountId).toBe("kev"); expect(runContext?.accountId).toBe("kev");
ws.close();
await server.close();
testState.allowFrom = undefined; testState.allowFrom = undefined;
}); });
test("agent avoids lastAccountId when explicit to is provided", async () => { test("agent avoids lastAccountId when explicit to is provided", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -331,6 +351,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -345,11 +369,14 @@ describe("gateway server agent", () => {
expectChannels(call, "whatsapp"); expectChannels(call, "whatsapp");
expect(call.to).toBe("+1666"); expect(call.to).toBe("+1666");
expect(call.accountId).toBeUndefined(); expect(call.accountId).toBeUndefined();
ws.close();
await server.close();
testState.allowFrom = undefined; testState.allowFrom = undefined;
}); });
test("agent keeps explicit accountId when explicit to is provided", async () => { test("agent keeps explicit accountId when explicit to is provided", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -364,6 +391,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -379,11 +410,14 @@ describe("gateway server agent", () => {
expectChannels(call, "whatsapp"); expectChannels(call, "whatsapp");
expect(call.to).toBe("+1666"); expect(call.to).toBe("+1666");
expect(call.accountId).toBe("primary"); expect(call.accountId).toBe("primary");
ws.close();
await server.close();
testState.allowFrom = undefined; testState.allowFrom = undefined;
}); });
test("agent falls back to lastAccountId for implicit delivery", async () => { test("agent falls back to lastAccountId for implicit delivery", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -398,6 +432,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -411,11 +449,14 @@ describe("gateway server agent", () => {
expectChannels(call, "whatsapp"); expectChannels(call, "whatsapp");
expect(call.to).toBe("+1555"); expect(call.to).toBe("+1555");
expect(call.accountId).toBe("kev"); expect(call.accountId).toBe("kev");
ws.close();
await server.close();
testState.allowFrom = undefined; testState.allowFrom = undefined;
}); });
test("agent forwards image attachments as images[]", async () => { test("agent forwards image attachments as images[]", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -426,6 +467,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "what is in the image?", message: "what is in the image?",
sessionKey: "main", sessionKey: "main",
@@ -452,10 +497,13 @@ describe("gateway server agent", () => {
expect(images[0]?.type).toBe("image"); expect(images[0]?.type).toBe("image");
expect(images[0]?.mimeType).toBe("image/png"); expect(images[0]?.mimeType).toBe("image/png");
expect(images[0]?.data).toBe(BASE_IMAGE_PNG); expect(images[0]?.data).toBe(BASE_IMAGE_PNG);
ws.close();
await server.close();
}); });
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => { test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -467,6 +515,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -481,11 +533,14 @@ describe("gateway server agent", () => {
expect(call.to).toBe("+1555"); expect(call.to).toBe("+1555");
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.sessionId).toBe("sess-main-missing-provider"); expect(call.sessionId).toBe("sess-main-missing-provider");
ws.close();
await server.close();
testState.allowFrom = undefined; testState.allowFrom = undefined;
}); });
test("agent routes main last-channel whatsapp", async () => { test("agent routes main last-channel whatsapp", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -498,6 +553,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -515,10 +574,13 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-main-whatsapp"); expect(call.sessionId).toBe("sess-main-whatsapp");
ws.close();
await server.close();
}); });
test("agent routes main last-channel telegram", async () => { test("agent routes main last-channel telegram", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -531,6 +593,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -547,10 +613,13 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-main"); expect(call.sessionId).toBe("sess-main");
ws.close();
await server.close();
}); });
test("agent routes main last-channel discord", async () => { test("agent routes main last-channel discord", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -563,6 +632,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -579,10 +652,13 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-discord"); expect(call.sessionId).toBe("sess-discord");
ws.close();
await server.close();
}); });
test("agent routes main last-channel slack", async () => { test("agent routes main last-channel slack", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -595,6 +671,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -611,10 +691,13 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-slack"); expect(call.sessionId).toBe("sess-slack");
ws.close();
await server.close();
}); });
test("agent routes main last-channel signal", async () => { test("agent routes main last-channel signal", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@@ -627,6 +710,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -643,5 +730,8 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-signal"); expect(call.sessionId).toBe("sess-signal");
ws.close();
await server.close();
}); });
}); });
@@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { ChannelPlugin } from "../channels/plugins/types.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
@@ -22,24 +22,7 @@ import {
writeSessionStore, writeSessionStore,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
const registryState = vi.hoisted(() => ({ const registryState = vi.hoisted(() => ({
registry: { registry: {
@@ -147,6 +130,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -163,6 +150,9 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-teams"); expect(call.sessionId).toBe("sess-teams");
ws.close();
await server.close();
}); });
test("agent accepts channel aliases (imsg/teams)", async () => { test("agent accepts channel aliases (imsg/teams)", async () => {
@@ -187,6 +177,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const resIMessage = await rpcReq(ws, "agent", { const resIMessage = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -214,9 +208,15 @@ describe("gateway server agent", () => {
const lastTeamsCall = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const lastTeamsCall = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectChannels(lastTeamsCall, "msteams"); expectChannels(lastTeamsCall, "msteams");
expect(lastTeamsCall.to).toBe("conversation:teams-abc"); expect(lastTeamsCall.to).toBe("conversation:teams-abc");
ws.close();
await server.close();
}); });
test("agent rejects unknown channel", async () => { test("agent rejects unknown channel", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -225,6 +225,9 @@ describe("gateway server agent", () => {
}); });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(res.error?.code).toBe("INVALID_REQUEST"); expect(res.error?.code).toBe("INVALID_REQUEST");
ws.close();
await server.close();
}); });
test("agent ignores webchat last-channel for routing", async () => { test("agent ignores webchat last-channel for routing", async () => {
@@ -241,6 +244,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -257,6 +264,9 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-main-webchat"); expect(call.sessionId).toBe("sess-main-webchat");
ws.close();
await server.close();
}); });
test("agent uses webchat for internal runs when last provider is webchat", async () => { test("agent uses webchat for internal runs when last provider is webchat", async () => {
@@ -272,6 +282,10 @@ describe("gateway server agent", () => {
}, },
}, },
}); });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
@@ -288,9 +302,15 @@ describe("gateway server agent", () => {
expect(call.deliver).toBe(false); expect(call.deliver).toBe(false);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-main-webchat-internal"); expect(call.sessionId).toBe("sess-main-webchat-internal");
ws.close();
await server.close();
}); });
test("agent ack response then final response", { timeout: 8000 }, async () => { test("agent ack response then final response", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const ackP = onceMessage( const ackP = onceMessage(
ws, ws,
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted", (o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted",
@@ -313,9 +333,15 @@ describe("gateway server agent", () => {
expect(ack.payload.runId).toBeDefined(); expect(ack.payload.runId).toBeDefined();
expect(final.payload.runId).toBe(ack.payload.runId); expect(final.payload.runId).toBe(ack.payload.runId);
expect(final.payload.status).toBe("ok"); expect(final.payload.status).toBe("ok");
ws.close();
await server.close();
}); });
test("agent dedupes by idempotencyKey after completion", async () => { test("agent dedupes by idempotencyKey after completion", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const firstFinalP = onceMessage( const firstFinalP = onceMessage(
ws, ws,
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
@@ -341,6 +367,9 @@ describe("gateway server agent", () => {
); );
const second = await secondP; const second = await secondP;
expect(second.payload).toEqual(firstFinal.payload); expect(second.payload).toEqual(firstFinal.payload);
ws.close();
await server.close();
}); });
test("agent dedupe survives reconnect", { timeout: 60_000 }, async () => { test("agent dedupe survives reconnect", { timeout: 60_000 }, async () => {
@@ -404,9 +433,8 @@ describe("gateway server agent", () => {
}, },
}); });
const webchatWs = new WebSocket(`ws://127.0.0.1:${port}`); const { server, ws } = await startServerWithClient();
await new Promise<void>((resolve) => webchatWs.once("open", resolve)); await connectOk(ws, {
await connectOk(webchatWs, {
client: { client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT, id: GATEWAY_CLIENT_NAMES.WEBCHAT,
version: "1.0.0", version: "1.0.0",
@@ -418,7 +446,7 @@ describe("gateway server agent", () => {
registerAgentRunContext("run-auto-1", { sessionKey: "main" }); registerAgentRunContext("run-auto-1", { sessionKey: "main" });
const finalChatP = onceMessage( const finalChatP = onceMessage(
webchatWs, ws,
(o) => { (o) => {
if (o.type !== "event" || o.event !== "chat") return false; if (o.type !== "event" || o.event !== "chat") return false;
const payload = o.payload as { state?: unknown; runId?: unknown } | undefined; const payload = o.payload as { state?: unknown; runId?: unknown } | undefined;
@@ -446,6 +474,7 @@ describe("gateway server agent", () => {
expect(payload.sessionKey).toBe("main"); expect(payload.sessionKey).toBe("main");
expect(payload.runId).toBe("run-auto-1"); expect(payload.runId).toBe("run-auto-1");
webchatWs.close(); ws.close();
await server.close();
}); });
}); });
@@ -0,0 +1,200 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
installGatewayTestHooks,
onceMessage,
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
const _BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
function _expectChannels(call: Record<string, unknown>, channel: string) {
expect(call.channel).toBe(channel);
expect(call.messageChannel).toBe(channel);
}
describe("gateway server agent", () => {
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
registerAgentRunContext("run-tool-1", {
sessionKey: "main",
verboseLevel: "on",
});
{
const agentEvtP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1",
8000,
);
emitAgentEvent({
runId: "run-tool-1",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
const evt = await agentEvtP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
}
{
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
emitAgentEvent({
runId: "run-tool-off",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
emitAgentEvent({
runId: "run-tool-off",
stream: "assistant",
data: { text: "hello" },
});
const evt = await onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
8000,
);
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.stream).toBe("assistant");
}
{
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-1",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-1",
stream: "lifecycle",
data: { phase: "end", startedAt: 200, endedAt: 210 },
});
}, 5);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(200);
}
{
emitAgentEvent({
runId: "run-wait-early",
stream: "lifecycle",
data: { phase: "end", startedAt: 50, endedAt: 55 },
});
const res = await rpcReq(ws, "agent.wait", {
runId: "run-wait-early",
timeoutMs: 1000,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(50);
}
{
const res = await rpcReq(ws, "agent.wait", {
runId: "run-wait-3",
timeoutMs: 30,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("timeout");
}
{
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-err",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-err",
stream: "lifecycle",
data: { phase: "error", error: "boom" },
});
}, 5);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("error");
expect(res.payload.error).toBe("boom");
}
{
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-start",
timeoutMs: 1000,
});
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "start", startedAt: 123 },
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "end", endedAt: 456 },
});
}, 5);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(123);
expect(res.payload.endedAt).toBe(456);
}
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.sessionStorePath = undefined;
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
testState,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway server agents", () => {
test("lists configured agents via agents.list RPC", async () => {
testState.agentsConfig = {
list: [
{ id: "work", name: "Work", default: true },
{ id: "home", name: "Home" },
],
};
const { ws } = await startServerWithClient();
const hello = await connectOk(ws);
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
expect.arrayContaining(["agents.list"]),
);
const res = await rpcReq<{
defaultId: string;
mainKey: string;
scope: string;
agents: Array<{ id: string; name?: string }>;
}>(ws, "agents.list", {});
expect(res.ok).toBe(true);
expect(res.payload?.defaultId).toBe("work");
expect(res.payload?.mainKey).toBe("main");
expect(res.payload?.scope).toBe("per-sender");
expect(res.payload?.agents.map((agent) => agent.id)).toEqual(["work", "home", "main"]);
const work = res.payload?.agents.find((agent) => agent.id === "work");
const home = res.payload?.agents.find((agent) => agent.id === "home");
expect(work?.name).toBe("Work");
expect(home?.name).toBe("Home");
});
});
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from "./test-helpers.js"; } from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean> { async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean> {
if (ws.readyState === WebSocket.CLOSED) return true; if (ws.readyState === WebSocket.CLOSED) return true;
+33 -24
View File
@@ -1,7 +1,6 @@
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { import {
connectOk, connectOk,
installGatewayTestHooks, installGatewayTestHooks,
@@ -11,7 +10,7 @@ import {
const loadConfigHelpers = async () => await import("../config/config.js"); const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
const registryState = vi.hoisted(() => ({ const registryState = vi.hoisted(() => ({
registry: { registry: {
@@ -132,31 +131,30 @@ const defaultRegistry = createRegistry([
}, },
]); ]);
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"]; const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
beforeAll(async () => { afterEach(async () => {
setRegistry(defaultRegistry); for (const { server, ws } of servers) {
const started = await startServerWithClient(); try {
server = started.server; ws.close();
ws = started.ws; await server.close();
await connectOk(ws); } catch {
/* ignore */
}
}
servers.length = 0;
await new Promise((resolve) => setTimeout(resolve, 50));
}); });
afterAll(async () => {
ws.close();
await server.close();
});
function setRegistry(registry: PluginRegistry) {
registryState.registry = registry;
setActivePluginRegistry(registry);
}
describe("gateway server channels", () => { describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => { test("channels.status returns snapshot without probe", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const res = await rpcReq<{ const res = await rpcReq<{
channels?: Record< channels?: Record<
string, string,
@@ -183,7 +181,12 @@ describe("gateway server channels", () => {
}); });
test("channels.logout reports no session when missing", async () => { test("channels.logout reports no session when missing", async () => {
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", { const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", {
channel: "whatsapp", channel: "whatsapp",
}); });
@@ -194,7 +197,7 @@ describe("gateway server channels", () => {
test("channels.logout clears telegram bot token from config", async () => { test("channels.logout clears telegram bot token from config", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
setRegistry(defaultRegistry); registryState.registry = defaultRegistry;
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers(); const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({ await writeConfigFile({
channels: { channels: {
@@ -204,6 +207,12 @@ describe("gateway server channels", () => {
}, },
}, },
}); });
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const res = await rpcReq<{ const res = await rpcReq<{
cleared?: boolean; cleared?: boolean;
envToken?: boolean; envToken?: boolean;
@@ -14,7 +14,7 @@ import {
testState, testState,
writeSessionStore, writeSessionStore,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
async function waitFor(condition: () => boolean, timeoutMs = 1500) { async function waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
@@ -38,88 +38,355 @@ const sendReq = (
}), }),
); );
}; };
const withSessionStore = async <T>(
tempDirs: string[],
entries: Record<
string,
{ sessionId: string; updatedAt: number; lastChannel?: string; lastTo?: string }
>,
fn: (dir: string) => Promise<T>,
): Promise<T> => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(dir);
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ entries });
try {
return await fn(dir);
} finally {
testState.sessionStorePath = undefined;
}
};
describe("gateway server chat", () => { describe("gateway server chat", () => {
const timeoutMs = process.platform === "win32" ? 120_000 : 60_000; test("handles history, abort, idempotency, and ordering flows", { timeout: 60_000 }, async () => {
test( const tempDirs: string[] = [];
"handles history, abort, idempotency, and ordering flows", const { server, ws } = await startServerWithClient();
{ timeout: timeoutMs }, const spy = vi.mocked(agentCommand);
async () => { const resetSpy = () => {
const tempDirs: string[] = []; spy.mockReset();
const { server, ws } = await startServerWithClient(); spy.mockResolvedValue(undefined);
const spy = vi.mocked(agentCommand); };
const resetSpy = () => { try {
spy.mockReset(); await connectOk(ws);
spy.mockResolvedValue(undefined); await withSessionStore(
}; tempDirs,
try { { main: { sessionId: "sess-main", updatedAt: Date.now() } },
await connectOk(ws); async (historyDir) => {
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const bigText = "x".repeat(200_000);
tempDirs.push(sessionDir); const largeLines: string[] = [];
testState.sessionStorePath = path.join(sessionDir, "sessions.json"); for (let i = 0; i < 40; i += 1) {
const writeStore = async ( largeLines.push(
entries: Record< JSON.stringify({
string, message: {
{ sessionId: string; updatedAt: number; lastChannel?: string; lastTo?: string } role: "user",
>, content: [{ type: "text", text: `${i}:${bigText}` }],
) => { timestamp: Date.now() + i,
await writeSessionStore({ entries }); },
}; }),
);
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } }); }
const bigText = "x".repeat(155_000); await fs.writeFile(
const largeLines: string[] = []; path.join(historyDir, "sess-main.jsonl"),
for (let i = 0; i < 40; i += 1) { largeLines.join("\n"),
largeLines.push( "utf-8",
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `${i}:${bigText}` }],
timestamp: Date.now() + i,
},
}),
); );
} const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
await fs.writeFile( sessionKey: "main",
path.join(sessionDir, "sess-main.jsonl"), limit: 1000,
largeLines.join("\n"), });
"utf-8", expect(cappedRes.ok).toBe(true);
); const cappedMsgs = cappedRes.payload?.messages ?? [];
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
sessionKey: "main", expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
limit: 1000, expect(cappedMsgs.length).toBeLessThan(60);
}); },
expect(cappedRes.ok).toBe(true); );
const cappedMsgs = cappedRes.payload?.messages ?? []; await withSessionStore(
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8"); tempDirs,
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024); {
expect(cappedMsgs.length).toBeLessThan(60);
await writeStore({
main: { main: {
sessionId: "sess-main", sessionId: "sess-main",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp", lastChannel: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
}); },
const routeRes = await rpcReq(ws, "chat.send", { async () => {
const routeRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-route",
});
expect(routeRes.ok).toBe(true);
const stored = JSON.parse(
await fs.readFile(testState.sessionStorePath as string, "utf-8"),
) as Record<string, { lastChannel?: string; lastTo?: string } | undefined>;
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
expect(stored["agent:main:main"]?.lastTo).toBe("+1555");
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
resetSpy();
let abortInFlight: Promise<unknown> | undefined;
try {
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-abort-1",
8000,
);
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000);
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
8000,
);
abortInFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
sendReq(ws, "send-abort-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-1",
timeoutMs: 30_000,
});
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
await new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline)
return reject(new Error("timeout waiting for agentCommand"));
setTimeout(tick, 5);
};
tick();
});
sendReq(ws, "abort-1", "chat.abort", {
sessionKey: "main",
runId: "idem-abort-1",
});
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
await abortInFlight;
}
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
sessionStoreSaveDelayMs.value = 120;
resetSpy();
try {
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
);
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-abort-save-1",
);
sendReq(ws, "send-abort-save-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-save-1",
timeoutMs: 30_000,
});
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1");
sendReq(ws, "abort-save-1", "chat.abort", {
sessionKey: "main",
runId: "idem-abort-save-1",
});
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-save-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
sessionStoreSaveDelayMs.value = 0;
}
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
resetSpy();
const callsBeforeStop = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const stopSendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-stop-1",
8000,
);
sendReq(ws, "send-stop-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-stop-run",
});
const stopSendRes = await stopSendResP;
expect(stopSendRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeStop);
const abortedStopEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-stop-run",
8000,
);
const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000);
sendReq(ws, "send-stop-2", "chat.send", {
sessionKey: "main",
message: "/stop",
idempotencyKey: "idem-stop-req",
});
const stopRes = await stopResP;
expect(stopRes.ok).toBe(true);
const stopEvt = await abortedStopEventP;
expect(stopEvt.payload?.sessionKey).toBe("main");
expect(spy.mock.calls.length).toBe(callsBeforeStop + 1);
},
);
resetSpy();
let resolveRun: (() => void) | undefined;
const runDone = new Promise<void>((resolve) => {
resolveRun = resolve;
});
spy.mockImplementationOnce(async () => {
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(started.ok).toBe(true);
expect(started.payload?.status).toBe("started");
const inFlightRes = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(inFlightRes.ok).toBe(true);
expect(inFlightRes.payload?.status).toBe("in_flight");
resolveRun?.();
let completed = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main", sessionKey: "main",
message: "hello", message: "hello",
idempotencyKey: "idem-route", idempotencyKey: "idem-status-1",
}); });
expect(routeRes.ok).toBe(true); if (again.ok && again.payload?.status === "ok") {
const stored = JSON.parse( completed = true;
await fs.readFile(testState.sessionStorePath as string, "utf-8"), break;
) as Record<string, { lastChannel?: string; lastTo?: string } | undefined>; }
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp"); await new Promise((r) => setTimeout(r, 10));
expect(stored["agent:main:main"]?.lastTo).toBe("+1555"); }
expect(completed).toBe(true);
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } }); resetSpy();
resetSpy(); spy.mockImplementationOnce(async (opts) => {
let abortInFlight: Promise<unknown> | undefined; const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
try { await new Promise<void>((resolve) => {
const callsBefore = spy.mock.calls.length; if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-abort-all-1",
);
const startedAbortAll = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-all-1",
});
expect(startedAbortAll.ok).toBe(true);
const abortRes = await rpcReq<{
ok?: boolean;
aborted?: boolean;
runIds?: string[];
}>(ws, "chat.abort", { sessionKey: "main" });
expect(abortRes.ok).toBe(true);
expect(abortRes.payload?.aborted).toBe(true);
expect(abortRes.payload?.runIds ?? []).toContain("idem-abort-all-1");
await abortedEventP;
const noDeltaP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
(o.payload?.state === "delta" || o.payload?.state === "final") &&
o.payload?.runId === "idem-abort-all-1",
250,
);
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "assistant",
data: { text: "should be suppressed" },
});
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "lifecycle",
data: { phase: "end" },
});
await expect(noDeltaP).rejects.toThrow(/timeout/i);
await withSessionStore(tempDirs, {}, async () => {
const abortUnknown = await rpcReq<{
ok?: boolean;
aborted?: boolean;
}>(ws, "chat.abort", { sessionKey: "main", runId: "missing-run" });
expect(abortUnknown.ok).toBe(true);
expect(abortUnknown.payload?.aborted).toBe(false);
});
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
resetSpy();
let agentStartedResolve: (() => void) | undefined;
const agentStartedP = new Promise<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => { spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (!signal) return resolve(); if (!signal) return resolve();
@@ -129,356 +396,127 @@ describe("gateway server chat", () => {
}); });
const sendResP = onceMessage( const sendResP = onceMessage(
ws, ws,
(o) => o.type === "res" && o.id === "send-abort-1", (o) => o.type === "res" && o.id === "send-mismatch-1",
8000, 10_000,
); );
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000); sendReq(ws, "send-mismatch-1", "chat.send", {
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
8000,
);
abortInFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
sendReq(ws, "send-abort-1", "chat.send", {
sessionKey: "main", sessionKey: "main",
message: "hello", message: "hello",
idempotencyKey: "idem-abort-1", idempotencyKey: "idem-mismatch-1",
timeoutMs: 30_000, timeoutMs: 30_000,
}); });
await agentStartedP;
const abortMismatch = await rpcReq(ws, "chat.abort", {
sessionKey: "other",
runId: "idem-mismatch-1",
});
expect(abortMismatch.ok).toBe(false);
expect(abortMismatch.error?.code).toBe("INVALID_REQUEST");
const abortMismatch2 = await rpcReq(ws, "chat.abort", {
sessionKey: "main",
runId: "idem-mismatch-1",
});
expect(abortMismatch2.ok).toBe(true);
const sendRes = await sendResP; const sendRes = await sendResP;
expect(sendRes.ok).toBe(true); expect(sendRes.ok).toBe(true);
await new Promise<void>((resolve, reject) => { },
const deadline = Date.now() + 1000; );
const tick = () => { await withSessionStore(
if (spy.mock.calls.length > callsBefore) return resolve(); tempDirs,
if (Date.now() > deadline) { main: { sessionId: "sess-main", updatedAt: Date.now() } },
return reject(new Error("timeout waiting for agentCommand")); async () => {
setTimeout(tick, 5); resetSpy();
}; spy.mockResolvedValueOnce(undefined);
tick(); sendReq(ws, "send-complete-1", "chat.send", {
});
sendReq(ws, "abort-1", "chat.abort", {
sessionKey: "main",
runId: "idem-abort-1",
});
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
await abortInFlight;
}
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
sessionStoreSaveDelayMs.value = 120;
resetSpy();
try {
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
);
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-save-1");
sendReq(ws, "send-abort-save-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-save-1",
timeoutMs: 30_000,
});
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1");
sendReq(ws, "abort-save-1", "chat.abort", {
sessionKey: "main",
runId: "idem-abort-save-1",
});
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-save-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
sessionStoreSaveDelayMs.value = 0;
}
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
resetSpy();
const callsBeforeStop = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const stopSendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-stop-1",
8000,
);
sendReq(ws, "send-stop-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-stop-run",
});
const stopSendRes = await stopSendResP;
expect(stopSendRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeStop);
const abortedStopEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-stop-run",
8000,
);
const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000);
sendReq(ws, "send-stop-2", "chat.send", {
sessionKey: "main",
message: "/stop",
idempotencyKey: "idem-stop-req",
});
const stopRes = await stopResP;
expect(stopRes.ok).toBe(true);
const stopEvt = await abortedStopEventP;
expect(stopEvt.payload?.sessionKey).toBe("main");
expect(spy.mock.calls.length).toBe(callsBeforeStop + 1);
resetSpy();
let resolveRun: (() => void) | undefined;
const runDone = new Promise<void>((resolve) => {
resolveRun = resolve;
});
spy.mockImplementationOnce(async () => {
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(started.ok).toBe(true);
expect(started.payload?.status).toBe("started");
const inFlightRes = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(inFlightRes.ok).toBe(true);
expect(inFlightRes.payload?.status).toBe("in_flight");
resolveRun?.();
let completed = false;
for (let i = 0; i < 20; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
if (again.ok && again.payload?.status === "ok") {
completed = true;
break;
}
await new Promise((r) => setTimeout(r, 10));
}
expect(completed).toBe(true);
resetSpy();
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-abort-all-1",
);
const startedAbortAll = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-all-1",
});
expect(startedAbortAll.ok).toBe(true);
const abortRes = await rpcReq<{
ok?: boolean;
aborted?: boolean;
runIds?: string[];
}>(ws, "chat.abort", { sessionKey: "main" });
expect(abortRes.ok).toBe(true);
expect(abortRes.payload?.aborted).toBe(true);
expect(abortRes.payload?.runIds ?? []).toContain("idem-abort-all-1");
await abortedEventP;
const noDeltaP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
(o.payload?.state === "delta" || o.payload?.state === "final") &&
o.payload?.runId === "idem-abort-all-1",
250,
);
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "assistant",
data: { text: "should be suppressed" },
});
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "lifecycle",
data: { phase: "end" },
});
await expect(noDeltaP).rejects.toThrow(/timeout/i);
await writeStore({});
const abortUnknown = await rpcReq<{
ok?: boolean;
aborted?: boolean;
}>(ws, "chat.abort", { sessionKey: "main", runId: "missing-run" });
expect(abortUnknown.ok).toBe(true);
expect(abortUnknown.payload?.aborted).toBe(false);
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
resetSpy();
let agentStartedResolve: (() => void) | undefined;
const agentStartedP = new Promise<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-mismatch-1",
10_000,
);
sendReq(ws, "send-mismatch-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-mismatch-1",
timeoutMs: 30_000,
});
await agentStartedP;
const abortMismatch = await rpcReq(ws, "chat.abort", {
sessionKey: "other",
runId: "idem-mismatch-1",
});
expect(abortMismatch.ok).toBe(false);
expect(abortMismatch.error?.code).toBe("INVALID_REQUEST");
const abortMismatch2 = await rpcReq(ws, "chat.abort", {
sessionKey: "main",
runId: "idem-mismatch-1",
});
expect(abortMismatch2.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
resetSpy();
spy.mockResolvedValueOnce(undefined);
sendReq(ws, "send-complete-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
});
const sendCompleteRes = await onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-complete-1",
);
expect(sendCompleteRes.ok).toBe(true);
let completedRun = false;
for (let i = 0; i < 20; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main", sessionKey: "main",
message: "hello", message: "hello",
idempotencyKey: "idem-complete-1", idempotencyKey: "idem-complete-1",
timeoutMs: 30_000, timeoutMs: 30_000,
}); });
if (again.ok && again.payload?.status === "ok") { const sendCompleteRes = await onceMessage(
completedRun = true; ws,
break; (o) => o.type === "res" && o.id === "send-complete-1",
);
expect(sendCompleteRes.ok).toBe(true);
let completedRun = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
});
if (again.ok && again.payload?.status === "ok") {
completedRun = true;
break;
}
await new Promise((r) => setTimeout(r, 10));
} }
await new Promise((r) => setTimeout(r, 10)); expect(completedRun).toBe(true);
} const abortCompleteRes = await rpcReq(ws, "chat.abort", {
expect(completedRun).toBe(true); sessionKey: "main",
const abortCompleteRes = await rpcReq(ws, "chat.abort", { runId: "idem-complete-1",
sessionKey: "main", });
runId: "idem-complete-1", expect(abortCompleteRes.ok).toBe(true);
}); expect(abortCompleteRes.payload?.aborted).toBe(false);
expect(abortCompleteRes.ok).toBe(true); },
expect(abortCompleteRes.payload?.aborted).toBe(false); );
await withSessionStore(
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } }); tempDirs,
const res1 = await rpcReq(ws, "chat.send", { { main: { sessionId: "sess-main", updatedAt: Date.now() } },
sessionKey: "main", async () => {
message: "first", const res1 = await rpcReq(ws, "chat.send", {
idempotencyKey: "idem-1", sessionKey: "main",
}); message: "first",
expect(res1.ok).toBe(true); idempotencyKey: "idem-1",
const res2 = await rpcReq(ws, "chat.send", { });
sessionKey: "main", expect(res1.ok).toBe(true);
message: "second", const res2 = await rpcReq(ws, "chat.send", {
idempotencyKey: "idem-2", sessionKey: "main",
}); message: "second",
expect(res2.ok).toBe(true); idempotencyKey: "idem-2",
const final1P = onceMessage( });
ws, expect(res2.ok).toBe(true);
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final", const final1P = onceMessage(
8000, ws,
); (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
emitAgentEvent({ 8000,
runId: "idem-1", );
stream: "lifecycle", emitAgentEvent({
data: { phase: "end" }, runId: "idem-1",
}); stream: "lifecycle",
const final1 = await final1P; data: { phase: "end" },
const run1 = });
final1.payload && typeof final1.payload === "object" const final1 = await final1P;
? (final1.payload as { runId?: string }).runId const run1 =
: undefined; final1.payload && typeof final1.payload === "object"
expect(run1).toBe("idem-1"); ? (final1.payload as { runId?: string }).runId
const final2P = onceMessage( : undefined;
ws, expect(run1).toBe("idem-1");
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final", const final2P = onceMessage(
8000, ws,
); (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
emitAgentEvent({ 8000,
runId: "idem-2", );
stream: "lifecycle", emitAgentEvent({
data: { phase: "end" }, runId: "idem-2",
}); stream: "lifecycle",
const final2 = await final2P; data: { phase: "end" },
const run2 = });
final2.payload && typeof final2.payload === "object" const final2 = await final2P;
? (final2.payload as { runId?: string }).runId const run2 =
: undefined; final2.payload && typeof final2.payload === "object"
expect(run2).toBe("idem-2"); ? (final2.payload as { runId?: string }).runId
} finally { : undefined;
testState.sessionStorePath = undefined; expect(run2).toBe("idem-2");
sessionStoreSaveDelayMs.value = 0; },
ws.close(); );
await server.close(); } finally {
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); testState.sessionStorePath = undefined;
} sessionStoreSaveDelayMs.value = 0;
}, ws.close();
); await server.close();
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
}
});
}); });
@@ -1,10 +1,9 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import { import {
agentCommand, agentCommand,
connectOk, connectOk,
@@ -16,24 +15,7 @@ import {
writeSessionStore, writeSessionStore,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: WebSocket;
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
async function waitFor(condition: () => boolean, timeoutMs = 1500) { async function waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@@ -47,9 +29,12 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
describe("gateway server chat", () => { describe("gateway server chat", () => {
test("handles chat send and history flows", async () => { test("handles chat send and history flows", async () => {
const tempDirs: string[] = []; const tempDirs: string[] = [];
const { server, ws, port } = await startServerWithClient();
let webchatWs: WebSocket | undefined; let webchatWs: WebSocket | undefined;
try { try {
await connectOk(ws);
webchatWs = new WebSocket(`ws://127.0.0.1:${port}`); webchatWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => webchatWs?.once("open", resolve)); await new Promise<void>((resolve) => webchatWs?.once("open", resolve));
await connectOk(webchatWs, { await connectOk(webchatWs, {
@@ -255,182 +240,9 @@ describe("gateway server chat", () => {
testState.sessionStorePath = undefined; testState.sessionStorePath = undefined;
testState.sessionConfig = undefined; testState.sessionConfig = undefined;
if (webchatWs) webchatWs.close(); if (webchatWs) webchatWs.close();
ws.close();
await server.close();
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
} }
}); });
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
},
},
});
const webchatWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => webchatWs.once("open", resolve));
await connectOk(webchatWs, {
client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
try {
registerAgentRunContext("run-tool-1", {
sessionKey: "main",
verboseLevel: "on",
});
{
const agentEvtP = onceMessage(
webchatWs,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1",
8000,
);
emitAgentEvent({
runId: "run-tool-1",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
const evt = await agentEvtP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
}
{
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
emitAgentEvent({
runId: "run-tool-off",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
emitAgentEvent({
runId: "run-tool-off",
stream: "assistant",
data: { text: "hello" },
});
const evt = await onceMessage(
webchatWs,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
8000,
);
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.stream).toBe("assistant");
}
{
const waitP = rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-1",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-1",
stream: "lifecycle",
data: { phase: "end", startedAt: 200, endedAt: 210 },
});
}, 5);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(200);
}
{
emitAgentEvent({
runId: "run-wait-early",
stream: "lifecycle",
data: { phase: "end", startedAt: 50, endedAt: 55 },
});
const res = await rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-early",
timeoutMs: 1000,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(50);
}
{
const res = await rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-3",
timeoutMs: 30,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("timeout");
}
{
const waitP = rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-err",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-err",
stream: "lifecycle",
data: { phase: "error", error: "boom" },
});
}, 5);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("error");
expect(res.payload.error).toBe("boom");
}
{
const waitP = rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-start",
timeoutMs: 1000,
});
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "start", startedAt: 123 },
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "end", endedAt: 456 },
});
}, 5);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(123);
expect(res.payload.endedAt).toBe(456);
}
} finally {
webchatWs.close();
await fs.rm(dir, { recursive: true, force: true });
testState.sessionStorePath = undefined;
}
});
}); });
+71 -82
View File
@@ -1,106 +1,95 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { import {
connectOk, connectOk,
getFreePort,
installGatewayTestHooks, installGatewayTestHooks,
onceMessage, onceMessage,
startGatewayServer, startServerWithClient,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startGatewayServer>>; const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
let port = 0;
let previousToken: string | undefined;
beforeAll(async () => { afterEach(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN; for (const { server, ws } of servers) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN; try {
port = await getFreePort(); ws.close();
server = await startGatewayServer(port); await server.close();
} catch {
/* ignore */
}
}
servers.length = 0;
await new Promise((resolve) => setTimeout(resolve, 50));
}); });
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws);
return ws;
};
describe("gateway config.apply", () => { describe("gateway config.apply", () => {
it("writes config, stores sentinel, and schedules restart", async () => { it("writes config, stores sentinel, and schedules restart", async () => {
const ws = await openClient(); const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const id = "req-1";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true);
// Verify sentinel file was created (restart was scheduled)
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
// Wait for file to be written
await new Promise((resolve) => setTimeout(resolve, 100));
try { try {
const id = "req-1"; const raw = await fs.readFile(sentinelPath, "utf-8");
ws.send( const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
JSON.stringify({ expect(parsed.payload?.kind).toBe("config-apply");
type: "req", } catch {
id, // File may not exist if signal delivery is mocked, verify response was ok instead
method: "config.apply",
params: {
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
// Verify sentinel file was created (restart was scheduled)
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
// Wait for file to be written
await new Promise((resolve) => setTimeout(resolve, 100));
try {
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");
} catch {
// File may not exist if signal delivery is mocked, verify response was ok instead
expect(res.ok).toBe(true);
}
} finally {
ws.close();
} }
}); });
it("rejects invalid raw config", async () => { it("rejects invalid raw config", async () => {
const ws = await openClient(); const result = await startServerWithClient();
try { servers.push(result);
const id = "req-2"; const { ws } = result;
ws.send( await connectOk(ws);
JSON.stringify({
type: "req", const id = "req-2";
id, ws.send(
method: "config.apply", JSON.stringify({
params: { type: "req",
raw: "{", id,
}, method: "config.apply",
}), params: {
); raw: "{",
const res = await onceMessage<{ ok: boolean; error?: unknown }>( },
ws, }),
(o) => o.type === "res" && o.id === id, );
); const res = await onceMessage<{ ok: boolean; error?: unknown }>(
expect(res.ok).toBe(false); ws,
} finally { (o) => o.type === "res" && o.id === id,
ws.close(); );
} expect(res.ok).toBe(false);
}); });
}); });
+19 -125
View File
@@ -1,7 +1,4 @@
import fs from "node:fs/promises"; import { describe, expect, it } from "vitest";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveConfigSnapshotHash } from "../config/config.js"; import { resolveConfigSnapshotHash } from "../config/config.js";
@@ -9,31 +6,16 @@ import {
connectOk, connectOk,
installGatewayTestHooks, installGatewayTestHooks,
onceMessage, onceMessage,
rpcReq,
startServerWithClient, startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
describe("gateway config.patch", () => { describe("gateway config.patch", () => {
it("merges patches without clobbering unrelated config", async () => { it("merges patches without clobbering unrelated config", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const setId = "req-set"; const setId = "req-set";
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -118,9 +100,15 @@ describe("gateway config.patch", () => {
expect(get2Res.ok).toBe(true); expect(get2Res.ok).toBe(true);
expect(get2Res.payload?.config?.gateway?.mode).toBe("local"); expect(get2Res.payload?.config?.gateway?.mode).toBe("local");
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1"); expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
ws.close();
await server.close();
}); });
it("requires base hash when config exists", async () => { it("requires base hash when config exists", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const setId = "req-set-2"; const setId = "req-set-2";
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -157,9 +145,15 @@ describe("gateway config.patch", () => {
); );
expect(patchRes.ok).toBe(false); expect(patchRes.ok).toBe(false);
expect(patchRes.error?.message).toContain("base hash"); expect(patchRes.error?.message).toContain("base hash");
ws.close();
await server.close();
}); });
it("requires base hash for config.set when config exists", async () => { it("requires base hash for config.set when config exists", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const setId = "req-set-3"; const setId = "req-set-3";
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -198,108 +192,8 @@ describe("gateway config.patch", () => {
); );
expect(set2Res.ok).toBe(false); expect(set2Res.ok).toBe(false);
expect(set2Res.error?.message).toContain("base hash"); expect(set2Res.error?.message).toContain("base hash");
});
});
describe("gateway server sessions", () => { ws.close();
it("filters sessions by agentId", async () => { await server.close();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-agents-"));
testState.sessionConfig = {
store: path.join(dir, "{agentId}", "sessions.json"),
};
testState.agentsConfig = {
list: [{ id: "home", default: true }, { id: "work" }],
};
const homeDir = path.join(dir, "home");
const workDir = path.join(dir, "work");
await fs.mkdir(homeDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await writeSessionStore({
storePath: path.join(homeDir, "sessions.json"),
agentId: "home",
entries: {
main: {
sessionId: "sess-home-main",
updatedAt: Date.now(),
},
"discord:group:dev": {
sessionId: "sess-home-group",
updatedAt: Date.now() - 1000,
},
},
});
await writeSessionStore({
storePath: path.join(workDir, "sessions.json"),
agentId: "work",
entries: {
main: {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
});
const homeSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "home",
});
expect(homeSessions.ok).toBe(true);
expect(homeSessions.payload?.sessions.map((s) => s.key).sort()).toEqual([
"agent:home:discord:group:dev",
"agent:home:main",
]);
const workSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "work",
});
expect(workSessions.ok).toBe(true);
expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual(["agent:work:main"]);
});
it("resolves and patches main alias to default agent main key", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
testState.sessionStorePath = storePath;
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
testState.sessionConfig = { mainKey: "work" };
await writeSessionStore({
storePath,
agentId: "ops",
mainKey: "work",
entries: {
main: {
sessionId: "sess-ops-main",
updatedAt: Date.now(),
},
},
});
const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
key: "main",
});
expect(resolved.ok).toBe(true);
expect(resolved.payload?.key).toBe("agent:ops:work");
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
key: "main",
thinkingLevel: "medium",
});
expect(patched.ok).toBe(true);
expect(patched.payload?.key).toBe("agent:ops:work");
const stored = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ thinkingLevel?: string }
>;
expect(stored["agent:ops:work"]?.thinkingLevel).toBe("medium");
expect(stored.main).toBeUndefined();
}); });
}); });
+12 -9
View File
@@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { import {
connectOk, connectOk,
installGatewayTestHooks, installGatewayTestHooks,
@@ -12,7 +12,7 @@ import {
waitForSystemEvent, waitForSystemEvent,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
async function yieldToEventLoop() { async function yieldToEventLoop() {
// Avoid relying on timers (fake timers can leak between tests). // Avoid relying on timers (fake timers can leak between tests).
@@ -36,11 +36,7 @@ async function rmTempDir(dir: string) {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
} }
async function waitForCronFinished( async function waitForCronFinished(ws: { send: (data: string) => void }, jobId: string) {
ws: { send: (data: string) => void },
jobId: string,
timeoutMs = 20_000,
) {
await onceMessage( await onceMessage(
ws as never, ws as never,
(o) => (o) =>
@@ -48,7 +44,7 @@ async function waitForCronFinished(
o.event === "cron" && o.event === "cron" &&
o.payload?.action === "finished" && o.payload?.action === "finished" &&
o.payload?.jobId === jobId, o.payload?.jobId === jobId,
timeoutMs, 10_000,
); );
} }
@@ -349,7 +345,14 @@ describe("gateway server cron", () => {
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : ""; const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
expect(autoJobId.length > 0).toBe(true); expect(autoJobId.length > 0).toBe(true);
await waitForCronFinished(ws, autoJobId); vi.useFakeTimers();
try {
const autoFinishedP = waitForCronFinished(ws, autoJobId);
await vi.advanceTimersByTimeAsync(1000);
await autoFinishedP;
} finally {
vi.useRealTimers();
}
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`)); await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`));
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
+31 -33
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { import {
@@ -21,35 +21,12 @@ import {
} from "./test-helpers.js"; } from "./test-helpers.js";
import { buildDeviceAuthPayload } from "./device-auth.js"; import { buildDeviceAuthPayload } from "./device-auth.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
beforeAll(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, opts);
return ws;
};
describe("gateway server health/presence", () => { describe("gateway server health/presence", () => {
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => { test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
const ws = await openClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws);
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1"); const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1"); const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
@@ -74,6 +51,7 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(presence.payload)).toBe(true); expect(Array.isArray(presence.payload)).toBe(true);
ws.close(); ws.close();
await server.close();
}); });
test("broadcasts heartbeat events and serves last-heartbeat", async () => { test("broadcasts heartbeat events and serves last-heartbeat", async () => {
@@ -98,7 +76,8 @@ describe("gateway server health/presence", () => {
payload?: unknown; payload?: unknown;
}; };
const ws = await openClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitHeartbeat = onceMessage<EventFrame>( const waitHeartbeat = onceMessage<EventFrame>(
ws, ws,
@@ -138,10 +117,12 @@ describe("gateway server health/presence", () => {
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false); expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
ws.close(); ws.close();
await server.close();
}); });
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => { test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
const ws = await openClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws);
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence"); const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
ws.send( ws.send(
@@ -159,10 +140,12 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(evt.payload?.presence)).toBe(true); expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close(); ws.close();
await server.close();
}); });
test("agent events stream with seq", { timeout: 8000 }, async () => { test("agent events stream with seq", { timeout: 8000 }, async () => {
const ws = await openClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws);
const runId = randomUUID(); const runId = randomUUID();
const evtPromise = onceMessage( const evtPromise = onceMessage(
@@ -180,6 +163,7 @@ describe("gateway server health/presence", () => {
expect(evt.payload.data.msg).toBe("hi"); expect(evt.payload.data.msg).toBe("hi");
ws.close(); ws.close();
await server.close();
}); });
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => { test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
@@ -193,7 +177,16 @@ describe("gateway server health/presence", () => {
}); });
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => { test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
const clients = await Promise.all([openClient(), openClient(), openClient()]); const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
await connectOk(c);
return c;
};
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const waits = clients.map((c) => const waits = clients.map((c) =>
onceMessage(c, (o) => o.type === "event" && o.event === "presence"), onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
); );
@@ -211,6 +204,7 @@ describe("gateway server health/presence", () => {
expect(typeof evt.seq).toBe("number"); expect(typeof evt.seq).toBe("number");
} }
for (const c of clients) c.close(); for (const c of clients) c.close();
await server.close();
}); });
test("presence includes client fingerprint", async () => { test("presence includes client fingerprint", async () => {
@@ -228,7 +222,8 @@ describe("gateway server health/presence", () => {
signedAtMs, signedAtMs,
token: null, token: null,
}); });
const ws = await openClient({ const { server, ws } = await startServerWithClient();
await connectOk(ws, {
role, role,
scopes, scopes,
client: { client: {
@@ -269,11 +264,13 @@ describe("gateway server health/presence", () => {
expect(clientEntry?.modelIdentifier).toBe("iPad16,6"); expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
ws.close(); ws.close();
await server.close();
}); });
test("cli connections are not tracked as instances", async () => { test("cli connections are not tracked as instances", async () => {
const { server, ws } = await startServerWithClient();
const cliId = `cli-${randomUUID()}`; const cliId = `cli-${randomUUID()}`;
const ws = await openClient({ await connectOk(ws, {
client: { client: {
id: GATEWAY_CLIENT_NAMES.CLI, id: GATEWAY_CLIENT_NAMES.CLI,
version: "dev", version: "dev",
@@ -297,5 +294,6 @@ describe("gateway server health/presence", () => {
expect(entries.some((e) => e.instanceId === cliId)).toBe(false); expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
ws.close(); ws.close();
await server.close();
}); });
}); });
+184 -128
View File
@@ -10,150 +10,206 @@ import {
waitForSystemEvent, waitForSystemEvent,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
const resolveMainKey = () => resolveMainSessionKeyFromConfig(); const resolveMainKey = () => resolveMainSessionKeyFromConfig();
describe("gateway server hooks", () => { describe("gateway server hooks", () => {
test("handles auth, wake, and agent flows", async () => { test("hooks wake requires auth", async () => {
testState.hooksConfig = { enabled: true, token: "hook-secret" }; testState.hooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort(); const port = await getFreePort();
const server = await startGatewayServer(port); const server = await startGatewayServer(port);
try { const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
const resNoAuth = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST",
method: "POST", headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Ping" }),
body: JSON.stringify({ text: "Ping" }), });
}); expect(res.status).toBe(401);
expect(resNoAuth.status).toBe(401); await server.close();
});
const resWake = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { test("hooks wake enqueues system event", async () => {
method: "POST", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { const port = await getFreePort();
"Content-Type": "application/json", const server = await startGatewayServer(port);
Authorization: "Bearer hook-secret", const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
}, method: "POST",
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }), headers: {
}); "Content-Type": "application/json",
expect(resWake.status).toBe(200); Authorization: "Bearer hook-secret",
const wakeEvents = await waitForSystemEvent(); },
expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true); body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
drainSystemEvents(resolveMainKey()); });
expect(res.status).toBe(200);
const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Ping"))).toBe(true);
drainSystemEvents(resolveMainKey());
await server.close();
});
cronIsolatedRun.mockReset(); test("hooks agent posts summary to main", async () => {
cronIsolatedRun.mockResolvedValueOnce({ testState.hooksConfig = { enabled: true, token: "hook-secret" };
status: "ok", cronIsolatedRun.mockResolvedValueOnce({
summary: "done", status: "ok",
}); summary: "done",
const resAgent = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { });
method: "POST", const port = await getFreePort();
headers: { const server = await startGatewayServer(port);
"Content-Type": "application/json", const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
Authorization: "Bearer hook-secret", method: "POST",
}, headers: {
body: JSON.stringify({ message: "Do it", name: "Email" }), "Content-Type": "application/json",
}); Authorization: "Bearer hook-secret",
expect(resAgent.status).toBe(202); },
const agentEvents = await waitForSystemEvent(); body: JSON.stringify({ message: "Do it", name: "Email" }),
expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true); });
drainSystemEvents(resolveMainKey()); expect(res.status).toBe(202);
const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Hook Email: done"))).toBe(true);
drainSystemEvents(resolveMainKey());
await server.close();
});
cronIsolatedRun.mockReset(); test("hooks agent forwards model override", async () => {
cronIsolatedRun.mockResolvedValueOnce({ testState.hooksConfig = { enabled: true, token: "hook-secret" };
status: "ok", cronIsolatedRun.mockClear();
summary: "done", cronIsolatedRun.mockResolvedValueOnce({
}); status: "ok",
const resAgentModel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { summary: "done",
method: "POST", });
headers: { const port = await getFreePort();
"Content-Type": "application/json", const server = await startGatewayServer(port);
Authorization: "Bearer hook-secret", const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
}, method: "POST",
body: JSON.stringify({ headers: {
message: "Do it", "Content-Type": "application/json",
name: "Email", Authorization: "Bearer hook-secret",
model: "openai/gpt-4.1-mini", },
}), body: JSON.stringify({
}); message: "Do it",
expect(resAgentModel.status).toBe(202); name: "Email",
await waitForSystemEvent(); model: "openai/gpt-4.1-mini",
const call = cronIsolatedRun.mock.calls[0]?.[0] as { }),
job?: { payload?: { model?: string } }; });
}; expect(res.status).toBe(202);
expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini"); await waitForSystemEvent();
drainSystemEvents(resolveMainKey()); const call = cronIsolatedRun.mock.calls[0]?.[0] as {
job?: { payload?: { model?: string } };
};
expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini");
drainSystemEvents(resolveMainKey());
await server.close();
});
const resQuery = await fetch(`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`, { test("hooks wake accepts query token", async () => {
method: "POST", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { "Content-Type": "application/json" }, const port = await getFreePort();
body: JSON.stringify({ text: "Query auth" }), const server = await startGatewayServer(port);
}); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`, {
expect(resQuery.status).toBe(200); method: "POST",
const queryEvents = await waitForSystemEvent(); headers: { "Content-Type": "application/json" },
expect(queryEvents.some((e) => e.includes("Query auth"))).toBe(true); body: JSON.stringify({ text: "Query auth" }),
drainSystemEvents(resolveMainKey()); });
expect(res.status).toBe(200);
const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Query auth"))).toBe(true);
drainSystemEvents(resolveMainKey());
await server.close();
});
const resBadChannel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { test("hooks agent rejects invalid channel", async () => {
method: "POST", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { const port = await getFreePort();
"Content-Type": "application/json", const server = await startGatewayServer(port);
Authorization: "Bearer hook-secret", const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
}, method: "POST",
body: JSON.stringify({ message: "Nope", channel: "sms" }), headers: {
}); "Content-Type": "application/json",
expect(resBadChannel.status).toBe(400); Authorization: "Bearer hook-secret",
expect(peekSystemEvents(resolveMainKey()).length).toBe(0); },
body: JSON.stringify({ message: "Nope", channel: "sms" }),
});
expect(res.status).toBe(400);
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
await server.close();
});
const resHeader = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { test("hooks wake accepts x-clawdbot-token header", async () => {
method: "POST", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { const port = await getFreePort();
"Content-Type": "application/json", const server = await startGatewayServer(port);
"x-clawdbot-token": "hook-secret", const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
}, method: "POST",
body: JSON.stringify({ text: "Header auth" }), headers: {
}); "Content-Type": "application/json",
expect(resHeader.status).toBe(200); "x-clawdbot-token": "hook-secret",
const headerEvents = await waitForSystemEvent(); },
expect(headerEvents.some((e) => e.includes("Header auth"))).toBe(true); body: JSON.stringify({ text: "Header auth" }),
drainSystemEvents(resolveMainKey()); });
expect(res.status).toBe(200);
const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Header auth"))).toBe(true);
drainSystemEvents(resolveMainKey());
await server.close();
});
const resGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { test("hooks rejects non-post", async () => {
method: "GET", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { Authorization: "Bearer hook-secret" }, const port = await getFreePort();
}); const server = await startGatewayServer(port);
expect(resGet.status).toBe(405); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "GET",
headers: { Authorization: "Bearer hook-secret" },
});
expect(res.status).toBe(405);
await server.close();
});
const resBlankText = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { test("hooks wake requires text", async () => {
method: "POST", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { const port = await getFreePort();
"Content-Type": "application/json", const server = await startGatewayServer(port);
Authorization: "Bearer hook-secret", const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
}, method: "POST",
body: JSON.stringify({ text: " " }), headers: {
}); "Content-Type": "application/json",
expect(resBlankText.status).toBe(400); Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ text: " " }),
});
expect(res.status).toBe(400);
await server.close();
});
const resBlankMessage = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { test("hooks agent requires message", async () => {
method: "POST", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { const port = await getFreePort();
"Content-Type": "application/json", const server = await startGatewayServer(port);
Authorization: "Bearer hook-secret", const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
}, method: "POST",
body: JSON.stringify({ message: " " }), headers: {
}); "Content-Type": "application/json",
expect(resBlankMessage.status).toBe(400); Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ message: " " }),
});
expect(res.status).toBe(400);
await server.close();
});
const resBadJson = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { test("hooks rejects invalid json", async () => {
method: "POST", testState.hooksConfig = { enabled: true, token: "hook-secret" };
headers: { const port = await getFreePort();
"Content-Type": "application/json", const server = await startGatewayServer(port);
Authorization: "Bearer hook-secret", const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
}, method: "POST",
body: "{", headers: {
}); "Content-Type": "application/json",
expect(resBadJson.status).toBe(400); Authorization: "Bearer hook-secret",
} finally { },
await server.close(); body: "{",
} });
expect(res.status).toBe(400);
await server.close();
}); });
}); });
+7 -13
View File
@@ -1,21 +1,9 @@
import { afterAll, beforeAll, test } from "vitest"; import { test } from "vitest";
import WebSocket from "ws"; import WebSocket from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js"; import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js";
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
beforeAll(async () => {
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
});
function connectReq( function connectReq(
ws: WebSocket, ws: WebSocket,
params: { clientId: string; platform: string; token?: string; password?: string }, params: { clientId: string; platform: string; token?: string; password?: string },
@@ -55,6 +43,8 @@ function connectReq(
} }
test("accepts clawdbot-ios as a valid gateway client id", async () => { test("accepts clawdbot-ios as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`); const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -71,9 +61,12 @@ test("accepts clawdbot-ios as a valid gateway client id", async () => {
} }
ws.close(); ws.close();
await server.close();
}); });
test("accepts clawdbot-android as a valid gateway client id", async () => { test("accepts clawdbot-android as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`); const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -90,4 +83,5 @@ test("accepts clawdbot-android as a valid gateway client id", async () => {
} }
ws.close(); ws.close();
await server.close();
}); });
+189
View File
@@ -0,0 +1,189 @@
import fs from "node:fs/promises";
import { createServer } from "node:net";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import type { ChannelOutboundAdapter } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
occupyPort,
onceMessage,
startGatewayServer,
startServerWithClient,
testState,
testTailnetIPv4,
} from "./test-helpers.js";
installGatewayTestHooks();
const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ deps, to, text }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, {})) };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { mediaUrl })) };
},
};
const whatsappPlugin = createOutboundTestPlugin({
id: "whatsapp",
outbound: whatsappOutbound,
label: "WhatsApp",
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const whatsappRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: whatsappPlugin,
},
]);
const emptyRegistry = createRegistry([]);
describe("gateway server misc", () => {
test("hello-ok advertises the gateway port for canvas host", async () => {
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
const prevCanvasPort = process.env.CLAWDBOT_CANVAS_HOST_PORT;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
testTailnetIPv4.value = "100.64.0.1";
testState.gatewayBind = "lan";
const canvasPort = await getFreePort();
testState.canvasHostPort = canvasPort;
process.env.CLAWDBOT_CANVAS_HOST_PORT = String(canvasPort);
const port = await getFreePort();
const canvasHostUrl = resolveCanvasHostUrl({
canvasPort,
requestHost: `100.64.0.1:${port}`,
localAddress: "127.0.0.1",
});
expect(canvasHostUrl).toBe(`http://100.64.0.1:${canvasPort}`);
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
if (prevCanvasPort === undefined) {
delete process.env.CLAWDBOT_CANVAS_HOST_PORT;
} else {
process.env.CLAWDBOT_CANVAS_HOST_PORT = prevCanvasPort;
}
});
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
const prevRegistry = getActivePluginRegistry() ?? emptyRegistry;
try {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
setActivePluginRegistry(whatsappRegistry);
expect(getChannelPlugin("whatsapp")).toBeDefined();
const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) =>
ws.send(
JSON.stringify({
type: "req",
id,
method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}),
);
sendReq("a1");
sendReq("a2");
const res1 = await res1P;
const res2 = await res2P;
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload);
ws.close();
await server.close();
} finally {
setActivePluginRegistry(prevRegistry);
}
});
test("auto-enables configured channel plugins on startup", async () => {
const configPath = process.env.CLAWDBOT_CONFIG_PATH;
if (!configPath) throw new Error("Missing CLAWDBOT_CONFIG_PATH");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
channels: {
discord: {
token: "token-123",
},
},
},
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
await server.close();
const updated = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
const plugins = updated.plugins as Record<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, unknown> | undefined;
const discord = entries?.discord as Record<string, unknown> | undefined;
expect(discord?.enabled).toBe(true);
expect((updated.channels as Record<string, unknown> | undefined)?.discord).toMatchObject({
token: "token-123",
});
});
test("refuses to start when port already bound", async () => {
const { server: blocker, port } = await occupyPort();
await expect(startGatewayServer(port)).rejects.toBeInstanceOf(GatewayLockError);
await expect(startGatewayServer(port)).rejects.toThrow(/already listening/i);
blocker.close();
});
test("releases port after close", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
await server.close();
const probe = createServer();
await new Promise<void>((resolve, reject) => {
probe.once("error", reject);
probe.listen(port, "127.0.0.1", () => resolve());
});
await new Promise<void>((resolve, reject) =>
probe.close((err) => (err ? reject(err) : resolve())),
);
});
});
@@ -1,93 +1,19 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { getChannelPlugin } from "../channels/plugins/index.js";
import type { ChannelOutboundAdapter } from "../channels/plugins/types.js";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { import {
connectOk, connectOk,
getFreePort,
installGatewayTestHooks, installGatewayTestHooks,
occupyPort,
onceMessage, onceMessage,
piSdkMock, piSdkMock,
rpcReq, rpcReq,
startGatewayServer,
startServerWithClient, startServerWithClient,
testState,
testTailnetIPv4,
} from "./test-helpers.js"; } from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: WebSocket;
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ deps, to, text }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, {})) };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { mediaUrl })) };
},
};
const whatsappPlugin = createOutboundTestPlugin({
id: "whatsapp",
outbound: whatsappOutbound,
label: "WhatsApp",
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const whatsappRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: whatsappPlugin,
},
]);
const emptyRegistry = createRegistry([]);
describe("gateway server models + voicewake", () => { describe("gateway server models + voicewake", () => {
const setTempHome = (homeDir: string) => { const setTempHome = (homeDir: string) => {
@@ -142,6 +68,9 @@ describe("gateway server models + voicewake", () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir); const restoreHome = setTempHome(homeDir);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get"); const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(initial.ok).toBe(true); expect(initial.ok).toBe(true);
expect(initial.payload?.triggers).toEqual(["clawd", "claude", "computer"]); expect(initial.payload?.triggers).toEqual(["clawd", "claude", "computer"]);
@@ -175,6 +104,9 @@ describe("gateway server models + voicewake", () => {
expect(onDisk.triggers).toEqual(["hi", "there"]); expect(onDisk.triggers).toEqual(["hi", "there"]);
expect(typeof onDisk.updatedAtMs).toBe("number"); expect(typeof onDisk.updatedAtMs).toBe("number");
ws.close();
await server.close();
restoreHome(); restoreHome();
}, },
); );
@@ -183,6 +115,9 @@ describe("gateway server models + voicewake", () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir); const restoreHome = setTempHome(homeDir);
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve)); await new Promise<void>((resolve) => nodeWs.once("open", resolve));
const firstEventP = onceMessage<{ type: "event"; event: string; payload?: unknown }>( const firstEventP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
@@ -224,6 +159,9 @@ describe("gateway server models + voicewake", () => {
]); ]);
nodeWs.close(); nodeWs.close();
ws.close();
await server.close();
restoreHome(); restoreHome();
}); });
@@ -251,6 +189,9 @@ describe("gateway server models + voicewake", () => {
}, },
]; ];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res1 = await rpcReq<{ const res1 = await rpcReq<{
models: Array<{ models: Array<{
id: string; id: string;
@@ -300,132 +241,23 @@ describe("gateway server models + voicewake", () => {
]); ]);
expect(piSdkMock.discoverCalls).toBe(1); expect(piSdkMock.discoverCalls).toBe(1);
ws.close();
await server.close();
}); });
test("models.list rejects unknown params", async () => { test("models.list rejects unknown params", async () => {
piSdkMock.enabled = true; piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "models.list", { extra: true }); const res = await rpcReq(ws, "models.list", { extra: true });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i); expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i);
});
});
describe("gateway server misc", () => { ws.close();
test("hello-ok advertises the gateway port for canvas host", async () => { await server.close();
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
const prevCanvasPort = process.env.CLAWDBOT_CANVAS_HOST_PORT;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
testTailnetIPv4.value = "100.64.0.1";
testState.gatewayBind = "lan";
const canvasPort = await getFreePort();
testState.canvasHostPort = canvasPort;
process.env.CLAWDBOT_CANVAS_HOST_PORT = String(canvasPort);
const testPort = await getFreePort();
const canvasHostUrl = resolveCanvasHostUrl({
canvasPort,
requestHost: `100.64.0.1:${testPort}`,
localAddress: "127.0.0.1",
});
expect(canvasHostUrl).toBe(`http://100.64.0.1:${canvasPort}`);
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
if (prevCanvasPort === undefined) {
delete process.env.CLAWDBOT_CANVAS_HOST_PORT;
} else {
process.env.CLAWDBOT_CANVAS_HOST_PORT = prevCanvasPort;
}
});
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
const prevRegistry = getActivePluginRegistry() ?? emptyRegistry;
try {
setActivePluginRegistry(whatsappRegistry);
expect(getChannelPlugin("whatsapp")).toBeDefined();
const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) =>
ws.send(
JSON.stringify({
type: "req",
id,
method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}),
);
sendReq("a1");
sendReq("a2");
const res1 = await res1P;
const res2 = await res2P;
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload);
} finally {
setActivePluginRegistry(prevRegistry);
}
});
test("auto-enables configured channel plugins on startup", async () => {
const configPath = process.env.CLAWDBOT_CONFIG_PATH;
if (!configPath) throw new Error("Missing CLAWDBOT_CONFIG_PATH");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
channels: {
discord: {
token: "token-123",
},
},
},
null,
2,
),
"utf-8",
);
const autoPort = await getFreePort();
const autoServer = await startGatewayServer(autoPort);
await autoServer.close();
const updated = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
const plugins = updated.plugins as Record<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, unknown> | undefined;
const discord = entries?.discord as Record<string, unknown> | undefined;
expect(discord?.enabled).toBe(true);
expect((updated.channels as Record<string, unknown> | undefined)?.discord).toMatchObject({
token: "token-123",
});
});
test("refuses to start when port already bound", async () => {
const { server: blocker, port: blockedPort } = await occupyPort();
await expect(startGatewayServer(blockedPort)).rejects.toBeInstanceOf(GatewayLockError);
await expect(startGatewayServer(blockedPort)).rejects.toThrow(/already listening/i);
blocker.close();
});
test("releases port after close", async () => {
const releasePort = await getFreePort();
const releaseServer = await startGatewayServer(releasePort);
await releaseServer.close();
const probe = createServer();
await new Promise<void>((resolve, reject) => {
probe.once("error", reject);
probe.listen(releasePort, "127.0.0.1", () => resolve());
});
await new Promise<void>((resolve, reject) =>
probe.close((err) => (err ? reject(err) : resolve())),
);
}); });
}); });
@@ -1,48 +1,15 @@
import fs from "node:fs/promises"; import { describe, expect, test } from "vitest";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
vi.mock("../infra/update-runner.js", () => ({
runGatewayUpdate: vi.fn(async () => ({
status: "ok",
mode: "git",
root: "/repo",
steps: [],
durationMs: 12,
})),
}));
import { import {
connectOk, connectOk,
installGatewayTestHooks, installGatewayTestHooks,
onceMessage,
rpcReq, rpcReq,
startServerWithClient, startServerWithClient,
} from "./test-helpers.js"; } from "./test-helpers.js";
import { GatewayClient } from "./client.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: WebSocket;
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -98,99 +65,11 @@ const connectNodeClient = async (params: {
return client; return client;
}; };
async function waitForSignal(check: () => boolean, timeoutMs = 2000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("timeout");
}
describe("gateway role enforcement", () => {
test("enforces operator and node permissions", async () => {
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
try {
const eventRes = await rpcReq(ws, "node.event", { event: "test", payload: { ok: true } });
expect(eventRes.ok).toBe(false);
expect(eventRes.error?.message ?? "").toContain("unauthorized role");
const invokeRes = await rpcReq(ws, "node.invoke.result", {
id: "invoke-1",
nodeId: "node-1",
ok: true,
});
expect(invokeRes.ok).toBe(false);
expect(invokeRes.error?.message ?? "").toContain("unauthorized role");
await connectOk(nodeWs, {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: [],
});
const binsRes = await rpcReq<{ bins?: unknown[] }>(nodeWs, "skills.bins", {});
expect(binsRes.ok).toBe(true);
expect(Array.isArray(binsRes.payload?.bins)).toBe(true);
const statusRes = await rpcReq(nodeWs, "status", {});
expect(statusRes.ok).toBe(false);
expect(statusRes.error?.message ?? "").toContain("unauthorized role");
} finally {
nodeWs.close();
}
});
});
describe("gateway update.run", () => {
test("writes sentinel and schedules restart", async () => {
const sigusr1 = vi.fn();
process.on("SIGUSR1", sigusr1);
try {
const id = "req-update";
ws.send(
JSON.stringify({
type: "req",
id,
method: "update.run",
params: {
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);
await waitForSignal(() => sigusr1.mock.calls.length > 0);
expect(sigusr1).toHaveBeenCalled();
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as {
payload?: { kind?: string; stats?: { mode?: string } };
};
expect(parsed.payload?.kind).toBe("update");
expect(parsed.payload?.stats?.mode).toBe("git");
} finally {
process.off("SIGUSR1", sigusr1);
}
});
});
describe("gateway node command allowlist", () => { describe("gateway node command allowlist", () => {
test("enforces command allowlists across node clients", async () => { test("enforces command allowlists across node clients", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const waitForConnectedCount = async (count: number) => { const waitForConnectedCount = async (count: number) => {
await expect await expect
.poll( .poll(
@@ -217,12 +96,8 @@ describe("gateway node command allowlist", () => {
return nodeId; return nodeId;
}; };
let systemClient: GatewayClient | undefined;
let emptyClient: GatewayClient | undefined;
let allowedClient: GatewayClient | undefined;
try { try {
systemClient = await connectNodeClient({ const systemClient = await connectNodeClient({
port, port,
commands: ["system.run"], commands: ["system.run"],
instanceId: "node-system-run", instanceId: "node-system-run",
@@ -240,7 +115,7 @@ describe("gateway node command allowlist", () => {
systemClient.stop(); systemClient.stop();
await waitForConnectedCount(0); await waitForConnectedCount(0);
emptyClient = await connectNodeClient({ const emptyClient = await connectNodeClient({
port, port,
commands: [], commands: [],
instanceId: "node-empty", instanceId: "node-empty",
@@ -263,7 +138,7 @@ describe("gateway node command allowlist", () => {
new Promise<{ id?: string; nodeId?: string }>((resolve) => { new Promise<{ id?: string; nodeId?: string }>((resolve) => {
resolveInvoke = resolve; resolveInvoke = resolve;
}); });
allowedClient = await connectNodeClient({ const allowedClient = await connectNodeClient({
port, port,
commands: ["canvas.snapshot"], commands: ["canvas.snapshot"],
instanceId: "node-allowed", instanceId: "node-allowed",
@@ -312,10 +187,11 @@ describe("gateway node command allowlist", () => {
}); });
const invokeNullRes = await invokeNullResP; const invokeNullRes = await invokeNullResP;
expect(invokeNullRes.ok).toBe(true); expect(invokeNullRes.ok).toBe(true);
allowedClient.stop();
} finally { } finally {
systemClient?.stop(); ws.close();
emptyClient?.stop(); await server.close();
allowedClient?.stop();
} }
}); });
}); });
+2 -21
View File
@@ -1,12 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js";
connectOk,
getFreePort,
installGatewayTestHooks,
rpcReq,
startGatewayServer,
startServerWithClient,
} from "./test-helpers.js";
const hoisted = vi.hoisted(() => { const hoisted = vi.hoisted(() => {
const cronInstances: Array<{ const cronInstances: Array<{
@@ -165,7 +158,7 @@ vi.mock("./config-reload.js", () => ({
startGatewayConfigReloader: hoisted.startGatewayConfigReloader, startGatewayConfigReloader: hoisted.startGatewayConfigReloader,
})); }));
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
describe("gateway hot reload", () => { describe("gateway hot reload", () => {
let prevSkipChannels: string | undefined; let prevSkipChannels: string | undefined;
@@ -305,15 +298,3 @@ describe("gateway hot reload", () => {
await server.close(); await server.close();
}); });
}); });
describe("gateway agents", () => {
it("lists configured agents via agents.list RPC", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ agents: Array<{ id: string }> }>(ws, "agents.list", {});
expect(res.ok).toBe(true);
expect(res.payload?.agents.map((agent) => agent.id)).toContain("main");
ws.close();
await server.close();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway role enforcement", () => {
test("enforces operator and node permissions", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const eventRes = await rpcReq(ws, "node.event", { event: "test", payload: { ok: true } });
expect(eventRes.ok).toBe(false);
expect(eventRes.error?.message ?? "").toContain("unauthorized role");
const invokeRes = await rpcReq(ws, "node.invoke.result", {
id: "invoke-1",
nodeId: "node-1",
ok: true,
});
expect(invokeRes.ok).toBe(false);
expect(invokeRes.error?.message ?? "").toContain("unauthorized role");
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
await connectOk(nodeWs, {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: [],
});
const binsRes = await rpcReq<{ bins?: unknown[] }>(nodeWs, "skills.bins", {});
expect(binsRes.ok).toBe(true);
expect(Array.isArray(binsRes.payload?.bins)).toBe(true);
const statusRes = await rpcReq(nodeWs, "status", {});
expect(statusRes.ok).toBe(false);
expect(statusRes.error?.message ?? "").toContain("unauthorized role");
nodeWs.close();
ws.close();
await server.close();
});
});
+40 -26
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "../agents/clawdbot-tools.js"; import { createClawdbotTools } from "../agents/clawdbot-tools.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
@@ -11,38 +11,30 @@ import {
startGatewayServer, startGatewayServer,
} from "./test-helpers.js"; } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startGatewayServer>>; const servers: Array<Awaited<ReturnType<typeof startGatewayServer>>> = [];
let gatewayPort: number;
let prevGatewayPort: string | undefined;
let prevGatewayToken: string | undefined;
beforeAll(async () => { afterEach(async () => {
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT; for (const server of servers) {
prevGatewayToken = process.env.CLAWDBOT_GATEWAY_TOKEN; try {
gatewayPort = await getFreePort(); await server.close();
process.env.CLAWDBOT_GATEWAY_PORT = String(gatewayPort); } catch {
process.env.CLAWDBOT_GATEWAY_TOKEN = "test-token"; /* ignore */
server = await startGatewayServer(gatewayPort); }
});
afterAll(async () => {
await server.close();
if (prevGatewayPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
}
if (prevGatewayToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevGatewayToken;
} }
servers.length = 0;
// Add small delay to ensure port is fully released by OS
await new Promise((resolve) => setTimeout(resolve, 50));
}); });
describe("sessions_send gateway loopback", () => { describe("sessions_send gateway loopback", () => {
it("returns reply when lifecycle ends before agent.wait", async () => { it("returns reply when lifecycle ends before agent.wait", async () => {
const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token");
const server = await startGatewayServer(port);
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
spy.mockImplementation(async (opts) => { spy.mockImplementation(async (opts) => {
const params = opts as { const params = opts as {
@@ -86,6 +78,8 @@ describe("sessions_send gateway loopback", () => {
}); });
}); });
servers.push(server);
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send"); const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");
@@ -110,6 +104,12 @@ describe("sessions_send gateway loopback", () => {
describe("sessions_send label lookup", () => { describe("sessions_send label lookup", () => {
it("finds session by label and sends message", { timeout: 60_000 }, async () => { it("finds session by label and sends message", { timeout: 60_000 }, async () => {
const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token");
const server = await startGatewayServer(port);
servers.push(server);
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
spy.mockImplementation(async (opts) => { spy.mockImplementation(async (opts) => {
const params = opts as { const params = opts as {
@@ -171,6 +171,13 @@ describe("sessions_send label lookup", () => {
}); });
it("returns error when label not found", { timeout: 60_000 }, async () => { it("returns error when label not found", { timeout: 60_000 }, async () => {
const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token");
const server = await startGatewayServer(port);
servers.push(server);
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send"); const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");
@@ -185,6 +192,13 @@ describe("sessions_send label lookup", () => {
}); });
it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => { it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => {
const port = await getFreePort();
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token");
const server = await startGatewayServer(port);
servers.push(server);
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send"); const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");
@@ -1,16 +1,14 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { import {
connectOk, connectOk,
embeddedRunMock, embeddedRunMock,
getFreePort,
installGatewayTestHooks, installGatewayTestHooks,
piSdkMock, piSdkMock,
rpcReq, rpcReq,
startGatewayServer, startServerWithClient,
testState, testState,
writeSessionStore, writeSessionStore,
} from "./test-helpers.js"; } from "./test-helpers.js";
@@ -41,31 +39,7 @@ vi.mock("../auto-reply/reply/abort.js", async () => {
}; };
}); });
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks();
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
beforeAll(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const hello = await connectOk(ws, opts);
return { ws, hello };
};
describe("gateway server sessions", () => { describe("gateway server sessions", () => {
beforeEach(() => { beforeEach(() => {
@@ -124,7 +98,8 @@ describe("gateway server sessions", () => {
}, },
}); });
const { ws, hello } = await openClient(); const { server, ws } = await startServerWithClient();
const hello = await connectOk(ws);
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual( expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
expect.arrayContaining([ expect.arrayContaining([
"sessions.list", "sessions.list",
@@ -361,6 +336,7 @@ describe("gateway server sessions", () => {
); );
ws.close(); ws.close();
await server.close();
}); });
test("sessions.preview returns transcript previews", async () => { test("sessions.preview returns transcript previews", async () => {
@@ -389,7 +365,8 @@ describe("gateway server sessions", () => {
}, },
}); });
const { ws } = await openClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws);
const preview = await rpcReq<{ const preview = await rpcReq<{
previews: Array<{ previews: Array<{
key: string; key: string;
@@ -406,6 +383,7 @@ describe("gateway server sessions", () => {
expect(entry?.items[1]?.text).toContain("call weather"); expect(entry?.items[1]?.text).toContain("call weather");
ws.close(); ws.close();
await server.close();
}); });
test("sessions.delete rejects main and aborts active runs", async () => { test("sessions.delete rejects main and aborts active runs", async () => {
@@ -437,7 +415,8 @@ describe("gateway server sessions", () => {
embeddedRunMock.activeIds.add("sess-active"); embeddedRunMock.activeIds.add("sess-active");
embeddedRunMock.waitResults.set("sess-active", true); embeddedRunMock.waitResults.set("sess-active", true);
const { ws } = await openClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws);
const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" }); const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" });
expect(mainDelete.ok).toBe(false); expect(mainDelete.ok).toBe(false);
@@ -460,5 +439,6 @@ describe("gateway server sessions", () => {
expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]); expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]);
ws.close(); ws.close();
await server.close();
}); });
}); });
@@ -0,0 +1,122 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway server sessions", () => {
test("filters sessions by agentId", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-agents-"));
testState.sessionConfig = {
store: path.join(dir, "{agentId}", "sessions.json"),
};
testState.agentsConfig = {
list: [{ id: "home", default: true }, { id: "work" }],
};
const homeDir = path.join(dir, "home");
const workDir = path.join(dir, "work");
await fs.mkdir(homeDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await writeSessionStore({
storePath: path.join(homeDir, "sessions.json"),
agentId: "home",
entries: {
main: {
sessionId: "sess-home-main",
updatedAt: Date.now(),
},
"discord:group:dev": {
sessionId: "sess-home-group",
updatedAt: Date.now() - 1000,
},
},
});
await writeSessionStore({
storePath: path.join(workDir, "sessions.json"),
agentId: "work",
entries: {
main: {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
});
const { ws } = await startServerWithClient();
await connectOk(ws);
const homeSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "home",
});
expect(homeSessions.ok).toBe(true);
expect(homeSessions.payload?.sessions.map((s) => s.key).sort()).toEqual([
"agent:home:discord:group:dev",
"agent:home:main",
]);
const workSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "work",
});
expect(workSessions.ok).toBe(true);
expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual(["agent:work:main"]);
});
test("resolves and patches main alias to default agent main key", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
testState.sessionStorePath = storePath;
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
testState.sessionConfig = { mainKey: "work" };
await writeSessionStore({
storePath,
agentId: "ops",
mainKey: "work",
entries: {
main: {
sessionId: "sess-ops-main",
updatedAt: Date.now(),
},
},
});
const { ws } = await startServerWithClient();
await connectOk(ws);
const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
key: "main",
});
expect(resolved.ok).toBe(true);
expect(resolved.payload?.key).toBe("agent:ops:work");
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
key: "main",
thinkingLevel: "medium",
});
expect(patched.ok).toBe(true);
expect(patched.payload?.key).toBe("agent:ops:work");
const stored = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ thinkingLevel?: string }
>;
expect(stored["agent:ops:work"]?.thinkingLevel).toBe("medium");
expect(stored.main).toBeUndefined();
});
});
+75
View File
@@ -0,0 +1,75 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
vi.mock("../infra/update-runner.js", () => ({
runGatewayUpdate: vi.fn(async () => ({
status: "ok",
mode: "git",
root: "/repo",
steps: [],
durationMs: 12,
})),
}));
import {
connectOk,
installGatewayTestHooks,
onceMessage,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks();
async function waitForSignal(check: () => boolean, timeoutMs = 2000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("timeout");
}
describe("gateway update.run", () => {
it("writes sentinel and schedules restart", async () => {
const sigusr1 = vi.fn();
process.on("SIGUSR1", sigusr1);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const id = "req-update";
ws.send(
JSON.stringify({
type: "req",
id,
method: "update.run",
params: {
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);
await waitForSignal(() => sigusr1.mock.calls.length > 0);
expect(sigusr1).toHaveBeenCalled();
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as {
payload?: { kind?: string; stats?: { mode?: string } };
};
expect(parsed.payload?.kind).toBe("update");
expect(parsed.payload?.stats?.mode).toBe("git");
ws.close();
await server.close();
process.off("SIGUSR1", sigusr1);
});
});
+71 -112
View File
@@ -3,7 +3,7 @@ import { type AddressInfo, createServer } from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from "vitest"; import { afterEach, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js"; import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
@@ -75,79 +75,67 @@ export async function writeSessionStore(params: {
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
} }
async function setupGatewayTestHome() { export function installGatewayTestHooks() {
previousHome = process.env.HOME; beforeEach(async () => {
previousUserProfile = process.env.USERPROFILE; // Some tests intentionally use fake timers; ensure they don't leak into gateway suites.
previousStateDir = process.env.CLAWDBOT_STATE_DIR; vi.useRealTimers();
previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH; setLoggerOverride({ level: "silent", consoleLevel: "silent" });
previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER; previousHome = process.env.HOME;
previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; previousUserProfile = process.env.USERPROFILE;
previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST; previousStateDir = process.env.CLAWDBOT_STATE_DIR;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-")); previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH;
process.env.HOME = tempHome; previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER;
process.env.USERPROFILE = tempHome; previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST;
delete process.env.CLAWDBOT_CONFIG_PATH; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-"));
} process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot");
delete process.env.CLAWDBOT_CONFIG_PATH;
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
tempConfigRoot = path.join(tempHome, ".clawdbot-test");
setTestConfigRoot(tempConfigRoot);
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined;
testState.gatewayAuth = undefined;
testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined;
testState.canvasHostPort = undefined;
testState.legacyIssues = [];
testState.legacyParsed = {};
testState.migrationConfig = null;
testState.migrationChanges = [];
testState.cronEnabled = false;
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
testState.channelsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
agentCommand.mockClear();
embeddedRunMock.activeIds.clear();
embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear();
drainSystemEvents(resolveMainSessionKeyFromConfig());
resetAgentRunContextForTest();
const mod = await serverModulePromise;
mod.__resetModelCatalogCacheForTest();
piSdkMock.enabled = false;
piSdkMock.discoverCalls = 0;
piSdkMock.models = [];
}, 60_000);
function applyGatewaySkipEnv() { afterEach(async () => {
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1"; vi.useRealTimers();
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; resetLogger();
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
}
async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
// Some tests intentionally use fake timers; ensure they don't leak into gateway suites.
vi.useRealTimers();
setLoggerOverride({ level: "silent", consoleLevel: "silent" });
if (!tempHome) {
throw new Error("resetGatewayTestState called before temp home was initialized");
}
applyGatewaySkipEnv();
tempConfigRoot = options.uniqueConfigRoot
? await fs.mkdtemp(path.join(tempHome, "clawdbot-test-"))
: path.join(tempHome, ".clawdbot-test");
setTestConfigRoot(tempConfigRoot);
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined;
testState.gatewayAuth = undefined;
testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined;
testState.canvasHostPort = undefined;
testState.legacyIssues = [];
testState.legacyParsed = {};
testState.migrationConfig = null;
testState.migrationChanges = [];
testState.cronEnabled = false;
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
testState.channelsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
agentCommand.mockClear();
embeddedRunMock.activeIds.clear();
embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear();
drainSystemEvents(resolveMainSessionKeyFromConfig());
resetAgentRunContextForTest();
const mod = await serverModulePromise;
mod.__resetModelCatalogCacheForTest();
piSdkMock.enabled = false;
piSdkMock.discoverCalls = 0;
piSdkMock.models = [];
}
async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
vi.useRealTimers();
resetLogger();
if (options.restoreEnv) {
if (previousHome === undefined) delete process.env.HOME; if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome; else process.env.HOME = previousHome;
if (previousUserProfile === undefined) delete process.env.USERPROFILE; if (previousUserProfile === undefined) delete process.env.USERPROFILE;
@@ -163,45 +151,16 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher; else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher;
if (previousSkipCanvasHost === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST; if (previousSkipCanvasHost === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST;
else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previousSkipCanvasHost; else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previousSkipCanvasHost;
} if (tempHome) {
if (options.restoreEnv && tempHome) { await fs.rm(tempHome, {
await fs.rm(tempHome, { recursive: true,
recursive: true, force: true,
force: true, maxRetries: 20,
maxRetries: 20, retryDelay: 25,
retryDelay: 25, });
}); tempHome = undefined;
tempHome = undefined; }
} tempConfigRoot = undefined;
tempConfigRoot = undefined;
}
export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) {
const scope = options?.scope ?? "test";
if (scope === "suite") {
beforeAll(async () => {
await setupGatewayTestHome();
await resetGatewayTestState({ uniqueConfigRoot: true });
});
beforeEach(async () => {
await resetGatewayTestState({ uniqueConfigRoot: true });
}, 60_000);
afterEach(async () => {
await cleanupGatewayTestHome({ restoreEnv: false });
});
afterAll(async () => {
await cleanupGatewayTestHome({ restoreEnv: true });
});
return;
}
beforeEach(async () => {
await setupGatewayTestHome();
await resetGatewayTestState({ uniqueConfigRoot: false });
}, 60_000);
afterEach(async () => {
await cleanupGatewayTestHome({ restoreEnv: true });
}); });
} }
+1 -1
View File
@@ -29,7 +29,7 @@ export const DEFAULT_PROMPT: Record<MediaUnderstandingCapability, string> = {
export const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB; export const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB;
export const DEFAULT_AUDIO_MODELS: Record<string, string> = { export const DEFAULT_AUDIO_MODELS: Record<string, string> = {
groq: "whisper-large-v3-turbo", groq: "whisper-large-v3-turbo",
openai: "gpt-4o-mini-transcribe", openai: "whisper-1",
deepgram: "nova-3", deepgram: "nova-3",
}; };
export const CLI_OUTPUT_MAX_BUFFER = 5 * MB; export const CLI_OUTPUT_MAX_BUFFER = 5 * MB;
@@ -1,84 +0,0 @@
import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js";
import { normalizeGoogleModelId } from "../../../agents/models-config.providers.js";
import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js";
export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview";
const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio.";
function resolveModel(model?: string): string {
const trimmed = model?.trim();
if (!trimmed) return DEFAULT_GOOGLE_AUDIO_MODEL;
return normalizeGoogleModelId(trimmed);
}
function resolvePrompt(prompt?: string): string {
const trimmed = prompt?.trim();
return trimmed || DEFAULT_GOOGLE_AUDIO_PROMPT;
}
export async function transcribeGeminiAudio(
params: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const fetchFn = params.fetchFn ?? fetch;
const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_GOOGLE_AUDIO_BASE_URL);
const model = resolveModel(params.model);
const url = `${baseUrl}/models/${model}:generateContent`;
const headers = new Headers(params.headers);
if (!headers.has("content-type")) {
headers.set("content-type", "application/json");
}
if (!headers.has("x-goog-api-key")) {
headers.set("x-goog-api-key", params.apiKey);
}
const body = {
contents: [
{
role: "user",
parts: [
{ text: resolvePrompt(params.prompt) },
{
inline_data: {
mime_type: params.mime ?? "audio/wav",
data: params.buffer.toString("base64"),
},
},
],
},
],
};
const res = await fetchWithTimeout(
url,
{
method: "POST",
headers,
body: JSON.stringify(body),
},
params.timeoutMs,
fetchFn,
);
if (!res.ok) {
const detail = await readErrorResponse(res);
const suffix = detail ? `: ${detail}` : "";
throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`);
}
const payload = (await res.json()) as {
candidates?: Array<{
content?: { parts?: Array<{ text?: string }> };
}>;
};
const parts = payload.candidates?.[0]?.content?.parts ?? [];
const text = parts
.map((part) => part?.text?.trim())
.filter(Boolean)
.join("\n");
if (!text) {
throw new Error("Audio transcription response missing text");
}
return { text, model };
}
@@ -1,12 +1,10 @@
import type { MediaUnderstandingProvider } from "../../types.js"; import type { MediaUnderstandingProvider } from "../../types.js";
import { describeImageWithModel } from "../image.js"; import { describeImageWithModel } from "../image.js";
import { transcribeGeminiAudio } from "./audio.js";
import { describeGeminiVideo } from "./video.js"; import { describeGeminiVideo } from "./video.js";
export const googleProvider: MediaUnderstandingProvider = { export const googleProvider: MediaUnderstandingProvider = {
id: "google", id: "google",
capabilities: ["image", "audio", "video"], capabilities: ["image", "audio", "video"],
describeImage: describeImageWithModel, describeImage: describeImageWithModel,
transcribeAudio: transcribeGeminiAudio,
describeVideo: describeGeminiVideo, describeVideo: describeGeminiVideo,
}; };
@@ -59,7 +59,7 @@ describe("transcribeOpenAiCompatibleAudio", () => {
fetchFn, fetchFn,
}); });
expect(result.model).toBe("gpt-4o-mini-transcribe"); expect(result.model).toBe("whisper-1");
expect(result.text).toBe("hello"); expect(result.text).toBe("hello");
expect(seenUrl).toBe("https://api.example.com/v1/audio/transcriptions"); expect(seenUrl).toBe("https://api.example.com/v1/audio/transcriptions");
expect(seenInit?.method).toBe("POST"); expect(seenInit?.method).toBe("POST");
@@ -71,7 +71,7 @@ describe("transcribeOpenAiCompatibleAudio", () => {
const form = seenInit?.body as FormData; const form = seenInit?.body as FormData;
expect(form).toBeInstanceOf(FormData); expect(form).toBeInstanceOf(FormData);
expect(form.get("model")).toBe("gpt-4o-mini-transcribe"); expect(form.get("model")).toBe("whisper-1");
expect(form.get("language")).toBe("en"); expect(form.get("language")).toBe("en");
expect(form.get("prompt")).toBe("hello"); expect(form.get("prompt")).toBe("hello");
const file = form.get("file") as Blob | { type?: string; name?: string } | null; const file = form.get("file") as Blob | { type?: string; name?: string } | null;
@@ -4,7 +4,7 @@ import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../
import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js";
export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; const DEFAULT_OPENAI_AUDIO_MODEL = "whisper-1";
function resolveModel(model?: string): string { function resolveModel(model?: string): string {
const trimmed = model?.trim(); const trimmed = model?.trim();
@@ -15,8 +15,6 @@ import {
describe("runCapability auto audio entries", () => { describe("runCapability auto audio entries", () => {
it("uses provider keys to auto-enable audio transcription", async () => { it("uses provider keys to auto-enable audio transcription", async () => {
const originalPath = process.env.PATH;
process.env.PATH = "/usr/bin:/bin";
const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`); const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`);
await fs.writeFile(tmpPath, Buffer.from("RIFF")); await fs.writeFile(tmpPath, Buffer.from("RIFF"));
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" }; const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
@@ -56,18 +54,15 @@ describe("runCapability auto audio entries", () => {
providerRegistry, providerRegistry,
}); });
expect(result.outputs[0]?.text).toBe("ok"); expect(result.outputs[0]?.text).toBe("ok");
expect(seenModel).toBe("gpt-4o-mini-transcribe"); expect(seenModel).toBe("whisper-1");
expect(result.decision.outcome).toBe("success"); expect(result.decision.outcome).toBe("success");
} finally { } finally {
process.env.PATH = originalPath;
await cache.cleanup(); await cache.cleanup();
await fs.unlink(tmpPath).catch(() => {}); await fs.unlink(tmpPath).catch(() => {});
} }
}); });
it("skips auto audio when disabled", async () => { it("skips auto audio when disabled", async () => {
const originalPath = process.env.PATH;
process.env.PATH = "/usr/bin:/bin";
const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`); const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`);
await fs.writeFile(tmpPath, Buffer.from("RIFF")); await fs.writeFile(tmpPath, Buffer.from("RIFF"));
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" }; const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
@@ -112,7 +107,6 @@ describe("runCapability auto audio entries", () => {
expect(result.outputs).toHaveLength(0); expect(result.outputs).toHaveLength(0);
expect(result.decision.outcome).toBe("disabled"); expect(result.decision.outcome).toBe("disabled");
} finally { } finally {
process.env.PATH = originalPath;
await cache.cleanup(); await cache.cleanup();
await fs.unlink(tmpPath).catch(() => {}); await fs.unlink(tmpPath).catch(() => {});
} }
+38 -472
View File
@@ -1,8 +1,3 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { MsgContext } from "../auto-reply/templating.js"; import type { MsgContext } from "../auto-reply/templating.js";
import { applyTemplate } from "../auto-reply/templating.js"; import { applyTemplate } from "../auto-reply/templating.js";
@@ -21,9 +16,9 @@ import {
} from "./defaults.js"; } from "./defaults.js";
import { isMediaUnderstandingSkipError, MediaUnderstandingSkipError } from "./errors.js"; import { isMediaUnderstandingSkipError, MediaUnderstandingSkipError } from "./errors.js";
import { import {
resolveEntriesWithActiveFallback,
resolveMaxBytes, resolveMaxBytes,
resolveMaxChars, resolveMaxChars,
resolveModelEntries,
resolvePrompt, resolvePrompt,
resolveScopeDecision, resolveScopeDecision,
resolveTimeoutMs, resolveTimeoutMs,
@@ -44,15 +39,7 @@ import {
import { describeImageWithModel } from "./providers/image.js"; import { describeImageWithModel } from "./providers/image.js";
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js"; import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
const AUTO_AUDIO_KEY_PROVIDERS = ["openai", "groq", "deepgram", "google"] as const; const AUTO_AUDIO_PROVIDERS = ["openai", "groq", "deepgram"] as const;
const AUTO_IMAGE_KEY_PROVIDERS = ["openai", "anthropic", "google", "minimax"] as const;
const AUTO_VIDEO_KEY_PROVIDERS = ["google"] as const;
const DEFAULT_IMAGE_MODELS: Record<string, string> = {
openai: "gpt-5-mini",
anthropic: "claude-opus-4-5",
google: "gemini-3-flash-preview",
minimax: "MiniMax-VL-01",
};
export type ActiveMediaModel = { export type ActiveMediaModel = {
provider: string; provider: string;
@@ -80,363 +67,27 @@ export function createMediaAttachmentCache(attachments: MediaAttachment[]): Medi
return new MediaAttachmentCache(attachments); return new MediaAttachmentCache(attachments);
} }
const binaryCache = new Map<string, Promise<string | null>>(); async function resolveAutoAudioEntries(params: {
const geminiProbeCache = new Map<string, Promise<boolean>>();
function expandHomeDir(value: string): string {
if (!value.startsWith("~")) return value;
const home = os.homedir();
if (value === "~") return home;
if (value.startsWith("~/")) return path.join(home, value.slice(2));
return value;
}
function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
function candidateBinaryNames(name: string): string[] {
if (process.platform !== "win32") return [name];
const ext = path.extname(name);
if (ext) return [name];
const pathext = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => (item.startsWith(".") ? item : `.${item}`));
const unique = Array.from(new Set(pathext));
return [name, ...unique.map((item) => `${name}${item}`)];
}
async function isExecutable(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) return false;
if (process.platform === "win32") return true;
await fs.access(filePath, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
async function findBinary(name: string): Promise<string | null> {
const cached = binaryCache.get(name);
if (cached) return cached;
const resolved = (async () => {
const direct = expandHomeDir(name.trim());
if (direct && hasPathSeparator(direct)) {
for (const candidate of candidateBinaryNames(direct)) {
if (await isExecutable(candidate)) return candidate;
}
}
const searchName = name.trim();
if (!searchName) return null;
const pathEntries = (process.env.PATH ?? "").split(path.delimiter);
const candidates = candidateBinaryNames(searchName);
for (const entryRaw of pathEntries) {
const entry = expandHomeDir(entryRaw.trim().replace(/^"(.*)"$/, "$1"));
if (!entry) continue;
for (const candidate of candidates) {
const fullPath = path.join(entry, candidate);
if (await isExecutable(fullPath)) return fullPath;
}
}
return null;
})();
binaryCache.set(name, resolved);
return resolved;
}
async function hasBinary(name: string): Promise<boolean> {
return Boolean(await findBinary(name));
}
async function fileExists(filePath?: string | null): Promise<boolean> {
if (!filePath) return false;
try {
await fs.stat(filePath);
return true;
} catch {
return false;
}
}
function extractLastJsonObject(raw: string): unknown {
const trimmed = raw.trim();
const start = trimmed.lastIndexOf("{");
if (start === -1) return null;
const slice = trimmed.slice(start);
try {
return JSON.parse(slice);
} catch {
return null;
}
}
function extractGeminiResponse(raw: string): string | null {
const payload = extractLastJsonObject(raw);
if (!payload || typeof payload !== "object") return null;
const response = (payload as { response?: unknown }).response;
if (typeof response !== "string") return null;
const trimmed = response.trim();
return trimmed || null;
}
function extractSherpaOnnxText(raw: string): string | null {
const tryParse = (value: string): string | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const head = trimmed[0];
if (head !== "{" && head !== '"') return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed === "string") {
return tryParse(parsed);
}
if (parsed && typeof parsed === "object") {
const text = (parsed as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
return text.trim();
}
}
} catch {}
return null;
};
const direct = tryParse(raw);
if (direct) return direct;
const lines = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (let i = lines.length - 1; i >= 0; i -= 1) {
const parsed = tryParse(lines[i] ?? "");
if (parsed) return parsed;
}
return null;
}
async function probeGeminiCli(): Promise<boolean> {
const cached = geminiProbeCache.get("gemini");
if (cached) return cached;
const resolved = (async () => {
if (!(await hasBinary("gemini"))) return false;
try {
const { stdout } = await runExec("gemini", ["--output-format", "json", "ok"], {
timeoutMs: 8000,
});
return Boolean(extractGeminiResponse(stdout) ?? stdout.toLowerCase().includes("ok"));
} catch {
return false;
}
})();
geminiProbeCache.set("gemini", resolved);
return resolved;
}
async function resolveLocalWhisperCppEntry(): Promise<MediaUnderstandingModelConfig | null> {
if (!(await hasBinary("whisper-cli"))) return null;
const envModel = process.env.WHISPER_CPP_MODEL?.trim();
const defaultModel = "/opt/homebrew/share/whisper-cpp/for-tests-ggml-tiny.bin";
const modelPath = envModel && (await fileExists(envModel)) ? envModel : defaultModel;
if (!(await fileExists(modelPath))) return null;
return {
type: "cli",
command: "whisper-cli",
args: ["-m", modelPath, "-otxt", "-of", "{{OutputBase}}", "-np", "-nt", "{{MediaPath}}"],
};
}
async function resolveLocalWhisperEntry(): Promise<MediaUnderstandingModelConfig | null> {
if (!(await hasBinary("whisper"))) return null;
return {
type: "cli",
command: "whisper",
args: [
"--model",
"turbo",
"--output_format",
"txt",
"--output_dir",
"{{OutputDir}}",
"--verbose",
"False",
"{{MediaPath}}",
],
};
}
async function resolveSherpaOnnxEntry(): Promise<MediaUnderstandingModelConfig | null> {
if (!(await hasBinary("sherpa-onnx-offline"))) return null;
const modelDir = process.env.SHERPA_ONNX_MODEL_DIR?.trim();
if (!modelDir) return null;
const tokens = path.join(modelDir, "tokens.txt");
const encoder = path.join(modelDir, "encoder.onnx");
const decoder = path.join(modelDir, "decoder.onnx");
const joiner = path.join(modelDir, "joiner.onnx");
if (!(await fileExists(tokens))) return null;
if (!(await fileExists(encoder))) return null;
if (!(await fileExists(decoder))) return null;
if (!(await fileExists(joiner))) return null;
return {
type: "cli",
command: "sherpa-onnx-offline",
args: [
`--tokens=${tokens}`,
`--encoder=${encoder}`,
`--decoder=${decoder}`,
`--joiner=${joiner}`,
"{{MediaPath}}",
],
};
}
async function resolveLocalAudioEntry(): Promise<MediaUnderstandingModelConfig | null> {
const sherpa = await resolveSherpaOnnxEntry();
if (sherpa) return sherpa;
const whisperCpp = await resolveLocalWhisperCppEntry();
if (whisperCpp) return whisperCpp;
return await resolveLocalWhisperEntry();
}
async function resolveGeminiCliEntry(
_capability: MediaUnderstandingCapability,
): Promise<MediaUnderstandingModelConfig | null> {
if (!(await probeGeminiCli())) return null;
return {
type: "cli",
command: "gemini",
args: [
"--output-format",
"json",
"--allowed-tools",
"read_many_files",
"--include-directories",
"{{MediaDir}}",
"{{Prompt}}",
"Use read_many_files to read {{MediaPath}} and respond with only the text output.",
],
};
}
async function resolveKeyEntry(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentDir?: string; agentDir?: string;
providerRegistry: ProviderRegistry; providerRegistry: ProviderRegistry;
capability: MediaUnderstandingCapability;
activeModel?: ActiveMediaModel;
}): Promise<MediaUnderstandingModelConfig | null> {
const { cfg, agentDir, providerRegistry, capability } = params;
const checkProvider = async (
providerId: string,
model?: string,
): Promise<MediaUnderstandingModelConfig | null> => {
const provider = getMediaUnderstandingProvider(providerId, providerRegistry);
if (!provider) return null;
if (capability === "audio" && !provider.transcribeAudio) return null;
if (capability === "image" && !provider.describeImage) return null;
if (capability === "video" && !provider.describeVideo) return null;
try {
await resolveApiKeyForProvider({ provider: providerId, cfg, agentDir });
return { type: "provider" as const, provider: providerId, model };
} catch {
return null;
}
};
if (capability === "image") {
const activeProvider = params.activeModel?.provider?.trim();
if (activeProvider) {
const activeEntry = await checkProvider(activeProvider, params.activeModel?.model);
if (activeEntry) return activeEntry;
}
for (const providerId of AUTO_IMAGE_KEY_PROVIDERS) {
const model = DEFAULT_IMAGE_MODELS[providerId];
const entry = await checkProvider(providerId, model);
if (entry) return entry;
}
return null;
}
if (capability === "video") {
const activeProvider = params.activeModel?.provider?.trim();
if (activeProvider) {
const activeEntry = await checkProvider(activeProvider, params.activeModel?.model);
if (activeEntry) return activeEntry;
}
for (const providerId of AUTO_VIDEO_KEY_PROVIDERS) {
const entry = await checkProvider(providerId, undefined);
if (entry) return entry;
}
return null;
}
const activeProvider = params.activeModel?.provider?.trim();
if (activeProvider) {
const activeEntry = await checkProvider(activeProvider, params.activeModel?.model);
if (activeEntry) return activeEntry;
}
for (const providerId of AUTO_AUDIO_KEY_PROVIDERS) {
const entry = await checkProvider(providerId, undefined);
if (entry) return entry;
}
return null;
}
async function resolveAutoEntries(params: {
cfg: ClawdbotConfig;
agentDir?: string;
providerRegistry: ProviderRegistry;
capability: MediaUnderstandingCapability;
activeModel?: ActiveMediaModel;
}): Promise<MediaUnderstandingModelConfig[]> { }): Promise<MediaUnderstandingModelConfig[]> {
const activeEntry = await resolveActiveModelEntry(params); const entries: MediaUnderstandingModelConfig[] = [];
if (activeEntry) return [activeEntry]; for (const providerId of AUTO_AUDIO_PROVIDERS) {
if (params.capability === "audio") { const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
const localAudio = await resolveLocalAudioEntry(); if (!provider?.transcribeAudio) continue;
if (localAudio) return [localAudio]; try {
await resolveApiKeyForProvider({
provider: providerId,
cfg: params.cfg,
agentDir: params.agentDir,
});
entries.push({ type: "provider", provider: providerId });
} catch {
continue;
}
} }
const gemini = await resolveGeminiCliEntry(params.capability); return entries;
if (gemini) return [gemini];
const keys = await resolveKeyEntry(params);
if (keys) return [keys];
return [];
}
async function resolveActiveModelEntry(params: {
cfg: ClawdbotConfig;
agentDir?: string;
providerRegistry: ProviderRegistry;
capability: MediaUnderstandingCapability;
activeModel?: ActiveMediaModel;
}): Promise<MediaUnderstandingModelConfig | null> {
const activeProviderRaw = params.activeModel?.provider?.trim();
if (!activeProviderRaw) return null;
const providerId = normalizeMediaProviderId(activeProviderRaw);
if (!providerId) return null;
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
if (!provider) return null;
if (params.capability === "audio" && !provider.transcribeAudio) return null;
if (params.capability === "image" && !provider.describeImage) return null;
if (params.capability === "video" && !provider.describeVideo) return null;
try {
await resolveApiKeyForProvider({
provider: providerId,
cfg: params.cfg,
agentDir: params.agentDir,
});
} catch {
return null;
}
return {
type: "provider",
provider: providerId,
model: params.activeModel?.model,
};
} }
function trimOutput(text: string, maxChars?: number): string { function trimOutput(text: string, maxChars?: number): string {
@@ -445,74 +96,6 @@ function trimOutput(text: string, maxChars?: number): string {
return trimmed.slice(0, maxChars).trim(); return trimmed.slice(0, maxChars).trim();
} }
function commandBase(command: string): string {
return path.parse(command).name;
}
function findArgValue(args: string[], keys: string[]): string | undefined {
for (let i = 0; i < args.length; i += 1) {
if (keys.includes(args[i] ?? "")) {
const value = args[i + 1];
if (value) return value;
}
}
return undefined;
}
function hasArg(args: string[], keys: string[]): boolean {
return args.some((arg) => keys.includes(arg));
}
function resolveWhisperOutputPath(args: string[], mediaPath: string): string | null {
const outputDir = findArgValue(args, ["--output_dir", "-o"]);
const outputFormat = findArgValue(args, ["--output_format"]);
if (!outputDir || !outputFormat) return null;
const formats = outputFormat.split(",").map((value) => value.trim());
if (!formats.includes("txt")) return null;
const base = path.parse(mediaPath).name;
return path.join(outputDir, `${base}.txt`);
}
function resolveWhisperCppOutputPath(args: string[]): string | null {
if (!hasArg(args, ["-otxt", "--output-txt"])) return null;
const outputBase = findArgValue(args, ["-of", "--output-file"]);
if (!outputBase) return null;
return `${outputBase}.txt`;
}
async function resolveCliOutput(params: {
command: string;
args: string[];
stdout: string;
mediaPath: string;
}): Promise<string> {
const commandId = commandBase(params.command);
const fileOutput =
commandId === "whisper-cli"
? resolveWhisperCppOutputPath(params.args)
: commandId === "whisper"
? resolveWhisperOutputPath(params.args, params.mediaPath)
: null;
if (fileOutput && (await fileExists(fileOutput))) {
try {
const content = await fs.readFile(fileOutput, "utf8");
if (content.trim()) return content.trim();
} catch {}
}
if (commandId === "gemini") {
const response = extractGeminiResponse(params.stdout);
if (response) return response;
}
if (commandId === "sherpa-onnx-offline") {
const response = extractSherpaOnnxText(params.stdout);
if (response) return response;
}
return params.stdout.trim();
}
type ProviderQuery = Record<string, string | number | boolean>; type ProviderQuery = Record<string, string | number | boolean>;
function normalizeProviderQuery( function normalizeProviderQuery(
@@ -839,48 +422,32 @@ async function runCliEntry(params: {
maxBytes, maxBytes,
timeoutMs, timeoutMs,
}); });
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-media-cli-"));
const mediaPath = pathResult.path;
const outputBase = path.join(outputDir, path.parse(mediaPath).name);
const templCtx: MsgContext = { const templCtx: MsgContext = {
...ctx, ...ctx,
MediaPath: mediaPath, MediaPath: pathResult.path,
MediaDir: path.dirname(mediaPath),
OutputDir: outputDir,
OutputBase: outputBase,
Prompt: prompt, Prompt: prompt,
MaxChars: maxChars, MaxChars: maxChars,
}; };
const argv = [command, ...args].map((part, index) => const argv = [command, ...args].map((part, index) =>
index === 0 ? part : applyTemplate(part, templCtx), index === 0 ? part : applyTemplate(part, templCtx),
); );
try { if (shouldLogVerbose()) {
if (shouldLogVerbose()) { logVerbose(`Media understanding via CLI: ${argv.join(" ")}`);
logVerbose(`Media understanding via CLI: ${argv.join(" ")}`);
}
const { stdout } = await runExec(argv[0], argv.slice(1), {
timeoutMs,
maxBuffer: CLI_OUTPUT_MAX_BUFFER,
});
const resolved = await resolveCliOutput({
command,
args: argv.slice(1),
stdout,
mediaPath,
});
const text = trimOutput(resolved, maxChars);
if (!text) return null;
return {
kind: capability === "audio" ? "audio.transcription" : `${capability}.description`,
attachmentIndex: params.attachmentIndex,
text,
provider: "cli",
model: command,
};
} finally {
await fs.rm(outputDir, { recursive: true, force: true }).catch(() => {});
} }
const { stdout } = await runExec(argv[0], argv.slice(1), {
timeoutMs,
maxBuffer: CLI_OUTPUT_MAX_BUFFER,
});
const text = trimOutput(stdout, maxChars);
if (!text) return null;
return {
kind: capability === "audio" ? "audio.transcription" : `${capability}.description`,
attachmentIndex: params.attachmentIndex,
text,
provider: "cli",
model: command,
};
} }
async function runAttachmentEntries(params: { async function runAttachmentEntries(params: {
@@ -1014,20 +581,19 @@ export async function runCapability(params: {
}; };
} }
const entries = resolveModelEntries({ const entries = resolveEntriesWithActiveFallback({
cfg, cfg,
capability, capability,
config, config,
providerRegistry: params.providerRegistry, providerRegistry: params.providerRegistry,
activeModel: params.activeModel,
}); });
let resolvedEntries = entries; let resolvedEntries = entries;
if (resolvedEntries.length === 0) { if (resolvedEntries.length === 0 && capability === "audio") {
resolvedEntries = await resolveAutoEntries({ resolvedEntries = await resolveAutoAudioEntries({
cfg, cfg,
agentDir: params.agentDir, agentDir: params.agentDir,
providerRegistry: params.providerRegistry, providerRegistry: params.providerRegistry,
capability,
activeModel: params.activeModel,
}); });
} }
if (resolvedEntries.length === 0) { if (resolvedEntries.length === 0) {
+1 -1
View File
@@ -75,7 +75,7 @@ describe("memory search async sync", () => {
const resolved = await Promise.race([ const resolved = await Promise.race([
manager.search("hello").then(() => true), manager.search("hello").then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 1000)), new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 50)),
]); ]);
expect(resolved).toBe(true); expect(resolved).toBe(true);
}); });
-14
View File
@@ -30,7 +30,6 @@ describe("memory indexing with OpenAI batches", () => {
let workspaceDir: string; let workspaceDir: string;
let indexPath: string; let indexPath: string;
let manager: MemoryIndexManager | null = null; let manager: MemoryIndexManager | null = null;
let setTimeoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => { beforeEach(async () => {
embedBatch.mockClear(); embedBatch.mockClear();
@@ -38,18 +37,6 @@ describe("memory indexing with OpenAI batches", () => {
embedBatch.mockImplementation(async (texts: string[]) => embedBatch.mockImplementation(async (texts: string[]) =>
texts.map((_text, index) => [index + 1, 0, 0]), texts.map((_text, index) => [index + 1, 0, 0]),
); );
const realSetTimeout = setTimeout;
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const delay = typeof timeout === "number" ? timeout : 0;
if (delay > 0 && delay <= 2000) {
return realSetTimeout(handler, 0, ...args);
}
return realSetTimeout(handler, delay, ...args);
}) as typeof setTimeout);
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-mem-batch-")); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-mem-batch-"));
indexPath = path.join(workspaceDir, "index.sqlite"); indexPath = path.join(workspaceDir, "index.sqlite");
await fs.mkdir(path.join(workspaceDir, "memory")); await fs.mkdir(path.join(workspaceDir, "memory"));
@@ -57,7 +44,6 @@ describe("memory indexing with OpenAI batches", () => {
afterEach(async () => { afterEach(async () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
setTimeoutSpy.mockRestore();
if (manager) { if (manager) {
await manager.close(); await manager.close();
manager = null; manager = null;
+9 -41
View File
@@ -44,7 +44,7 @@ describe("memory embedding batches", () => {
it("splits large files across multiple embedding batches", async () => { it("splits large files across multiple embedding batches", async () => {
const line = "a".repeat(200); const line = "a".repeat(200);
const content = Array.from({ length: 50 }, () => line).join("\n"); const content = Array.from({ length: 200 }, () => line).join("\n");
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-03.md"), content); await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-03.md"), content);
const cfg = { const cfg = {
@@ -78,7 +78,7 @@ describe("memory embedding batches", () => {
it("keeps small files in a single embedding batch", async () => { it("keeps small files in a single embedding batch", async () => {
const line = "b".repeat(120); const line = "b".repeat(120);
const content = Array.from({ length: 4 }, () => line).join("\n"); const content = Array.from({ length: 12 }, () => line).join("\n");
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-04.md"), content); await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-04.md"), content);
const cfg = { const cfg = {
@@ -109,7 +109,7 @@ describe("memory embedding batches", () => {
it("reports sync progress totals", async () => { it("reports sync progress totals", async () => {
const line = "c".repeat(120); const line = "c".repeat(120);
const content = Array.from({ length: 8 }, () => line).join("\n"); const content = Array.from({ length: 20 }, () => line).join("\n");
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-05.md"), content); await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-05.md"), content);
const cfg = { const cfg = {
@@ -150,7 +150,7 @@ describe("memory embedding batches", () => {
it("retries embeddings on rate limit errors", async () => { it("retries embeddings on rate limit errors", async () => {
const line = "d".repeat(120); const line = "d".repeat(120);
const content = Array.from({ length: 4 }, () => line).join("\n"); const content = Array.from({ length: 12 }, () => line).join("\n");
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-06.md"), content); await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-06.md"), content);
let calls = 0; let calls = 0;
@@ -162,19 +162,6 @@ describe("memory embedding batches", () => {
return texts.map(() => [0, 1, 0]); return texts.map(() => [0, 1, 0]);
}); });
const realSetTimeout = setTimeout;
const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const delay = typeof timeout === "number" ? timeout : 0;
if (delay > 0 && delay <= 2000) {
return realSetTimeout(handler, 0, ...args);
}
return realSetTimeout(handler, delay, ...args);
}) as typeof setTimeout);
const cfg = { const cfg = {
agents: { agents: {
defaults: { defaults: {
@@ -196,18 +183,15 @@ describe("memory embedding batches", () => {
expect(result.manager).not.toBeNull(); expect(result.manager).not.toBeNull();
if (!result.manager) throw new Error("manager missing"); if (!result.manager) throw new Error("manager missing");
manager = result.manager; manager = result.manager;
try {
await manager.sync({ force: true }); await manager.sync({ force: true });
} finally {
setTimeoutSpy.mockRestore();
}
expect(calls).toBe(3); expect(calls).toBe(3);
}, 10000); }, 10000);
it("retries embeddings on transient 5xx errors", async () => { it("retries embeddings on transient 5xx errors", async () => {
const line = "e".repeat(120); const line = "e".repeat(120);
const content = Array.from({ length: 4 }, () => line).join("\n"); const content = Array.from({ length: 12 }, () => line).join("\n");
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-08.md"), content); await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-08.md"), content);
let calls = 0; let calls = 0;
@@ -219,19 +203,6 @@ describe("memory embedding batches", () => {
return texts.map(() => [0, 1, 0]); return texts.map(() => [0, 1, 0]);
}); });
const realSetTimeout = setTimeout;
const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const delay = typeof timeout === "number" ? timeout : 0;
if (delay > 0 && delay <= 2000) {
return realSetTimeout(handler, 0, ...args);
}
return realSetTimeout(handler, delay, ...args);
}) as typeof setTimeout);
const cfg = { const cfg = {
agents: { agents: {
defaults: { defaults: {
@@ -253,11 +224,8 @@ describe("memory embedding batches", () => {
expect(result.manager).not.toBeNull(); expect(result.manager).not.toBeNull();
if (!result.manager) throw new Error("manager missing"); if (!result.manager) throw new Error("manager missing");
manager = result.manager; manager = result.manager;
try {
await manager.sync({ force: true }); await manager.sync({ force: true });
} finally {
setTimeoutSpy.mockRestore();
}
expect(calls).toBe(3); expect(calls).toBe(3);
}, 10000); }, 10000);
-1
View File
@@ -230,7 +230,6 @@ export {
listSlackAccountIds, listSlackAccountIds,
resolveDefaultSlackAccountId, resolveDefaultSlackAccountId,
resolveSlackAccount, resolveSlackAccount,
resolveSlackReplyToMode,
type ResolvedSlackAccount, type ResolvedSlackAccount,
} from "../slack/accounts.js"; } from "../slack/accounts.js";
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
+2 -28
View File
@@ -98,34 +98,8 @@ describe("loadClawdbotPlugins", () => {
expect(enabled?.status).toBe("loaded"); expect(enabled?.status).toBe("loaded");
}); });
it("loads bundled telegram plugin when enabled", () => { it("loads bundled telegram plugin when enabled", { timeout: 120_000 }, () => {
const bundledDir = makeTempDir(); process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = path.join(process.cwd(), "extensions");
writePlugin({
id: "telegram",
body: `export default { id: "telegram", register(api) {
api.registerChannel({
plugin: {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "telegram channel"
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" })
},
outbound: { deliveryMode: "direct" }
}
});
} };`,
dir: bundledDir,
filename: "telegram.ts",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
cache: false, cache: false,
-17
View File
@@ -1,6 +1,5 @@
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { SlackAccountConfig } from "../config/types.js"; import type { SlackAccountConfig } from "../config/types.js";
import { normalizeChatType } from "../channels/chat-type.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
@@ -21,7 +20,6 @@ export type ResolvedSlackAccount = {
reactionNotifications?: SlackAccountConfig["reactionNotifications"]; reactionNotifications?: SlackAccountConfig["reactionNotifications"];
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
replyToMode?: SlackAccountConfig["replyToMode"]; replyToMode?: SlackAccountConfig["replyToMode"];
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
actions?: SlackAccountConfig["actions"]; actions?: SlackAccountConfig["actions"];
slashCommand?: SlackAccountConfig["slashCommand"]; slashCommand?: SlackAccountConfig["slashCommand"];
dm?: SlackAccountConfig["dm"]; dm?: SlackAccountConfig["dm"];
@@ -97,7 +95,6 @@ export function resolveSlackAccount(params: {
reactionNotifications: merged.reactionNotifications, reactionNotifications: merged.reactionNotifications,
reactionAllowlist: merged.reactionAllowlist, reactionAllowlist: merged.reactionAllowlist,
replyToMode: merged.replyToMode, replyToMode: merged.replyToMode,
replyToModeByChatType: merged.replyToModeByChatType,
actions: merged.actions, actions: merged.actions,
slashCommand: merged.slashCommand, slashCommand: merged.slashCommand,
dm: merged.dm, dm: merged.dm,
@@ -110,17 +107,3 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco
.map((accountId) => resolveSlackAccount({ cfg, accountId })) .map((accountId) => resolveSlackAccount({ cfg, accountId }))
.filter((account) => account.enabled); .filter((account) => account.enabled);
} }
export function resolveSlackReplyToMode(
account: ResolvedSlackAccount,
chatType?: string | null,
): "off" | "first" | "all" {
const normalized = normalizeChatType(chatType ?? undefined);
if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) {
return account.replyToModeByChatType[normalized] ?? "off";
}
if (normalized === "direct" && account.dm?.replyToMode !== undefined) {
return account.dm.replyToMode;
}
return account.replyToMode ?? "off";
}
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { WebClient } from "@slack/web-api";
import { readSlackMessages } from "./actions.js";
function createClient() {
return {
conversations: {
replies: vi.fn(async () => ({ messages: [], has_more: false })),
history: vi.fn(async () => ({ messages: [], has_more: false })),
},
} as unknown as WebClient & {
conversations: {
replies: ReturnType<typeof vi.fn>;
history: ReturnType<typeof vi.fn>;
};
};
}
describe("readSlackMessages", () => {
it("uses conversations.replies and drops the parent message", async () => {
const client = createClient();
client.conversations.replies.mockResolvedValueOnce({
messages: [{ ts: "171234.567" }, { ts: "171234.890" }, { ts: "171235.000" }],
has_more: true,
});
const result = await readSlackMessages("C1", {
client,
threadId: "171234.567",
token: "xoxb-test",
});
expect(client.conversations.replies).toHaveBeenCalledWith({
channel: "C1",
ts: "171234.567",
limit: undefined,
latest: undefined,
oldest: undefined,
});
expect(client.conversations.history).not.toHaveBeenCalled();
expect(result.messages.map((message) => message.ts)).toEqual(["171234.890", "171235.000"]);
});
it("uses conversations.history when threadId is missing", async () => {
const client = createClient();
client.conversations.history.mockResolvedValueOnce({
messages: [{ ts: "1" }],
has_more: false,
});
const result = await readSlackMessages("C1", {
client,
limit: 20,
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: 20,
latest: undefined,
oldest: undefined,
});
expect(client.conversations.replies).not.toHaveBeenCalled();
expect(result.messages.map((message) => message.ts)).toEqual(["1"]);
});
});
+2 -23
View File
@@ -1,9 +1,8 @@
import type { WebClient } from "@slack/web-api"; import { WebClient } from "@slack/web-api";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js"; import { logVerbose } from "../globals.js";
import { resolveSlackAccount } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js";
import { sendMessageSlack } from "./send.js"; import { sendMessageSlack } from "./send.js";
import { resolveSlackBotToken } from "./token.js"; import { resolveSlackBotToken } from "./token.js";
@@ -57,7 +56,7 @@ function normalizeEmoji(raw: string) {
async function getClient(opts: SlackActionClientOpts = {}) { async function getClient(opts: SlackActionClientOpts = {}) {
const token = resolveToken(opts.token, opts.accountId); const token = resolveToken(opts.token, opts.accountId);
return opts.client ?? createSlackWebClient(token); return opts.client ?? new WebClient(token);
} }
async function resolveBotUserId(client: WebClient) { async function resolveBotUserId(client: WebClient) {
@@ -187,29 +186,9 @@ export async function readSlackMessages(
limit?: number; limit?: number;
before?: string; before?: string;
after?: string; after?: string;
threadId?: string;
} = {}, } = {},
): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> { ): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> {
const client = await getClient(opts); const client = await getClient(opts);
// Use conversations.replies for thread messages, conversations.history for channel messages.
if (opts.threadId) {
const result = await client.conversations.replies({
channel: channelId,
ts: opts.threadId,
limit: opts.limit,
latest: opts.before,
oldest: opts.after,
});
return {
// conversations.replies includes the parent message; drop it for replies-only reads.
messages: (result.messages ?? []).filter(
(message) => (message as SlackMessageSummary)?.ts !== opts.threadId,
) as SlackMessageSummary[],
hasMore: Boolean(result.has_more),
};
}
const result = await client.conversations.history({ const result = await client.conversations.history({
channel: channelId, channel: channelId,
limit: opts.limit, limit: opts.limit,
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@slack/web-api", () => {
const WebClient = vi.fn(function WebClientMock(
this: Record<string, unknown>,
token: string,
options?: Record<string, unknown>,
) {
this.token = token;
this.options = options;
});
return { WebClient };
});
const slackWebApi = await import("@slack/web-api");
const { createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
await import("./client.js");
const WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
describe("slack web client config", () => {
it("applies the default retry config when none is provided", () => {
const options = resolveSlackWebClientOptions();
expect(options.retryConfig).toEqual(SLACK_DEFAULT_RETRY_OPTIONS);
});
it("respects explicit retry config overrides", () => {
const customRetry = { retries: 0 };
const options = resolveSlackWebClientOptions({ retryConfig: customRetry });
expect(options.retryConfig).toBe(customRetry);
});
it("passes merged options into WebClient", () => {
createSlackWebClient("xoxb-test", { timeout: 1234 });
expect(WebClient).toHaveBeenCalledWith(
"xoxb-test",
expect.objectContaining({
timeout: 1234,
retryConfig: SLACK_DEFAULT_RETRY_OPTIONS,
}),
);
});
});
-20
View File
@@ -1,20 +0,0 @@
import { type RetryOptions, type WebClientOptions, WebClient } from "@slack/web-api";
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
retries: 2,
factor: 2,
minTimeout: 500,
maxTimeout: 3000,
randomize: true,
};
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
return {
...options,
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
};
}
export function createSlackWebClient(token: string, options: WebClientOptions = {}) {
return new WebClient(token, resolveSlackWebClientOptions(options));
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { createSlackWebClient } from "./client.js"; import { WebClient } from "@slack/web-api";
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
@@ -61,7 +61,7 @@ export async function listSlackDirectoryPeersLive(
): Promise<ChannelDirectoryEntry[]> { ): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params); const token = resolveReadToken(params);
if (!token) return []; if (!token) return [];
const client = createSlackWebClient(token); const client = new WebClient(token);
const query = normalizeQuery(params.query); const query = normalizeQuery(params.query);
const members: SlackUser[] = []; const members: SlackUser[] = [];
let cursor: string | undefined; let cursor: string | undefined;
@@ -119,7 +119,7 @@ export async function listSlackDirectoryGroupsLive(
): Promise<ChannelDirectoryEntry[]> { ): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params); const token = resolveReadToken(params);
if (!token) return []; if (!token) return [];
const client = createSlackWebClient(token); const client = new WebClient(token);
const query = normalizeQuery(params.query); const query = normalizeQuery(params.query);
const channels: SlackChannel[] = []; const channels: SlackChannel[] = [];
let cursor: string | undefined; let cursor: string | undefined;
+1 -3
View File
@@ -53,7 +53,6 @@ export type SlackThreadStarter = {
text: string; text: string;
userId?: string; userId?: string;
ts?: string; ts?: string;
files?: SlackFile[];
}; };
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>(); const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
@@ -72,7 +71,7 @@ export async function resolveSlackThreadStarter(params: {
ts: params.threadTs, ts: params.threadTs,
limit: 1, limit: 1,
inclusive: true, inclusive: true,
})) as { messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }> }; })) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
const message = response?.messages?.[0]; const message = response?.messages?.[0];
const text = (message?.text ?? "").trim(); const text = (message?.text ?? "").trim();
if (!message || !text) return null; if (!message || !text) return null;
@@ -80,7 +79,6 @@ export async function resolveSlackThreadStarter(params: {
text, text,
userId: message.user, userId: message.user,
ts: message.ts, ts: message.ts,
files: message.files,
}; };
THREAD_STARTER_CACHE.set(cacheKey, starter); THREAD_STARTER_CACHE.set(cacheKey, starter);
return starter; return starter;
+3 -20
View File
@@ -434,7 +434,6 @@ export async function prepareSlackMessage(params: {
let threadStarterBody: string | undefined; let threadStarterBody: string | undefined;
let threadLabel: string | undefined; let threadLabel: string | undefined;
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null;
if (isThreadReply && threadTs) { if (isThreadReply && threadTs) {
const starter = await resolveSlackThreadStarter({ const starter = await resolveSlackThreadStarter({
channelId: message.channel, channelId: message.channel,
@@ -454,27 +453,11 @@ export async function prepareSlackMessage(params: {
}); });
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80); const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`; threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
// If current message has no files but thread starter does, fetch starter's files
if (!media && starter.files && starter.files.length > 0) {
threadStarterMedia = await resolveSlackMedia({
files: starter.files,
token: ctx.botToken,
maxBytes: ctx.mediaMaxBytes,
});
if (threadStarterMedia) {
logVerbose(
`slack: hydrated thread starter file ${threadStarterMedia.placeholder} from root message`,
);
}
}
} else { } else {
threadLabel = `Slack thread ${roomLabel}`; threadLabel = `Slack thread ${roomLabel}`;
} }
} }
// Use thread starter media if current message has none
const effectiveMedia = media ?? threadStarterMedia;
const ctxPayload = finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
RawBody: rawBody, RawBody: rawBody,
@@ -500,9 +483,9 @@ export async function prepareSlackMessage(params: {
ThreadLabel: threadLabel, ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? effectiveWasMentioned : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
MediaPath: effectiveMedia?.path, MediaPath: media?.path,
MediaType: effectiveMedia?.contentType, MediaType: media?.contentType,
MediaUrl: effectiveMedia?.path, MediaUrl: media?.path,
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const, OriginatingChannel: "slack" as const,
OriginatingTo: slackTo, OriginatingTo: slackTo,
-4
View File
@@ -17,7 +17,6 @@ import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js"; import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js"; import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { resolveSlackWebClientOptions } from "../client.js";
import { resolveSlackSlashCommandConfig } from "./commands.js"; import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js"; import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js"; import { registerSlackMonitorEvents } from "./events.js";
@@ -131,19 +130,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
endpoints: slackWebhookPath, endpoints: slackWebhookPath,
}) })
: null; : null;
const clientOptions = resolveSlackWebClientOptions();
const app = new App( const app = new App(
slackMode === "socket" slackMode === "socket"
? { ? {
token: botToken, token: botToken,
appToken, appToken,
socketMode: true, socketMode: true,
clientOptions,
} }
: { : {
token: botToken, token: botToken,
receiver: receiver ?? undefined, receiver: receiver ?? undefined,
clientOptions,
}, },
); );
const slackHttpHandler = const slackHttpHandler =
+2 -2
View File
@@ -1,4 +1,4 @@
import { createSlackWebClient } from "./client.js"; import { WebClient } from "@slack/web-api";
export type SlackProbe = { export type SlackProbe = {
ok: boolean; ok: boolean;
@@ -21,7 +21,7 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
} }
export async function probeSlack(token: string, timeoutMs = 2500): Promise<SlackProbe> { export async function probeSlack(token: string, timeoutMs = 2500): Promise<SlackProbe> {
const client = createSlackWebClient(token); const client = new WebClient(token);
const start = Date.now(); const start = Date.now();
try { try {
const result = await withTimeout(client.auth.test(), timeoutMs); const result = await withTimeout(client.auth.test(), timeoutMs);
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more