Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b24bf7ac1 | ||
|
|
a61c8a2317 | ||
|
|
c717258a8f |
No files matched your search
+1
-8
@@ -6,10 +6,8 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Changes
|
||||
- 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
|
||||
- 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.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
@@ -30,26 +28,21 @@ Docs: https://docs.clawd.bot
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||
- Agents: make tool summaries more readable and only show optional params when set.
|
||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436) Thanks @sibbl.
|
||||
- CLI: prefer `~` for home paths in output.
|
||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: 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/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).
|
||||
|
||||
## 2026.1.21-2
|
||||
|
||||
@@ -102,9 +102,7 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
|
||||
"It will only attach to an existing Gateway.")
|
||||
Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
|
||||
@@ -248,11 +248,12 @@ final class GatewayProcessManager {
|
||||
guard let linkId else {
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
let linked = snap.channels[linkId]?.linked ?? false
|
||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
snap.channelLabels?[linkId] ??
|
||||
linkId.capitalized
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"channel"
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
|
||||
+6
-55
@@ -362,72 +362,23 @@ 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`).
|
||||
|
||||
### Per-chat-type threading
|
||||
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
|
||||
### DM-specific threading
|
||||
You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off", // default for channels
|
||||
replyToModeByChatType: {
|
||||
direct: "all", // DMs always thread
|
||||
group: "first" // group DMs/MPIM thread first reply
|
||||
},
|
||||
dm: {
|
||||
replyToMode: "all" // DMs always thread
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
When `dm.replyToMode` is set, DMs use that mode; channels use the top-level `replyToMode`. If `dm.replyToMode` is not set, both DMs and channels use the top-level setting.
|
||||
|
||||
### Manual threading tags
|
||||
For fine-grained control, use these tags in agent responses:
|
||||
|
||||
@@ -129,7 +129,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
// Optional CLI fallback (Whisper binary):
|
||||
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
],
|
||||
|
||||
@@ -1865,7 +1865,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
||||
- Each `models[]` entry:
|
||||
- Provider entry (`type: "provider"` or omitted):
|
||||
- `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.
|
||||
- CLI entry (`type: "cli"`):
|
||||
- `command`: executable to run.
|
||||
@@ -1890,7 +1890,7 @@ Example:
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
]
|
||||
},
|
||||
|
||||
+3
-19
@@ -6,7 +6,7 @@ read_when:
|
||||
# Audio / Voice Notes — 2026-01-17
|
||||
|
||||
## 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.
|
||||
2) Enforces `maxBytes` before sending to each model entry.
|
||||
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.
|
||||
- **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
|
||||
|
||||
### 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,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
@@ -69,7 +54,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
]
|
||||
},
|
||||
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`.
|
||||
- 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.
|
||||
- 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`).
|
||||
- Transcript is available to templates as `{{Transcript}}`.
|
||||
- CLI stdout is capped (5MB); keep CLI output concise.
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
---
|
||||
# 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
|
||||
- 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
|
||||
Recommended defaults:
|
||||
- `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
|
||||
**active reply model** when its provider supports the capability.
|
||||
|
||||
### Auto-detect media understanding (default)
|
||||
If `tools.media.<capability>.enabled` is **not** set to `false` and you haven’t
|
||||
configured models, Clawdbot auto-detects in this order and **stops at the first
|
||||
working option**:
|
||||
### Auto-enable audio (when keys exist)
|
||||
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||
even when you haven’t listed models explicitly.
|
||||
|
||||
1) **Local CLIs** (audio only; if installed)
|
||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
||||
- `whisper-cli` (`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**
|
||||
- Audio: OpenAI → Groq → Deepgram → Google
|
||||
- Image: OpenAI → Anthropic → Google → MiniMax
|
||||
- Video: Google
|
||||
Providers checked (in order):
|
||||
1) OpenAI
|
||||
2) Groq
|
||||
3) Deepgram
|
||||
|
||||
To disable auto-detection, set:
|
||||
To disable this behavior, set:
|
||||
```json5
|
||||
{
|
||||
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)
|
||||
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 |
|
||||
|------------|----------------------|-------|
|
||||
| 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. |
|
||||
|
||||
## 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`.
|
||||
|
||||
**Audio**
|
||||
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
@@ -220,7 +209,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
|
||||
@@ -14,12 +14,11 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
|
||||
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
|
||||
const liveEnabled = HAS_OPENAI_KEY && process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const describeLive = liveEnabled ? describe : describe.skip;
|
||||
// Skip if no OpenAI API key
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
|
||||
|
||||
describe("memory plugin e2e", () => {
|
||||
describeWithKey("memory plugin e2e", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -160,7 +159,7 @@ describe("memory plugin e2e", () => {
|
||||
});
|
||||
|
||||
// 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 dbPath: string;
|
||||
|
||||
@@ -177,7 +176,6 @@ describeLive("memory plugin live tests", () => {
|
||||
|
||||
test("memory tools work end-to-end", async () => {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
const liveApiKey = process.env.OPENAI_API_KEY ?? "";
|
||||
|
||||
// Mock plugin API
|
||||
const registeredTools: any[] = [];
|
||||
@@ -193,7 +191,7 @@ describeLive("memory plugin live tests", () => {
|
||||
config: {},
|
||||
pluginConfig: {
|
||||
embedding: {
|
||||
apiKey: liveApiKey,
|
||||
apiKey: OPENAI_API_KEY,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
resolveShellEnvFallbackTimeoutMs,
|
||||
} from "../infra/shell-env.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { logInfo, logWarn } from "../logger.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import {
|
||||
type ProcessSession,
|
||||
type SessionStdin,
|
||||
@@ -381,56 +381,41 @@ async function runExecProcess(opts: {
|
||||
) as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
} else if (opts.usePty) {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
try {
|
||||
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
||||
spawn?: PtySpawn;
|
||||
default?: { spawn?: PtySpawn };
|
||||
};
|
||||
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
||||
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 ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
||||
spawn?: PtySpawn;
|
||||
default?: { spawn?: PtySpawn };
|
||||
};
|
||||
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
||||
if (!spawnPty) {
|
||||
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
||||
}
|
||||
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 {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
child = spawn(shell, [...shellArgs, opts.command], {
|
||||
@@ -1335,7 +1320,7 @@ export function createExecTool(
|
||||
|
||||
const effectiveTimeout =
|
||||
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 run = await runExecProcess({
|
||||
command: params.command,
|
||||
@@ -1375,7 +1360,7 @@ export function createExecTool(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${getWarningText()}` +
|
||||
`${warningText}` +
|
||||
`Command still running (session ${run.session.id}, pid ${
|
||||
run.session.pid ?? "n/a"
|
||||
}). ` +
|
||||
@@ -1425,7 +1410,7 @@ export function createExecTool(
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${getWarningText()}${outcome.aggregated || "(no output)"}`,
|
||||
text: `${warningText}${outcome.aggregated || "(no output)"}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
|
||||
+235
@@ -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");
|
||||
});
|
||||
});
|
||||
-192
@@ -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 { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
@@ -121,195 +120,4 @@ describe("clawdbot-tools: subagents", () => {
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
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) => {
|
||||
const message = describeUnknownError(reason);
|
||||
if (!isCompactionFailureError(message)) return false;
|
||||
log.error(`Auto-compaction failed (unhandled): ${message}`);
|
||||
compactionFailureEmitter.emit("failure", message);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
||||
* - <invoke name="...">...</invoke> blocks
|
||||
* - </minimax:tool_call> closing tags
|
||||
*/
|
||||
export function stripMinimaxToolCallXml(text: string): string {
|
||||
function stripMinimaxToolCallXml(text: string): string {
|
||||
if (!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
|
||||
* not be shown to users.
|
||||
*/
|
||||
export function stripDowngradedToolCallText(text: string): string {
|
||||
function stripDowngradedToolCallText(text: string): string {
|
||||
if (!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
|
||||
* that slip through other filtering mechanisms.
|
||||
*/
|
||||
export function stripThinkingTagsFromText(text: string): string {
|
||||
function stripThinkingTagsFromText(text: string): string {
|
||||
if (!text) return text;
|
||||
// Quick check to avoid regex overhead when no tags present.
|
||||
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";
|
||||
|
||||
const {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
} = __testing;
|
||||
const { collectToolFailures, formatToolFailuresSection } = __testing;
|
||||
|
||||
describe("compaction-safeguard tool failures", () => {
|
||||
it("formats tool failures with meta and summary", () => {
|
||||
@@ -104,107 +96,3 @@ describe("compaction-safeguard tool failures", () => {
|
||||
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";
|
||||
|
||||
const BASE_CHUNK_RATIO = 0.4;
|
||||
const MIN_CHUNK_RATIO = 0.15;
|
||||
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
|
||||
const MAX_CHUNK_RATIO = 0.4;
|
||||
const FALLBACK_SUMMARY =
|
||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||
const TURN_PREFIX_INSTRUCTIONS =
|
||||
@@ -162,38 +160,6 @@ function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessag
|
||||
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: {
|
||||
messages: AgentMessage[];
|
||||
model: NonNullable<ExtensionContext["model"]>;
|
||||
@@ -226,78 +192,6 @@ async function summarizeChunks(params: {
|
||||
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 {
|
||||
api.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, customInstructions, signal } = event;
|
||||
@@ -339,35 +233,29 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
1,
|
||||
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
||||
);
|
||||
|
||||
// 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 maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * MAX_CHUNK_RATIO));
|
||||
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
||||
|
||||
const historySummary = await summarizeWithFallback({
|
||||
const historySummary = await summarizeChunks({
|
||||
messages: preparation.messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
});
|
||||
|
||||
let summary = historySummary;
|
||||
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
||||
const prefixSummary = await summarizeWithFallback({
|
||||
const prefixSummary = await summarizeChunks({
|
||||
messages: preparation.turnPrefixMessages,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||
});
|
||||
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 = {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
} as const;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
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";
|
||||
|
||||
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 {
|
||||
if (!message || typeof message !== "object") 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 as { type?: unknown }).type !== "text") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
const sanitized = sanitizeTextContent(text);
|
||||
if (sanitized.trim()) {
|
||||
chunks.push(sanitized);
|
||||
}
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
chunks.push(text);
|
||||
}
|
||||
}
|
||||
const joined = chunks.join("").trim();
|
||||
return joined ? sanitizeUserFacingText(joined) : undefined;
|
||||
return joined ? joined : undefined;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
extractAssistantText,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
sanitizeTextContent,
|
||||
stripToolMessages,
|
||||
} from "../../agents/tools/sessions-helpers.js";
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
@@ -107,14 +106,11 @@ function normalizeMessageText(text: string) {
|
||||
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 shouldSanitize = role === "assistant";
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
const normalized = normalizeMessageText(
|
||||
shouldSanitize ? sanitizeTextContent(content) : content,
|
||||
);
|
||||
const normalized = normalizeMessageText(content);
|
||||
return normalized ? { role, text: normalized } : 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 as { type?: unknown }).type !== "text") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
const value = shouldSanitize ? sanitizeTextContent(text) : text;
|
||||
if (value.trim()) {
|
||||
chunks.push(value);
|
||||
}
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
chunks.push(text);
|
||||
}
|
||||
}
|
||||
const joined = normalizeMessageText(chunks.join(" "));
|
||||
|
||||
@@ -32,34 +32,7 @@ describe("resolveReplyToMode", () => {
|
||||
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", () => {
|
||||
it("uses dm-specific replyToMode for Slack DMs when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -70,6 +43,20 @@ describe("resolveReplyToMode", () => {
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level replyToMode when dm.replyToMode is not configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
dm: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,12 +60,9 @@ export type MsgContext = {
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
MediaDir?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: 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). */
|
||||
MediaRemoteHost?: string;
|
||||
Transcript?: string;
|
||||
|
||||
@@ -47,7 +47,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
"1) Discord Developer Portal → Applications → New Application",
|
||||
"2) Bot → Add Bot → Reset Token → copy token",
|
||||
"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")}`,
|
||||
].join("\n"),
|
||||
"Discord bot token",
|
||||
|
||||
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
||||
value: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude Code CLI + API key",
|
||||
choices: ["token", "claude-cli", "apiKey"],
|
||||
choices: ["claude-cli", "token", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "minimax",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -21,17 +21,9 @@ vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("openUrl", () => {
|
||||
it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => {
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "");
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
|
||||
const url =
|
||||
"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,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
|
||||
platformSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBrowserOpenCommand", () => {
|
||||
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();
|
||||
expect(resolved.argv).toEqual(["cmd", "/c", "start", ""]);
|
||||
expect(resolved.quoteUrl).toBe(true);
|
||||
platformSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -192,7 +192,6 @@ function resolveSshTargetHint(): string {
|
||||
}
|
||||
|
||||
export async function openUrl(url: string): Promise<boolean> {
|
||||
if (shouldSkipBrowserOpenInTests()) return false;
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv) return false;
|
||||
const quoteUrl = resolved.quoteUrl === true;
|
||||
@@ -219,7 +218,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;
|
||||
@@ -310,11 +308,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: {
|
||||
url: string;
|
||||
token?: string;
|
||||
|
||||
@@ -4,43 +4,18 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
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";
|
||||
|
||||
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() {}
|
||||
},
|
||||
}));
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
@@ -66,6 +41,85 @@ async function getFreeGatewayPort(): Promise<number> {
|
||||
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 = {
|
||||
log: () => {},
|
||||
error: (msg: string) => {
|
||||
@@ -98,6 +152,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(tempHome, prefix));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
vi.resetModules();
|
||||
return stateDir;
|
||||
};
|
||||
|
||||
@@ -152,9 +207,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
runtime,
|
||||
);
|
||||
|
||||
const { resolveConfigPath } = await import("../config/paths.js");
|
||||
const configPath = resolveConfigPath(process.env, stateDir);
|
||||
const cfg = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
||||
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
|
||||
gateway?: { auth?: { mode?: string; token?: 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?.token).toBe(token);
|
||||
|
||||
const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js");
|
||||
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
|
||||
const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } });
|
||||
expect(resNoToken.ok).toBe(false);
|
||||
const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } });
|
||||
expect(resToken.ok).toBe(true);
|
||||
const { startGatewayServer } = await import("../gateway/server.js");
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
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 });
|
||||
}, 60_000);
|
||||
@@ -177,37 +244,43 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
const stateDir = await initStateDir("state-remote-");
|
||||
const port = await getFreePort();
|
||||
const token = "tok_remote_123";
|
||||
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||
await runNonInteractiveOnboarding(
|
||||
{
|
||||
nonInteractive: true,
|
||||
mode: "remote",
|
||||
remoteUrl: `ws://127.0.0.1:${port}`,
|
||||
remoteToken: token,
|
||||
authChoice: "skip",
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
const { startGatewayServer } = await import("../gateway/server.js");
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
const { resolveConfigPath } = await import("../config/config.js");
|
||||
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
|
||||
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
||||
};
|
||||
try {
|
||||
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||
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");
|
||||
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
|
||||
expect(cfg.gateway?.remote?.token).toBe(token);
|
||||
const { resolveConfigPath } = await import("../config/config.js");
|
||||
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
|
||||
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
||||
};
|
||||
|
||||
gatewayClientCalls.length = 0;
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
const health = await callGateway<{ ok?: boolean }>({ method: "health" });
|
||||
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);
|
||||
expect(cfg.gateway?.mode).toBe("remote");
|
||||
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
|
||||
expect(cfg.gateway?.remote?.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);
|
||||
|
||||
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 ?? "";
|
||||
expect(token.length).toBeGreaterThan(8);
|
||||
|
||||
const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js");
|
||||
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
|
||||
const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } });
|
||||
expect(resNoToken.ok).toBe(false);
|
||||
const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } });
|
||||
expect(resToken.ok).toBe(true);
|
||||
const { startGatewayServer } = await import("../gateway/server.js");
|
||||
const server = await startGatewayServer(port, {
|
||||
controlUiEnabled: false,
|
||||
auth: {
|
||||
mode: "token",
|
||||
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 });
|
||||
}, 60_000);
|
||||
|
||||
@@ -17,7 +17,7 @@ export type SlackDmConfig = {
|
||||
groupEnabled?: boolean;
|
||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||
groupChannels?: Array<string | number>;
|
||||
/** @deprecated Prefer channels.slack.replyToModeByChatType.direct. */
|
||||
/** Control reply threading for DMs (off|first|all). Overrides top-level replyToMode for DMs. */
|
||||
replyToMode?: ReplyToMode;
|
||||
};
|
||||
|
||||
@@ -119,11 +119,6 @@ export type SlackAccountConfig = {
|
||||
reactionAllowlist?: Array<string | number>;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
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?: SlackThreadConfig;
|
||||
actions?: SlackActionConfig;
|
||||
|
||||
@@ -248,7 +248,6 @@ export const SlackDmSchema = z
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -281,14 +280,6 @@ export const SlackThreadSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const SlackReplyToModeByChatTypeSchema = z
|
||||
.object({
|
||||
direct: ReplyToModeSchema.optional(),
|
||||
group: ReplyToModeSchema.optional(),
|
||||
channel: ReplyToModeSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SlackAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -316,7 +307,6 @@ export const SlackAccountSchema = z
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(),
|
||||
thread: SlackThreadSchema.optional(),
|
||||
actions: z
|
||||
.object({
|
||||
|
||||
@@ -36,7 +36,6 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
updateLastRouteMock.mockReset();
|
||||
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
||||
|
||||
@@ -7,8 +7,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
|
||||
const MAX_ASSISTANT_NAME = 50;
|
||||
const MAX_ASSISTANT_AVATAR = 200;
|
||||
|
||||
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {
|
||||
agentId: "main",
|
||||
export const DEFAULT_ASSISTANT_IDENTITY = {
|
||||
name: "Assistant",
|
||||
avatar: "A",
|
||||
};
|
||||
|
||||
@@ -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 { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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" });
|
||||
});
|
||||
installGatewayTestHooks();
|
||||
|
||||
async function startServerWithDefaultConfig(port: number) {
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
@@ -94,7 +82,8 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
});
|
||||
|
||||
it("handles request validation and routing", async () => {
|
||||
const port = enabledPort;
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
||||
@@ -341,12 +330,13 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// shared server
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("streams SSE chunks when stream=true", async () => {
|
||||
const port = enabledPort;
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
try {
|
||||
{
|
||||
agentCommand.mockReset();
|
||||
@@ -426,7 +416,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
expect(fallbackText).toContain("hello");
|
||||
}
|
||||
} finally {
|
||||
// shared server
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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" });
|
||||
});
|
||||
installGatewayTestHooks();
|
||||
|
||||
async function startServerWithDefaultConfig(port: number) {
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
@@ -84,7 +72,7 @@ async function ensureResponseConsumed(res: Response) {
|
||||
describe("OpenResponses HTTP API (e2e)", () => {
|
||||
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const _server = await startServerWithDefaultConfig(port);
|
||||
const server = await startServerWithDefaultConfig(port);
|
||||
try {
|
||||
const res = await postResponses(port, {
|
||||
model: "clawdbot",
|
||||
@@ -93,7 +81,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
expect(res.status).toBe(404);
|
||||
await ensureResponseConsumed(res);
|
||||
} finally {
|
||||
// shared server
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
|
||||
const disabledPort = await getFreePort();
|
||||
@@ -113,7 +101,8 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
});
|
||||
|
||||
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) => {
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockResolvedValueOnce({ payloads, meta } as never);
|
||||
@@ -417,12 +406,14 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
);
|
||||
await ensureResponseConsumed(resNoUser);
|
||||
} finally {
|
||||
// shared server
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
|
||||
it("streams OpenResponses SSE events", async () => {
|
||||
const port = enabledPort;
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
|
||||
try {
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||
@@ -498,7 +489,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
expect(event.event).toBe(parsed.type);
|
||||
}
|
||||
} finally {
|
||||
// shared server
|
||||
await server.close({ reason: "test done" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
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 { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
agentCommand,
|
||||
connectOk,
|
||||
@@ -15,22 +14,7 @@ import {
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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();
|
||||
});
|
||||
installGatewayTestHooks();
|
||||
|
||||
const registryState = vi.hoisted(() => ({
|
||||
registry: {
|
||||
@@ -59,11 +43,6 @@ vi.mock("./server-plugins.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const setRegistry = (registry: PluginRegistry) => {
|
||||
registryState.registry = registry;
|
||||
setActivePluginRegistry(registry);
|
||||
};
|
||||
|
||||
const BASE_IMAGE_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
|
||||
|
||||
@@ -163,7 +142,7 @@ const defaultRegistry = createRegistry([
|
||||
|
||||
describe("gateway server agent", () => {
|
||||
test("agent marks implicit delivery when lastTo is stale", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
testState.allowFrom = ["+436769770569"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -192,11 +175,14 @@ describe("gateway server agent", () => {
|
||||
expect(call.to).toBe("+1555");
|
||||
expect(call.deliveryTargetMode).toBe("implicit");
|
||||
expect(call.sessionId).toBe("sess-main-stale");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
testState.allowFrom = undefined;
|
||||
});
|
||||
|
||||
test("agent forwards sessionKey to agentCommand", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -207,6 +193,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:subagent:abc",
|
||||
@@ -221,10 +211,13 @@ describe("gateway server agent", () => {
|
||||
expectChannels(call, "webchat");
|
||||
expect(call.deliver).toBe(false);
|
||||
expect(call.to).toBeUndefined();
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent derives sessionKey from agentId", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
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", {
|
||||
message: "hi",
|
||||
agentId: "ops",
|
||||
@@ -248,10 +245,16 @@ describe("gateway server agent", () => {
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.sessionKey).toBe("agent:ops:main");
|
||||
expect(call.sessionId).toBe("sess-ops");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
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", {
|
||||
message: "hi",
|
||||
replyChannel: "unknown-channel",
|
||||
@@ -262,11 +265,18 @@ describe("gateway server agent", () => {
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent rejects mismatched agentId and sessionKey", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
testState.agentsConfig = { list: [{ id: "ops" }] };
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
agentId: "ops",
|
||||
@@ -278,10 +288,13 @@ describe("gateway server agent", () => {
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent forwards accountId to agentCommand", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -312,11 +329,14 @@ describe("gateway server agent", () => {
|
||||
expect(call.accountId).toBe("kev");
|
||||
const runContext = call.runContext as { accountId?: string } | undefined;
|
||||
expect(runContext?.accountId).toBe("kev");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
testState.allowFrom = undefined;
|
||||
});
|
||||
|
||||
test("agent avoids lastAccountId when explicit to is provided", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -345,11 +369,14 @@ describe("gateway server agent", () => {
|
||||
expectChannels(call, "whatsapp");
|
||||
expect(call.to).toBe("+1666");
|
||||
expect(call.accountId).toBeUndefined();
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
testState.allowFrom = undefined;
|
||||
});
|
||||
|
||||
test("agent keeps explicit accountId when explicit to is provided", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -379,11 +410,14 @@ describe("gateway server agent", () => {
|
||||
expectChannels(call, "whatsapp");
|
||||
expect(call.to).toBe("+1666");
|
||||
expect(call.accountId).toBe("primary");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
testState.allowFrom = undefined;
|
||||
});
|
||||
|
||||
test("agent falls back to lastAccountId for implicit delivery", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -411,11 +449,14 @@ describe("gateway server agent", () => {
|
||||
expectChannels(call, "whatsapp");
|
||||
expect(call.to).toBe("+1555");
|
||||
expect(call.accountId).toBe("kev");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
testState.allowFrom = undefined;
|
||||
});
|
||||
|
||||
test("agent forwards image attachments as images[]", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -426,6 +467,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "what is in the image?",
|
||||
sessionKey: "main",
|
||||
@@ -452,10 +497,13 @@ describe("gateway server agent", () => {
|
||||
expect(images[0]?.type).toBe("image");
|
||||
expect(images[0]?.mimeType).toBe("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 () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -481,11 +533,14 @@ describe("gateway server agent", () => {
|
||||
expect(call.to).toBe("+1555");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-main-missing-provider");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
testState.allowFrom = undefined;
|
||||
});
|
||||
|
||||
test("agent routes main last-channel whatsapp", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -498,6 +553,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -515,10 +574,13 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-main-whatsapp");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel telegram", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -531,6 +593,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -547,10 +613,13 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-main");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel discord", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -563,6 +632,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -579,10 +652,13 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-discord");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel slack", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -595,6 +671,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -611,10 +691,13 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-slack");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel signal", async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -627,6 +710,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -643,5 +730,8 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-signal");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
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 type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
|
||||
@@ -22,24 +22,7 @@ import {
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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();
|
||||
});
|
||||
installGatewayTestHooks();
|
||||
|
||||
const registryState = vi.hoisted(() => ({
|
||||
registry: {
|
||||
@@ -147,6 +130,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -163,6 +150,9 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-teams");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -214,9 +208,15 @@ describe("gateway server agent", () => {
|
||||
const lastTeamsCall = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expectChannels(lastTeamsCall, "msteams");
|
||||
expect(lastTeamsCall.to).toBe("conversation:teams-abc");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent rejects unknown channel", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -225,6 +225,9 @@ describe("gateway server agent", () => {
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.code).toBe("INVALID_REQUEST");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
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", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -257,6 +264,9 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
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 () => {
|
||||
@@ -272,6 +282,10 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -288,9 +302,15 @@ describe("gateway server agent", () => {
|
||||
expect(call.deliver).toBe(false);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-main-webchat-internal");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent ack response then final response", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const ackP = onceMessage(
|
||||
ws,
|
||||
(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(final.payload.runId).toBe(ack.payload.runId);
|
||||
expect(final.payload.status).toBe("ok");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent dedupes by idempotencyKey after completion", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const firstFinalP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
|
||||
@@ -341,6 +367,9 @@ describe("gateway server agent", () => {
|
||||
);
|
||||
const second = await secondP;
|
||||
expect(second.payload).toEqual(firstFinal.payload);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
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}`);
|
||||
await new Promise<void>((resolve) => webchatWs.once("open", resolve));
|
||||
await connectOk(webchatWs, {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
|
||||
version: "1.0.0",
|
||||
@@ -418,7 +446,7 @@ describe("gateway server agent", () => {
|
||||
registerAgentRunContext("run-auto-1", { sessionKey: "main" });
|
||||
|
||||
const finalChatP = onceMessage(
|
||||
webchatWs,
|
||||
ws,
|
||||
(o) => {
|
||||
if (o.type !== "event" || o.event !== "chat") return false;
|
||||
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.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;
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "./test-helpers.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> {
|
||||
if (ws.readyState === WebSocket.CLOSED) return true;
|
||||
|
||||
@@ -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 { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
|
||||
const loadConfigHelpers = async () => await import("../config/config.js");
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
installGatewayTestHooks();
|
||||
|
||||
const registryState = vi.hoisted(() => ({
|
||||
registry: {
|
||||
@@ -132,31 +131,30 @@ const defaultRegistry = createRegistry([
|
||||
},
|
||||
]);
|
||||
|
||||
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
|
||||
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
|
||||
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
const started = await startServerWithClient();
|
||||
server = started.server;
|
||||
ws = started.ws;
|
||||
await connectOk(ws);
|
||||
afterEach(async () => {
|
||||
for (const { server, ws } of servers) {
|
||||
try {
|
||||
ws.close();
|
||||
await server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
servers.length = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function setRegistry(registry: PluginRegistry) {
|
||||
registryState.registry = registry;
|
||||
setActivePluginRegistry(registry);
|
||||
}
|
||||
|
||||
describe("gateway server channels", () => {
|
||||
test("channels.status returns snapshot without probe", async () => {
|
||||
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<{
|
||||
channels?: Record<
|
||||
string,
|
||||
@@ -183,7 +181,12 @@ describe("gateway server channels", () => {
|
||||
});
|
||||
|
||||
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", {
|
||||
channel: "whatsapp",
|
||||
});
|
||||
@@ -194,7 +197,7 @@ describe("gateway server channels", () => {
|
||||
|
||||
test("channels.logout clears telegram bot token from config", async () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||
setRegistry(defaultRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
|
||||
await writeConfigFile({
|
||||
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<{
|
||||
cleared?: boolean;
|
||||
envToken?: boolean;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
installGatewayTestHooks();
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
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", () => {
|
||||
const timeoutMs = process.platform === "win32" ? 120_000 : 60_000;
|
||||
test(
|
||||
"handles history, abort, idempotency, and ordering flows",
|
||||
{ timeout: timeoutMs },
|
||||
async () => {
|
||||
const tempDirs: string[] = [];
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const resetSpy = () => {
|
||||
spy.mockReset();
|
||||
spy.mockResolvedValue(undefined);
|
||||
};
|
||||
try {
|
||||
await connectOk(ws);
|
||||
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
tempDirs.push(sessionDir);
|
||||
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
|
||||
const writeStore = async (
|
||||
entries: Record<
|
||||
string,
|
||||
{ sessionId: string; updatedAt: number; lastChannel?: string; lastTo?: string }
|
||||
>,
|
||||
) => {
|
||||
await writeSessionStore({ entries });
|
||||
};
|
||||
|
||||
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
|
||||
const bigText = "x".repeat(155_000);
|
||||
const largeLines: string[] = [];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
largeLines.push(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `${i}:${bigText}` }],
|
||||
timestamp: Date.now() + i,
|
||||
},
|
||||
}),
|
||||
test("handles history, abort, idempotency, and ordering flows", { timeout: 60_000 }, async () => {
|
||||
const tempDirs: string[] = [];
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const resetSpy = () => {
|
||||
spy.mockReset();
|
||||
spy.mockResolvedValue(undefined);
|
||||
};
|
||||
try {
|
||||
await connectOk(ws);
|
||||
await withSessionStore(
|
||||
tempDirs,
|
||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
||||
async (historyDir) => {
|
||||
const bigText = "x".repeat(200_000);
|
||||
const largeLines: string[] = [];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
largeLines.push(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `${i}:${bigText}` }],
|
||||
timestamp: Date.now() + i,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(historyDir, "sess-main.jsonl"),
|
||||
largeLines.join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(sessionDir, "sess-main.jsonl"),
|
||||
largeLines.join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
||||
sessionKey: "main",
|
||||
limit: 1000,
|
||||
});
|
||||
expect(cappedRes.ok).toBe(true);
|
||||
const cappedMsgs = cappedRes.payload?.messages ?? [];
|
||||
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
||||
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
|
||||
expect(cappedMsgs.length).toBeLessThan(60);
|
||||
|
||||
await writeStore({
|
||||
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
||||
sessionKey: "main",
|
||||
limit: 1000,
|
||||
});
|
||||
expect(cappedRes.ok).toBe(true);
|
||||
const cappedMsgs = cappedRes.payload?.messages ?? [];
|
||||
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
||||
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
|
||||
expect(cappedMsgs.length).toBeLessThan(60);
|
||||
},
|
||||
);
|
||||
await withSessionStore(
|
||||
tempDirs,
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
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",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-route",
|
||||
idempotencyKey: "idem-status-1",
|
||||
});
|
||||
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 writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
|
||||
resetSpy();
|
||||
let abortInFlight: Promise<unknown> | undefined;
|
||||
try {
|
||||
const callsBefore = spy.mock.calls.length;
|
||||
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 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) => {
|
||||
agentStartedResolve?.();
|
||||
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
@@ -129,356 +396,127 @@ describe("gateway server chat", () => {
|
||||
});
|
||||
const sendResP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "send-abort-1",
|
||||
8000,
|
||||
(o) => o.type === "res" && o.id === "send-mismatch-1",
|
||||
10_000,
|
||||
);
|
||||
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", {
|
||||
sendReq(ws, "send-mismatch-1", "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-abort-1",
|
||||
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 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 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", {
|
||||
},
|
||||
);
|
||||
await withSessionStore(
|
||||
tempDirs,
|
||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
||||
async () => {
|
||||
resetSpy();
|
||||
spy.mockResolvedValueOnce(undefined);
|
||||
sendReq(ws, "send-complete-1", "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-complete-1",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (again.ok && again.payload?.status === "ok") {
|
||||
completedRun = true;
|
||||
break;
|
||||
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 < 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", {
|
||||
sessionKey: "main",
|
||||
runId: "idem-complete-1",
|
||||
});
|
||||
expect(abortCompleteRes.ok).toBe(true);
|
||||
expect(abortCompleteRes.payload?.aborted).toBe(false);
|
||||
|
||||
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
|
||||
const res1 = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "first",
|
||||
idempotencyKey: "idem-1",
|
||||
});
|
||||
expect(res1.ok).toBe(true);
|
||||
const res2 = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "second",
|
||||
idempotencyKey: "idem-2",
|
||||
});
|
||||
expect(res2.ok).toBe(true);
|
||||
const final1P = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
|
||||
8000,
|
||||
);
|
||||
emitAgentEvent({
|
||||
runId: "idem-1",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
const final1 = await final1P;
|
||||
const run1 =
|
||||
final1.payload && typeof final1.payload === "object"
|
||||
? (final1.payload as { runId?: string }).runId
|
||||
: undefined;
|
||||
expect(run1).toBe("idem-1");
|
||||
const final2P = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
|
||||
8000,
|
||||
);
|
||||
emitAgentEvent({
|
||||
runId: "idem-2",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
const final2 = await final2P;
|
||||
const run2 =
|
||||
final2.payload && typeof final2.payload === "object"
|
||||
? (final2.payload as { runId?: string }).runId
|
||||
: undefined;
|
||||
expect(run2).toBe("idem-2");
|
||||
} finally {
|
||||
testState.sessionStorePath = undefined;
|
||||
sessionStoreSaveDelayMs.value = 0;
|
||||
ws.close();
|
||||
await server.close();
|
||||
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(completedRun).toBe(true);
|
||||
const abortCompleteRes = await rpcReq(ws, "chat.abort", {
|
||||
sessionKey: "main",
|
||||
runId: "idem-complete-1",
|
||||
});
|
||||
expect(abortCompleteRes.ok).toBe(true);
|
||||
expect(abortCompleteRes.payload?.aborted).toBe(false);
|
||||
},
|
||||
);
|
||||
await withSessionStore(
|
||||
tempDirs,
|
||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
||||
async () => {
|
||||
const res1 = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "first",
|
||||
idempotencyKey: "idem-1",
|
||||
});
|
||||
expect(res1.ok).toBe(true);
|
||||
const res2 = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "second",
|
||||
idempotencyKey: "idem-2",
|
||||
});
|
||||
expect(res2.ok).toBe(true);
|
||||
const final1P = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
|
||||
8000,
|
||||
);
|
||||
emitAgentEvent({
|
||||
runId: "idem-1",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
const final1 = await final1P;
|
||||
const run1 =
|
||||
final1.payload && typeof final1.payload === "object"
|
||||
? (final1.payload as { runId?: string }).runId
|
||||
: undefined;
|
||||
expect(run1).toBe("idem-1");
|
||||
const final2P = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
|
||||
8000,
|
||||
);
|
||||
emitAgentEvent({
|
||||
runId: "idem-2",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
const final2 = await final2P;
|
||||
const run2 =
|
||||
final2.payload && typeof final2.payload === "object"
|
||||
? (final2.payload as { runId?: string }).runId
|
||||
: undefined;
|
||||
expect(run2).toBe("idem-2");
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
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 os from "node:os";
|
||||
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 { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import {
|
||||
agentCommand,
|
||||
connectOk,
|
||||
@@ -16,24 +15,7 @@ import {
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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();
|
||||
});
|
||||
installGatewayTestHooks();
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -47,9 +29,12 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
describe("gateway server chat", () => {
|
||||
test("handles chat send and history flows", async () => {
|
||||
const tempDirs: string[] = [];
|
||||
const { server, ws, port } = await startServerWithClient();
|
||||
let webchatWs: WebSocket | undefined;
|
||||
|
||||
try {
|
||||
await connectOk(ws);
|
||||
|
||||
webchatWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => webchatWs?.once("open", resolve));
|
||||
await connectOk(webchatWs, {
|
||||
@@ -255,182 +240,9 @@ describe("gateway server chat", () => {
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
if (webchatWs) webchatWs.close();
|
||||
ws.close();
|
||||
await server.close();
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveConfigSnapshotHash } from "../config/config.js";
|
||||
|
||||
@@ -9,31 +6,16 @@ import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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();
|
||||
});
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway config.patch", () => {
|
||||
it("merges patches without clobbering unrelated config", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const setId = "req-set";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -118,9 +100,15 @@ describe("gateway config.patch", () => {
|
||||
expect(get2Res.ok).toBe(true);
|
||||
expect(get2Res.payload?.config?.gateway?.mode).toBe("local");
|
||||
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("requires base hash when config exists", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const setId = "req-set-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -157,9 +145,15 @@ describe("gateway config.patch", () => {
|
||||
);
|
||||
expect(patchRes.ok).toBe(false);
|
||||
expect(patchRes.error?.message).toContain("base hash");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("requires base hash for config.set when config exists", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const setId = "req-set-3";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -198,108 +192,8 @@ describe("gateway config.patch", () => {
|
||||
);
|
||||
expect(set2Res.ok).toBe(false);
|
||||
expect(set2Res.error?.message).toContain("base hash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway server sessions", () => {
|
||||
it("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 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();
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
@@ -36,11 +36,7 @@ async function rmTempDir(dir: string) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function waitForCronFinished(
|
||||
ws: { send: (data: string) => void },
|
||||
jobId: string,
|
||||
timeoutMs = 20_000,
|
||||
) {
|
||||
async function waitForCronFinished(ws: { send: (data: string) => void }, jobId: string) {
|
||||
await onceMessage(
|
||||
ws as never,
|
||||
(o) =>
|
||||
@@ -48,7 +44,7 @@ async function waitForCronFinished(
|
||||
o.event === "cron" &&
|
||||
o.payload?.action === "finished" &&
|
||||
o.payload?.jobId === jobId,
|
||||
timeoutMs,
|
||||
10_000,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -349,7 +345,14 @@ describe("gateway server cron", () => {
|
||||
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
|
||||
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`));
|
||||
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
|
||||
|
||||
+184
-128
@@ -10,150 +10,206 @@ import {
|
||||
waitForSystemEvent,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
installGatewayTestHooks();
|
||||
|
||||
const resolveMainKey = () => resolveMainSessionKeyFromConfig();
|
||||
|
||||
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" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
try {
|
||||
const resNoAuth = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Ping" }),
|
||||
});
|
||||
expect(resNoAuth.status).toBe(401);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Ping" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const resWake = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
|
||||
});
|
||||
expect(resWake.status).toBe(200);
|
||||
const wakeEvents = await waitForSystemEvent();
|
||||
expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true);
|
||||
drainSystemEvents(resolveMainKey());
|
||||
test("hooks wake enqueues system event", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
|
||||
});
|
||||
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();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
const resAgent = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: "Do it", name: "Email" }),
|
||||
});
|
||||
expect(resAgent.status).toBe(202);
|
||||
const agentEvents = await waitForSystemEvent();
|
||||
expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true);
|
||||
drainSystemEvents(resolveMainKey());
|
||||
test("hooks agent posts summary to main", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: "Do it", name: "Email" }),
|
||||
});
|
||||
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();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
const resAgentModel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
}),
|
||||
});
|
||||
expect(resAgentModel.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
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());
|
||||
test("hooks agent forwards model override", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Query auth" }),
|
||||
});
|
||||
expect(resQuery.status).toBe(200);
|
||||
const queryEvents = await waitForSystemEvent();
|
||||
expect(queryEvents.some((e) => e.includes("Query auth"))).toBe(true);
|
||||
drainSystemEvents(resolveMainKey());
|
||||
test("hooks wake accepts query token", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Query auth" }),
|
||||
});
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: "Nope", channel: "sms" }),
|
||||
});
|
||||
expect(resBadChannel.status).toBe(400);
|
||||
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
|
||||
test("hooks agent rejects invalid channel", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-clawdbot-token": "hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: "Header auth" }),
|
||||
});
|
||||
expect(resHeader.status).toBe(200);
|
||||
const headerEvents = await waitForSystemEvent();
|
||||
expect(headerEvents.some((e) => e.includes("Header auth"))).toBe(true);
|
||||
drainSystemEvents(resolveMainKey());
|
||||
test("hooks wake accepts x-clawdbot-token header", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-clawdbot-token": "hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: "Header auth" }),
|
||||
});
|
||||
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`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer hook-secret" },
|
||||
});
|
||||
expect(resGet.status).toBe(405);
|
||||
test("hooks rejects non-post", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: " " }),
|
||||
});
|
||||
expect(resBlankText.status).toBe(400);
|
||||
test("hooks wake requires text", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: " " }),
|
||||
});
|
||||
expect(resBlankMessage.status).toBe(400);
|
||||
test("hooks agent requires message", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: "{",
|
||||
});
|
||||
expect(resBadJson.status).toBe(400);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
test("hooks rejects invalid json", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: "{",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -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())),
|
||||
);
|
||||
});
|
||||
});
|
||||
+26
-194
@@ -1,93 +1,19 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { 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 {
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
occupyPort,
|
||||
onceMessage,
|
||||
piSdkMock,
|
||||
rpcReq,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
testTailnetIPv4,
|
||||
} from "./test-helpers.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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([]);
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server models + voicewake", () => {
|
||||
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 restoreHome = setTempHome(homeDir);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||
expect(initial.ok).toBe(true);
|
||||
expect(initial.payload?.triggers).toEqual(["clawd", "claude", "computer"]);
|
||||
@@ -175,6 +104,9 @@ describe("gateway server models + voicewake", () => {
|
||||
expect(onDisk.triggers).toEqual(["hi", "there"]);
|
||||
expect(typeof onDisk.updatedAtMs).toBe("number");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
restoreHome();
|
||||
},
|
||||
);
|
||||
@@ -183,6 +115,9 @@ describe("gateway server models + voicewake", () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
const restoreHome = setTempHome(homeDir);
|
||||
|
||||
const { server, ws, port } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
|
||||
const firstEventP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
|
||||
@@ -224,6 +159,9 @@ describe("gateway server models + voicewake", () => {
|
||||
]);
|
||||
|
||||
nodeWs.close();
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
restoreHome();
|
||||
});
|
||||
|
||||
@@ -251,6 +189,9 @@ describe("gateway server models + voicewake", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res1 = await rpcReq<{
|
||||
models: Array<{
|
||||
id: string;
|
||||
@@ -300,132 +241,23 @@ describe("gateway server models + voicewake", () => {
|
||||
]);
|
||||
|
||||
expect(piSdkMock.discoverCalls).toBe(1);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("models.list rejects unknown params", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
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 });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i);
|
||||
});
|
||||
});
|
||||
|
||||
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 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())),
|
||||
);
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
+13
-137
@@ -1,48 +1,15 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
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 {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
import { GatewayClient } from "./client.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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();
|
||||
});
|
||||
installGatewayTestHooks();
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -98,99 +65,11 @@ const connectNodeClient = async (params: {
|
||||
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", () => {
|
||||
test("enforces command allowlists across node clients", async () => {
|
||||
const { server, ws, port } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const waitForConnectedCount = async (count: number) => {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -217,12 +96,8 @@ describe("gateway node command allowlist", () => {
|
||||
return nodeId;
|
||||
};
|
||||
|
||||
let systemClient: GatewayClient | undefined;
|
||||
let emptyClient: GatewayClient | undefined;
|
||||
let allowedClient: GatewayClient | undefined;
|
||||
|
||||
try {
|
||||
systemClient = await connectNodeClient({
|
||||
const systemClient = await connectNodeClient({
|
||||
port,
|
||||
commands: ["system.run"],
|
||||
instanceId: "node-system-run",
|
||||
@@ -240,7 +115,7 @@ describe("gateway node command allowlist", () => {
|
||||
systemClient.stop();
|
||||
await waitForConnectedCount(0);
|
||||
|
||||
emptyClient = await connectNodeClient({
|
||||
const emptyClient = await connectNodeClient({
|
||||
port,
|
||||
commands: [],
|
||||
instanceId: "node-empty",
|
||||
@@ -263,7 +138,7 @@ describe("gateway node command allowlist", () => {
|
||||
new Promise<{ id?: string; nodeId?: string }>((resolve) => {
|
||||
resolveInvoke = resolve;
|
||||
});
|
||||
allowedClient = await connectNodeClient({
|
||||
const allowedClient = await connectNodeClient({
|
||||
port,
|
||||
commands: ["canvas.snapshot"],
|
||||
instanceId: "node-allowed",
|
||||
@@ -312,10 +187,11 @@ describe("gateway node command allowlist", () => {
|
||||
});
|
||||
const invokeNullRes = await invokeNullResP;
|
||||
expect(invokeNullRes.ok).toBe(true);
|
||||
|
||||
allowedClient.stop();
|
||||
} finally {
|
||||
systemClient?.stop();
|
||||
emptyClient?.stop();
|
||||
allowedClient?.stop();
|
||||
ws.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const cronInstances: Array<{
|
||||
@@ -165,7 +158,7 @@ vi.mock("./config-reload.js", () => ({
|
||||
startGatewayConfigReloader: hoisted.startGatewayConfigReloader,
|
||||
}));
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway hot reload", () => {
|
||||
let prevSkipChannels: string | undefined;
|
||||
@@ -305,15 +298,3 @@ describe("gateway hot reload", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
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 { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
@@ -11,38 +11,30 @@ import {
|
||||
startGatewayServer,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
installGatewayTestHooks();
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let gatewayPort: number;
|
||||
let prevGatewayPort: string | undefined;
|
||||
let prevGatewayToken: string | undefined;
|
||||
const servers: Array<Awaited<ReturnType<typeof startGatewayServer>>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
prevGatewayToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
gatewayPort = await getFreePort();
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(gatewayPort);
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "test-token";
|
||||
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;
|
||||
afterEach(async () => {
|
||||
for (const server of servers) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
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", () => {
|
||||
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);
|
||||
spy.mockImplementation(async (opts) => {
|
||||
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");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
@@ -110,6 +104,12 @@ describe("sessions_send gateway loopback", () => {
|
||||
|
||||
describe("sessions_send label lookup", () => {
|
||||
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);
|
||||
spy.mockImplementation(async (opts) => {
|
||||
const params = opts as {
|
||||
@@ -171,6 +171,13 @@ describe("sessions_send label lookup", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
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 () => {
|
||||
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");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ export const DEFAULT_PROMPT: Record<MediaUnderstandingCapability, string> = {
|
||||
export const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB;
|
||||
export const DEFAULT_AUDIO_MODELS: Record<string, string> = {
|
||||
groq: "whisper-large-v3-turbo",
|
||||
openai: "gpt-4o-mini-transcribe",
|
||||
openai: "whisper-1",
|
||||
deepgram: "nova-3",
|
||||
};
|
||||
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 { describeImageWithModel } from "../image.js";
|
||||
import { transcribeGeminiAudio } from "./audio.js";
|
||||
import { describeGeminiVideo } from "./video.js";
|
||||
|
||||
export const googleProvider: MediaUnderstandingProvider = {
|
||||
id: "google",
|
||||
capabilities: ["image", "audio", "video"],
|
||||
describeImage: describeImageWithModel,
|
||||
transcribeAudio: transcribeGeminiAudio,
|
||||
describeVideo: describeGeminiVideo,
|
||||
};
|
||||
@@ -59,7 +59,7 @@ describe("transcribeOpenAiCompatibleAudio", () => {
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(result.model).toBe("gpt-4o-mini-transcribe");
|
||||
expect(result.model).toBe("whisper-1");
|
||||
expect(result.text).toBe("hello");
|
||||
expect(seenUrl).toBe("https://api.example.com/v1/audio/transcriptions");
|
||||
expect(seenInit?.method).toBe("POST");
|
||||
@@ -71,7 +71,7 @@ describe("transcribeOpenAiCompatibleAudio", () => {
|
||||
|
||||
const form = seenInit?.body as 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("prompt")).toBe("hello");
|
||||
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";
|
||||
|
||||
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 {
|
||||
const trimmed = model?.trim();
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
|
||||
describe("runCapability auto audio entries", () => {
|
||||
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`);
|
||||
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
|
||||
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
|
||||
@@ -56,18 +54,15 @@ describe("runCapability auto audio entries", () => {
|
||||
providerRegistry,
|
||||
});
|
||||
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");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
await cache.cleanup();
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
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`);
|
||||
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
|
||||
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
|
||||
@@ -112,7 +107,6 @@ describe("runCapability auto audio entries", () => {
|
||||
expect(result.outputs).toHaveLength(0);
|
||||
expect(result.decision.outcome).toBe("disabled");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
await cache.cleanup();
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -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 { MsgContext } from "../auto-reply/templating.js";
|
||||
import { applyTemplate } from "../auto-reply/templating.js";
|
||||
@@ -21,9 +16,9 @@ import {
|
||||
} from "./defaults.js";
|
||||
import { isMediaUnderstandingSkipError, MediaUnderstandingSkipError } from "./errors.js";
|
||||
import {
|
||||
resolveEntriesWithActiveFallback,
|
||||
resolveMaxBytes,
|
||||
resolveMaxChars,
|
||||
resolveModelEntries,
|
||||
resolvePrompt,
|
||||
resolveScopeDecision,
|
||||
resolveTimeoutMs,
|
||||
@@ -44,15 +39,7 @@ import {
|
||||
import { describeImageWithModel } from "./providers/image.js";
|
||||
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
|
||||
|
||||
const AUTO_AUDIO_KEY_PROVIDERS = ["openai", "groq", "deepgram", "google"] 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",
|
||||
};
|
||||
const AUTO_AUDIO_PROVIDERS = ["openai", "groq", "deepgram"] as const;
|
||||
|
||||
export type ActiveMediaModel = {
|
||||
provider: string;
|
||||
@@ -80,363 +67,27 @@ export function createMediaAttachmentCache(attachments: MediaAttachment[]): Medi
|
||||
return new MediaAttachmentCache(attachments);
|
||||
}
|
||||
|
||||
const binaryCache = new Map<string, Promise<string | null>>();
|
||||
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: {
|
||||
async function resolveAutoAudioEntries(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
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[]> {
|
||||
const activeEntry = await resolveActiveModelEntry(params);
|
||||
if (activeEntry) return [activeEntry];
|
||||
if (params.capability === "audio") {
|
||||
const localAudio = await resolveLocalAudioEntry();
|
||||
if (localAudio) return [localAudio];
|
||||
const entries: MediaUnderstandingModelConfig[] = [];
|
||||
for (const providerId of AUTO_AUDIO_PROVIDERS) {
|
||||
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
|
||||
if (!provider?.transcribeAudio) continue;
|
||||
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);
|
||||
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,
|
||||
};
|
||||
return entries;
|
||||
}
|
||||
|
||||
function trimOutput(text: string, maxChars?: number): string {
|
||||
@@ -445,74 +96,6 @@ function trimOutput(text: string, maxChars?: number): string {
|
||||
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>;
|
||||
|
||||
function normalizeProviderQuery(
|
||||
@@ -839,48 +422,32 @@ async function runCliEntry(params: {
|
||||
maxBytes,
|
||||
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 = {
|
||||
...ctx,
|
||||
MediaPath: mediaPath,
|
||||
MediaDir: path.dirname(mediaPath),
|
||||
OutputDir: outputDir,
|
||||
OutputBase: outputBase,
|
||||
MediaPath: pathResult.path,
|
||||
Prompt: prompt,
|
||||
MaxChars: maxChars,
|
||||
};
|
||||
const argv = [command, ...args].map((part, index) =>
|
||||
index === 0 ? part : applyTemplate(part, templCtx),
|
||||
);
|
||||
try {
|
||||
if (shouldLogVerbose()) {
|
||||
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(() => {});
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`Media understanding via CLI: ${argv.join(" ")}`);
|
||||
}
|
||||
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: {
|
||||
@@ -1014,20 +581,19 @@ export async function runCapability(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const entries = resolveModelEntries({
|
||||
const entries = resolveEntriesWithActiveFallback({
|
||||
cfg,
|
||||
capability,
|
||||
config,
|
||||
providerRegistry: params.providerRegistry,
|
||||
activeModel: params.activeModel,
|
||||
});
|
||||
let resolvedEntries = entries;
|
||||
if (resolvedEntries.length === 0) {
|
||||
resolvedEntries = await resolveAutoEntries({
|
||||
if (resolvedEntries.length === 0 && capability === "audio") {
|
||||
resolvedEntries = await resolveAutoAudioEntries({
|
||||
cfg,
|
||||
agentDir: params.agentDir,
|
||||
providerRegistry: params.providerRegistry,
|
||||
capability,
|
||||
activeModel: params.activeModel,
|
||||
});
|
||||
}
|
||||
if (resolvedEntries.length === 0) {
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("memory search async sync", () => {
|
||||
|
||||
const resolved = await Promise.race([
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ describe("memory indexing with OpenAI batches", () => {
|
||||
let workspaceDir: string;
|
||||
let indexPath: string;
|
||||
let manager: MemoryIndexManager | null = null;
|
||||
let setTimeoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
embedBatch.mockClear();
|
||||
@@ -38,18 +37,6 @@ describe("memory indexing with OpenAI batches", () => {
|
||||
embedBatch.mockImplementation(async (texts: string[]) =>
|
||||
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-"));
|
||||
indexPath = path.join(workspaceDir, "index.sqlite");
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"));
|
||||
@@ -57,7 +44,6 @@ describe("memory indexing with OpenAI batches", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
setTimeoutSpy.mockRestore();
|
||||
if (manager) {
|
||||
await manager.close();
|
||||
manager = null;
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("memory embedding batches", () => {
|
||||
|
||||
it("splits large files across multiple embedding batches", async () => {
|
||||
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);
|
||||
|
||||
const cfg = {
|
||||
@@ -78,7 +78,7 @@ describe("memory embedding batches", () => {
|
||||
|
||||
it("keeps small files in a single embedding batch", async () => {
|
||||
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);
|
||||
|
||||
const cfg = {
|
||||
@@ -109,7 +109,7 @@ describe("memory embedding batches", () => {
|
||||
|
||||
it("reports sync progress totals", async () => {
|
||||
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);
|
||||
|
||||
const cfg = {
|
||||
@@ -150,7 +150,7 @@ describe("memory embedding batches", () => {
|
||||
|
||||
it("retries embeddings on rate limit errors", async () => {
|
||||
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);
|
||||
|
||||
let calls = 0;
|
||||
@@ -162,19 +162,6 @@ describe("memory embedding batches", () => {
|
||||
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 = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -196,18 +183,15 @@ describe("memory embedding batches", () => {
|
||||
expect(result.manager).not.toBeNull();
|
||||
if (!result.manager) throw new Error("manager missing");
|
||||
manager = result.manager;
|
||||
try {
|
||||
await manager.sync({ force: true });
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
await manager.sync({ force: true });
|
||||
|
||||
expect(calls).toBe(3);
|
||||
}, 10000);
|
||||
|
||||
it("retries embeddings on transient 5xx errors", async () => {
|
||||
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);
|
||||
|
||||
let calls = 0;
|
||||
@@ -219,19 +203,6 @@ describe("memory embedding batches", () => {
|
||||
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 = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -253,11 +224,8 @@ describe("memory embedding batches", () => {
|
||||
expect(result.manager).not.toBeNull();
|
||||
if (!result.manager) throw new Error("manager missing");
|
||||
manager = result.manager;
|
||||
try {
|
||||
await manager.sync({ force: true });
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
await manager.sync({ force: true });
|
||||
|
||||
expect(calls).toBe(3);
|
||||
}, 10000);
|
||||
|
||||
@@ -98,34 +98,8 @@ describe("loadClawdbotPlugins", () => {
|
||||
expect(enabled?.status).toBe("loaded");
|
||||
});
|
||||
|
||||
it("loads bundled telegram plugin when enabled", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
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;
|
||||
it("loads bundled telegram plugin when enabled", { timeout: 120_000 }, () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = path.join(process.cwd(), "extensions");
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
cache: false,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ClawdbotConfig } from "../config/config.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 { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
|
||||
@@ -21,7 +20,6 @@ export type ResolvedSlackAccount = {
|
||||
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
|
||||
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
|
||||
replyToMode?: SlackAccountConfig["replyToMode"];
|
||||
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
|
||||
actions?: SlackAccountConfig["actions"];
|
||||
slashCommand?: SlackAccountConfig["slashCommand"];
|
||||
dm?: SlackAccountConfig["dm"];
|
||||
@@ -97,7 +95,6 @@ export function resolveSlackAccount(params: {
|
||||
reactionNotifications: merged.reactionNotifications,
|
||||
reactionAllowlist: merged.reactionAllowlist,
|
||||
replyToMode: merged.replyToMode,
|
||||
replyToModeByChatType: merged.replyToModeByChatType,
|
||||
actions: merged.actions,
|
||||
slashCommand: merged.slashCommand,
|
||||
dm: merged.dm,
|
||||
@@ -115,11 +112,7 @@ 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) {
|
||||
if (chatType === "direct" && account.dm?.replyToMode !== undefined) {
|
||||
return account.dm.replyToMode;
|
||||
}
|
||||
return account.replyToMode ?? "off";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { sendMessageSlack } from "./send.js";
|
||||
import { resolveSlackBotToken } from "./token.js";
|
||||
|
||||
@@ -57,7 +56,7 @@ function normalizeEmoji(raw: string) {
|
||||
|
||||
async function getClient(opts: SlackActionClientOpts = {}) {
|
||||
const token = resolveToken(opts.token, opts.accountId);
|
||||
return opts.client ?? createSlackWebClient(token);
|
||||
return opts.client ?? new WebClient(token);
|
||||
}
|
||||
|
||||
async function resolveBotUserId(client: WebClient) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
|
||||
@@ -61,7 +61,7 @@ export async function listSlackDirectoryPeersLive(
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) return [];
|
||||
const client = createSlackWebClient(token);
|
||||
const client = new WebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const members: SlackUser[] = [];
|
||||
let cursor: string | undefined;
|
||||
@@ -119,7 +119,7 @@ export async function listSlackDirectoryGroupsLive(
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) return [];
|
||||
const client = createSlackWebClient(token);
|
||||
const client = new WebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const channels: SlackChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
@@ -17,7 +17,6 @@ import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveSlackUserAllowlist } from "../resolve-users.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
|
||||
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
|
||||
import { resolveSlackWebClientOptions } from "../client.js";
|
||||
import { resolveSlackSlashCommandConfig } from "./commands.js";
|
||||
import { createSlackMonitorContext } from "./context.js";
|
||||
import { registerSlackMonitorEvents } from "./events.js";
|
||||
@@ -131,19 +130,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
endpoints: slackWebhookPath,
|
||||
})
|
||||
: null;
|
||||
const clientOptions = resolveSlackWebClientOptions();
|
||||
const app = new App(
|
||||
slackMode === "socket"
|
||||
? {
|
||||
token: botToken,
|
||||
appToken,
|
||||
socketMode: true,
|
||||
clientOptions,
|
||||
}
|
||||
: {
|
||||
token: botToken,
|
||||
receiver: receiver ?? undefined,
|
||||
clientOptions,
|
||||
},
|
||||
);
|
||||
const slackHttpHandler =
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export type SlackProbe = {
|
||||
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> {
|
||||
const client = createSlackWebClient(token);
|
||||
const client = new WebClient(token);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await withTimeout(client.auth.test(), timeoutMs);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export type SlackChannelLookup = {
|
||||
id: string;
|
||||
@@ -86,7 +84,7 @@ export async function resolveSlackChannelAllowlist(params: {
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
}): Promise<SlackChannelResolution[]> {
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const client = params.client ?? new WebClient(params.token);
|
||||
const channels = await listSlackChannels(client);
|
||||
const results: SlackChannelResolution[] = [];
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export type SlackUserLookup = {
|
||||
id: string;
|
||||
@@ -103,7 +101,7 @@ export async function resolveSlackUserAllowlist(params: {
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
}): Promise<SlackUserResolution[]> {
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const client = params.client ?? new WebClient(params.token);
|
||||
const users = await listSlackUsers(client);
|
||||
const results: SlackUserResolution[] = [];
|
||||
|
||||
|
||||
+2
-4
@@ -1,6 +1,4 @@
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export type SlackScopesResult = {
|
||||
ok: boolean;
|
||||
@@ -83,7 +81,7 @@ export async function fetchSlackScopes(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
): Promise<SlackScopesResult> {
|
||||
const client = createSlackWebClient(token, { timeout: timeoutMs });
|
||||
const client = new WebClient(token, { timeout: timeoutMs });
|
||||
const attempts: SlackScopesSource[] = ["auth.scopes", "apps.permissions.info"];
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api";
|
||||
import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
|
||||
|
||||
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@@ -6,7 +6,6 @@ import { logVerbose } from "../globals.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import type { SlackTokenSource } from "./accounts.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
||||
import { parseSlackTarget } from "./targets.js";
|
||||
import { resolveSlackBotToken } from "./token.js";
|
||||
@@ -138,7 +137,7 @@ export async function sendMessageSlack(
|
||||
fallbackToken: account.botToken,
|
||||
fallbackSource: account.botTokenSource,
|
||||
});
|
||||
const client = opts.client ?? createSlackWebClient(token);
|
||||
const client = opts.client ?? new WebClient(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(client, recipient);
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||
|
||||
@@ -20,57 +20,7 @@ describe("buildSlackThreadingToolContext", () => {
|
||||
expect(result.replyToMode).toBe("first");
|
||||
});
|
||||
|
||||
it("uses chat-type replyToMode overrides for direct messages when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "direct" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("all");
|
||||
});
|
||||
|
||||
it("uses top-level replyToMode for channels when no channel override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "channel" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level when no chat-type override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "direct" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("first");
|
||||
});
|
||||
|
||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||
it("uses dm.replyToMode for direct messages when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -87,6 +37,40 @@ describe("buildSlackThreadingToolContext", () => {
|
||||
expect(result.replyToMode).toBe("all");
|
||||
});
|
||||
|
||||
it("uses top-level replyToMode for channels even when dm.replyToMode is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
dm: { replyToMode: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "channel" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level when dm.replyToMode is not set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
dm: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "direct" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("first");
|
||||
});
|
||||
|
||||
it("uses all mode when ThreadLabel is present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -21,7 +21,6 @@ const apiStub: ApiStub = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetInboundDedupe();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
const { listSkillCommandsForAgents } = vi.hoisted(() => ({
|
||||
listSkillCommandsForAgents: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("../auto-reply/skill-commands.js", () => ({
|
||||
listSkillCommandsForAgents,
|
||||
}));
|
||||
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
|
||||
|
||||
+5
-10
@@ -452,16 +452,11 @@ export async function runTui(opts: TuiOptions) {
|
||||
const reasoning = sessionInfo.reasoningLevel ?? "off";
|
||||
const reasoningLabel =
|
||||
reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null;
|
||||
const footerParts = [
|
||||
`agent ${agentLabel}`,
|
||||
`session ${sessionLabel}`,
|
||||
modelLabel,
|
||||
think !== "off" ? `think ${think}` : null,
|
||||
verbose !== "off" ? `verbose ${verbose}` : null,
|
||||
reasoningLabel,
|
||||
tokens,
|
||||
].filter(Boolean);
|
||||
footer.setText(theme.dim(footerParts.join(" | ")));
|
||||
footer.setText(
|
||||
theme.dim(
|
||||
`agent ${agentLabel} | session ${sessionLabel} | ${modelLabel} | think ${think} | verbose ${verbose}${reasoningLabel ? ` | ${reasoningLabel}` : ""} | ${tokens}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../src/config/config.js";
|
||||
import type { MsgContext } from "../src/auto-reply/templating.js";
|
||||
|
||||
const makeTempDir = async (prefix: string) => await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
|
||||
const writeExecutable = async (dir: string, name: string, content: string) => {
|
||||
const filePath = path.join(dir, name);
|
||||
await fs.writeFile(filePath, content, { mode: 0o755 });
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const makeTempMedia = async (ext: string) => {
|
||||
const dir = await makeTempDir("clawdbot-media-e2e-");
|
||||
const filePath = path.join(dir, `sample${ext}`);
|
||||
await fs.writeFile(filePath, "audio");
|
||||
return { dir, filePath };
|
||||
};
|
||||
|
||||
const loadApply = async () => {
|
||||
vi.resetModules();
|
||||
return await import("../src/media-understanding/apply.js");
|
||||
};
|
||||
|
||||
const envSnapshot = () => ({
|
||||
PATH: process.env.PATH,
|
||||
SHERPA_ONNX_MODEL_DIR: process.env.SHERPA_ONNX_MODEL_DIR,
|
||||
WHISPER_CPP_MODEL: process.env.WHISPER_CPP_MODEL,
|
||||
});
|
||||
|
||||
const restoreEnv = (snapshot: ReturnType<typeof envSnapshot>) => {
|
||||
process.env.PATH = snapshot.PATH;
|
||||
process.env.SHERPA_ONNX_MODEL_DIR = snapshot.SHERPA_ONNX_MODEL_DIR;
|
||||
process.env.WHISPER_CPP_MODEL = snapshot.WHISPER_CPP_MODEL;
|
||||
};
|
||||
|
||||
describe("media understanding auto-detect (e2e)", () => {
|
||||
let tempPaths: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const p of tempPaths) {
|
||||
await fs.rm(p, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
tempPaths = [];
|
||||
});
|
||||
|
||||
it("uses sherpa-onnx-offline when available", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("clawdbot-bin-sherpa-");
|
||||
const modelDir = await makeTempDir("clawdbot-sherpa-model-");
|
||||
tempPaths.push(binDir, modelDir);
|
||||
|
||||
await fs.writeFile(path.join(modelDir, "tokens.txt"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "encoder.onnx"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "decoder.onnx"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "joiner.onnx"), "a");
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"sherpa-onnx-offline",
|
||||
"#!/usr/bin/env bash\n" + 'echo "{\\"text\\":\\"sherpa ok\\"}"\n',
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
process.env.SHERPA_ONNX_MODEL_DIR = modelDir;
|
||||
|
||||
const { filePath } = await makeTempMedia(".wav");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:audio>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "audio/wav",
|
||||
};
|
||||
const cfg: ClawdbotConfig = { tools: { media: { audio: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Transcript).toBe("sherpa ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses whisper-cli when sherpa is missing", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("clawdbot-bin-whispercpp-");
|
||||
const modelDir = await makeTempDir("clawdbot-whispercpp-model-");
|
||||
tempPaths.push(binDir, modelDir);
|
||||
|
||||
const modelPath = path.join(modelDir, "tiny.bin");
|
||||
await fs.writeFile(modelPath, "model");
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"whisper-cli",
|
||||
"#!/usr/bin/env bash\n" +
|
||||
'out=""\n' +
|
||||
'prev=""\n' +
|
||||
'for arg in "$@"; do\n' +
|
||||
' if [ "$prev" = "-of" ]; then out="$arg"; break; fi\n' +
|
||||
' prev="$arg"\n' +
|
||||
"done\n" +
|
||||
'if [ -n "$out" ]; then echo \'whisper cpp ok\' > "${out}.txt"; fi\n',
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
process.env.WHISPER_CPP_MODEL = modelPath;
|
||||
|
||||
const { filePath } = await makeTempMedia(".wav");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:audio>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "audio/wav",
|
||||
};
|
||||
const cfg: ClawdbotConfig = { tools: { media: { audio: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Transcript).toBe("whisper cpp ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses gemini CLI for images when available", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("clawdbot-bin-gemini-");
|
||||
tempPaths.push(binDir);
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"gemini",
|
||||
"#!/usr/bin/env bash\necho '{" + '\\"response\\":\\"gemini ok\\"' + "}'\n",
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
|
||||
const { filePath } = await makeTempMedia(".png");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:image>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "image/png",
|
||||
};
|
||||
const cfg: ClawdbotConfig = { tools: { media: { image: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Body).toContain("gemini ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -415,51 +415,6 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.callout.info {
|
||||
border-color: rgba(92, 156, 255, 0.4);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.callout.success {
|
||||
border-color: rgba(92, 255, 128, 0.4);
|
||||
color: var(--positive, #5cff80);
|
||||
}
|
||||
|
||||
.compaction-indicator {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
animation: compaction-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.compaction-indicator--active {
|
||||
animation: compaction-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.compaction-indicator--complete {
|
||||
animation: compaction-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes compaction-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes compaction-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -145,14 +145,6 @@ export function connectGateway(host: GatewayHost) {
|
||||
}
|
||||
|
||||
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
try {
|
||||
handleGatewayEventUnsafe(host, evt);
|
||||
} catch (err) {
|
||||
console.error("[gateway] handleGatewayEvent error:", evt.event, err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
host.eventLogBuffer = [
|
||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||
...host.eventLogBuffer,
|
||||
|
||||
@@ -444,7 +444,6 @@ export function renderApp(state: AppViewState) {
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
|
||||
@@ -138,59 +138,8 @@ export function resetToolStream(host: ToolStreamHost) {
|
||||
flushToolStreamSync(host);
|
||||
}
|
||||
|
||||
export type CompactionStatus = {
|
||||
active: boolean;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
type CompactionHost = ToolStreamHost & {
|
||||
compactionStatus?: CompactionStatus | null;
|
||||
compactionClearTimer?: number | null;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
|
||||
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
|
||||
// Clear any existing timer
|
||||
if (host.compactionClearTimer != null) {
|
||||
window.clearTimeout(host.compactionClearTimer);
|
||||
host.compactionClearTimer = null;
|
||||
}
|
||||
|
||||
if (phase === "start") {
|
||||
host.compactionStatus = {
|
||||
active: true,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
};
|
||||
} else if (phase === "end") {
|
||||
host.compactionStatus = {
|
||||
active: false,
|
||||
startedAt: host.compactionStatus?.startedAt ?? null,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
// Auto-clear the toast after duration
|
||||
host.compactionClearTimer = window.setTimeout(() => {
|
||||
host.compactionStatus = null;
|
||||
host.compactionClearTimer = null;
|
||||
}, COMPACTION_TOAST_DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||
if (!payload) return;
|
||||
|
||||
// Handle compaction events
|
||||
if (payload.stream === "compaction") {
|
||||
handleCompactionEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream !== "tool") return;
|
||||
if (!payload || payload.stream !== "tool") return;
|
||||
const sessionKey =
|
||||
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) return;
|
||||
|
||||
@@ -125,7 +125,6 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
|
||||
@@ -254,11 +254,7 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
try {
|
||||
this.opts.onEvent?.(evt);
|
||||
} catch (err) {
|
||||
console.error("[gateway] event handler error:", err);
|
||||
}
|
||||
this.opts.onEvent?.(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,6 @@ import {
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||
import "../components/resizable-divider";
|
||||
|
||||
export type CompactionIndicatorStatus = {
|
||||
active: boolean;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type ChatProps = {
|
||||
sessionKey: string;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
@@ -30,7 +24,6 @@ export type ChatProps = {
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
canAbort?: boolean;
|
||||
compactionStatus?: CompactionIndicatorStatus | null;
|
||||
messages: unknown[];
|
||||
toolMessages: unknown[];
|
||||
stream: string | null;
|
||||
@@ -66,35 +59,6 @@ export type ChatProps = {
|
||||
onChatScroll?: (event: Event) => void;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
|
||||
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
|
||||
if (!status) return nothing;
|
||||
|
||||
// Show "compacting..." while active
|
||||
if (status.active) {
|
||||
return html`
|
||||
<div class="callout info compaction-indicator compaction-indicator--active">
|
||||
🧹 Compacting context...
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show "compaction complete" briefly after completion
|
||||
if (status.completedAt) {
|
||||
const elapsed = Date.now() - status.completedAt;
|
||||
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
|
||||
return html`
|
||||
<div class="callout success compaction-indicator compaction-indicator--complete">
|
||||
🧹 Context compacted
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
export function renderChat(props: ChatProps) {
|
||||
const canCompose = props.connected;
|
||||
const isBusy = props.sending || props.stream !== null;
|
||||
@@ -125,8 +89,6 @@ export function renderChat(props: ChatProps) {
|
||||
? html`<div class="callout danger">${props.error}</div>`
|
||||
: nothing}
|
||||
|
||||
${renderCompactionIndicator(props.compactionStatus)}
|
||||
|
||||
${props.focusMode
|
||||
? html`
|
||||
<button
|
||||
|
||||
Reference in new issue
Block a user