Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48751a99c | ||
|
|
86e0916fa3 | ||
|
|
0c7e649676 | ||
|
|
45ce07a098 | ||
|
|
aed8dc1ade | ||
|
|
86a341be62 | ||
|
|
ff78e9a564 | ||
|
|
60a60779d7 | ||
|
|
32da00cb2f | ||
|
|
0420f2804c | ||
|
|
4de660bec6 | ||
|
|
58f638463f | ||
|
|
f1afc722da | ||
|
|
bc75d58e9e | ||
|
|
2efd265697 | ||
|
|
9c1f1476bc | ||
|
|
e8352c8d21 | ||
|
|
551685351f | ||
|
|
3fbbac07fe | ||
|
|
03bec49299 | ||
|
|
6779ba2367 | ||
|
|
8598e906ef | ||
|
|
300fc486a4 | ||
|
|
f014b46b56 | ||
|
|
833f5acda1 | ||
|
|
d03c404cb4 | ||
|
|
68ea6e521b | ||
|
|
4912e85ac8 | ||
|
|
39d8ff59aa | ||
|
|
070944f64f | ||
|
|
d4db45e8a9 | ||
|
|
451792d326 | ||
|
|
c7ca312f97 | ||
|
|
1e6e58b23b | ||
|
|
e98e71401a | ||
|
|
bec1d0d3d4 | ||
|
|
9f6ea67415 |
No files matched your search
@@ -51,6 +51,7 @@
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- Full kit + what’s covered: `docs/testing.md`.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
|
||||
+21
-17
@@ -5,32 +5,22 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.22
|
||||
|
||||
### Changes
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
||||
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
|
||||
- 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
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
- 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.
|
||||
- Compaction: harden oversized summarization handling and gate UI compaction status by verbose level. (#1466) Thanks @dlauer.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
||||
- Gateway: stop the service before uninstalling and fail if it remains loaded.
|
||||
- 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.
|
||||
@@ -43,9 +33,10 @@ Docs: https://docs.clawd.bot
|
||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: 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.
|
||||
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||
|
||||
## 2026.1.21-2
|
||||
|
||||
@@ -56,6 +47,8 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
@@ -65,13 +58,24 @@ Docs: https://docs.clawd.bot
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
|
||||
@@ -102,7 +102,9 @@ 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,12 +248,11 @@ final class GatewayProcessManager {
|
||||
guard let linkId else {
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let linked = snap.channels[linkId]?.linked ?? false
|
||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"channel"
|
||||
snap.channelLabels?[linkId] ??
|
||||
linkId.capitalized
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ provider in two different ways.
|
||||
|
||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||
|
||||
Use the native device-login flow to obtain a GitHub token and use it directly
|
||||
against the Copilot API. This is the **default** and simplest path because it
|
||||
does not require VS Code. Enterprise domains are supported.
|
||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||
because it does not require VS Code.
|
||||
|
||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||
|
||||
@@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot
|
||||
|
||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||
open until it completes.
|
||||
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
|
||||
domain (for example `company.ghe.com`).
|
||||
|
||||
### Optional flags
|
||||
|
||||
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
|
||||
- Requires an interactive TTY; run it directly in a terminal.
|
||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||
another ID (for example `github-copilot/gpt-4.1`).
|
||||
- The login stores a GitHub token in the auth profile store and uses it directly
|
||||
for Copilot API calls.
|
||||
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
|
||||
for GitHub Enterprise.
|
||||
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||
Copilot API token when Clawdbot runs.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/mattermost",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.22",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Mattermost channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -14,11 +14,12 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
// Skip if no OpenAI API key
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
|
||||
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;
|
||||
|
||||
describeWithKey("memory plugin e2e", () => {
|
||||
describe("memory plugin e2e", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -159,7 +160,7 @@ describeWithKey("memory plugin e2e", () => {
|
||||
});
|
||||
|
||||
// Live tests that require OpenAI API key and actually use LanceDB
|
||||
describeWithKey("memory plugin live tests", () => {
|
||||
describeLive("memory plugin live tests", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
@@ -176,6 +177,7 @@ describeWithKey("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[] = [];
|
||||
@@ -191,7 +193,7 @@ describeWithKey("memory plugin live tests", () => {
|
||||
config: {},
|
||||
pluginConfig: {
|
||||
embedding: {
|
||||
apiKey: OPENAI_API_KEY,
|
||||
apiKey: liveApiKey,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/open-prose",
|
||||
"version": "2026.1.23",
|
||||
"version": "2026.1.22",
|
||||
"type": "module",
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"clawdbot": {
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@
|
||||
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
||||
"format:all": "pnpm format && pnpm format:swift",
|
||||
"format:fix": "oxfmt --write src test",
|
||||
"test": "vitest run",
|
||||
"test": "node scripts/test-parallel.mjs",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "pnpm --dir ui test",
|
||||
"test:force": "node --import tsx scripts/test-force.ts",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
|
||||
const runs = [
|
||||
{
|
||||
name: "unit",
|
||||
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
|
||||
},
|
||||
{
|
||||
name: "gateway",
|
||||
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
|
||||
},
|
||||
];
|
||||
|
||||
const children = new Set();
|
||||
|
||||
const run = (entry) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(pnpm, entry.args, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, VITEST_GROUP: entry.name },
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
children.add(child);
|
||||
child.on("exit", (code, signal) => {
|
||||
children.delete(child);
|
||||
resolve(code ?? (signal ? 1 : 0));
|
||||
});
|
||||
});
|
||||
|
||||
const shutdown = (signal) => {
|
||||
for (const child of children) {
|
||||
child.kill(signal);
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
const codes = await Promise.all(runs.map(run));
|
||||
const failed = codes.find((code) => code !== 0);
|
||||
process.exit(failed ?? 0);
|
||||
@@ -1,70 +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 AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
getOAuthApiKey: vi.fn(() => {
|
||||
throw new Error("refresh should not be called");
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("auth-profiles (github-copilot)", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
});
|
||||
|
||||
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "oauth",
|
||||
provider: "github-copilot",
|
||||
refresh: "gh-token",
|
||||
access: "gh-token",
|
||||
expires: 0,
|
||||
enterpriseUrl: "company.ghe.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
|
||||
|
||||
const loaded = ensureAuthProfileStore();
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
store: loaded,
|
||||
profileId: "github-copilot:github",
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("gh-token");
|
||||
});
|
||||
});
|
||||
@@ -39,15 +39,6 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
const cred = store.profiles[params.profileId];
|
||||
if (!cred || cred.type !== "oauth") return null;
|
||||
|
||||
if (
|
||||
cred.provider === "github-copilot" &&
|
||||
(!Number.isFinite(cred.expires) || cred.expires <= 0)
|
||||
) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
newCredentials: cred,
|
||||
};
|
||||
}
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
@@ -112,20 +103,6 @@ async function tryResolveOAuthProfile(params: {
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||
|
||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
provider: cred.provider,
|
||||
email: cred.email,
|
||||
};
|
||||
}
|
||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
provider: cred.provider,
|
||||
email: cred.email,
|
||||
};
|
||||
}
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
|
||||
@@ -19,7 +19,6 @@ export type TokenCredential = {
|
||||
token: string;
|
||||
/** Optional expiry timestamp (ms since epoch). */
|
||||
expires?: number;
|
||||
enterpriseUrl?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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 } from "../logger.js";
|
||||
import { logInfo, logWarn } from "../logger.js";
|
||||
import {
|
||||
type ProcessSession,
|
||||
type SessionStdin,
|
||||
@@ -381,41 +381,56 @@ async function runExecProcess(opts: {
|
||||
) as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
} else if (opts.usePty) {
|
||||
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
|
||||
}
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
child = spawn(shell, [...shellArgs, opts.command], {
|
||||
@@ -1320,7 +1335,7 @@ export function createExecTool(
|
||||
|
||||
const effectiveTimeout =
|
||||
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
|
||||
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
|
||||
const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : "");
|
||||
const usePty = params.pty === true && !sandbox;
|
||||
const run = await runExecProcess({
|
||||
command: params.command,
|
||||
@@ -1360,7 +1375,7 @@ export function createExecTool(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warningText}` +
|
||||
`${getWarningText()}` +
|
||||
`Command still running (session ${run.session.id}, pid ${
|
||||
run.session.pid ?? "n/a"
|
||||
}). ` +
|
||||
@@ -1410,7 +1425,7 @@ export function createExecTool(
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${warningText}${outcome.aggregated || "(no output)"}`,
|
||||
text: `${getWarningText()}${outcome.aggregated || "(no output)"}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
|
||||
-235
@@ -1,235 +0,0 @@
|
||||
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,6 +21,7 @@ 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";
|
||||
|
||||
@@ -120,4 +121,195 @@ 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");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||
@@ -52,6 +51,16 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
const agentDir = path.join(home, "agent-default-base-url");
|
||||
@@ -62,55 +71,48 @@ describe("models-config", () => {
|
||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
it("uses enterprise URL from auth profiles to derive base URL", async () => {
|
||||
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
|
||||
process.env.GH_TOKEN = "gh-token";
|
||||
process.env.GITHUB_TOKEN = "github-token";
|
||||
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "oauth",
|
||||
provider: "github-copilot",
|
||||
refresh: "gh-token",
|
||||
access: "gh-token",
|
||||
expires: 0,
|
||||
enterpriseUrl: "company.ghe.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } });
|
||||
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://copilot-api.company.ghe.com",
|
||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "copilot-token" }),
|
||||
);
|
||||
} finally {
|
||||
// no-op
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||
@@ -44,7 +43,7 @@ describe("models-config", () => {
|
||||
process.env.HOME = previousHome;
|
||||
});
|
||||
|
||||
it("uses default baseUrl when env token is present", async () => {
|
||||
it("falls back to default baseUrl when token exchange fails", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||
@@ -52,6 +51,11 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
|
||||
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
@@ -63,13 +67,13 @@ describe("models-config", () => {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
it("normalizes enterprise URL when deriving base URL", async () => {
|
||||
it("uses agentDir override auth profiles for copilot injection", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
@@ -90,12 +94,9 @@ describe("models-config", () => {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
refresh: "gh-profile-token",
|
||||
access: "gh-profile-token",
|
||||
expires: 0,
|
||||
enterpriseUrl: "https://company.ghe.com/",
|
||||
token: "gh-profile-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,6 +105,16 @@ describe("models-config", () => {
|
||||
),
|
||||
);
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
@@ -113,9 +124,7 @@ describe("models-config", () => {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://copilot-api.company.ghe.com",
|
||||
);
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
normalizeGithubCopilotDomain,
|
||||
resolveGithubCopilotBaseUrl,
|
||||
} from "../providers/github-copilot-utils.js";
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
@@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: {
|
||||
|
||||
if (!hasProfile && !githubToken) return null;
|
||||
|
||||
let enterpriseDomain: string | null = null;
|
||||
if (hasProfile) {
|
||||
let selectedGithubToken = githubToken;
|
||||
if (!selectedGithubToken && hasProfile) {
|
||||
// Use the first available profile as a default for discovery (it will be
|
||||
// re-resolved per-run by the embedded runner).
|
||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
|
||||
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
|
||||
if (profile && profile.type === "token") {
|
||||
selectedGithubToken = profile.token;
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
|
||||
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
if (selectedGithubToken) {
|
||||
try {
|
||||
const token = await resolveCopilotApiToken({
|
||||
githubToken: selectedGithubToken,
|
||||
env,
|
||||
});
|
||||
baseUrl = token.baseUrl;
|
||||
} catch {
|
||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||
@@ -353,7 +364,7 @@ export async function resolveImplicitCopilotProvider(params: {
|
||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||
// patterns for OAuth-like providers in pi-coding-agent.
|
||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
|
||||
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||
|
||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||
@@ -81,16 +80,25 @@ describe("models-config", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
});
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "alpha-token" }),
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
@@ -109,6 +117,16 @@ describe("models-config", () => {
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
|
||||
@@ -16,13 +16,15 @@ vi.mock("./pi-embedded-runner/run/attempt.js", () => ({
|
||||
|
||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
runEmbeddedAttemptMock.mockReset();
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
runEmbeddedAttemptMock.mockReset();
|
||||
});
|
||||
|
||||
const baseUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
|
||||
@@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: {
|
||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||
);
|
||||
}
|
||||
} else if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } =
|
||||
await import("../../providers/github-copilot-token.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
|
||||
@@ -7,20 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { normalizeModelCompat } from "../model-compat.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
|
||||
|
||||
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
||||
|
||||
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
|
||||
if (model.provider === "github-copilot") {
|
||||
const headers = model.headers
|
||||
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
|
||||
: { "User-Agent": resolveGithubCopilotUserAgent() };
|
||||
return { ...model, headers };
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
export function buildInlineProviderModels(
|
||||
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
||||
): InlineModelEntry[] {
|
||||
@@ -71,7 +60,7 @@ export function resolveModel(
|
||||
if (inlineMatch) {
|
||||
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
||||
return {
|
||||
model: applyProviderModelOverrides(normalized),
|
||||
model: normalized,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
};
|
||||
@@ -89,7 +78,7 @@ export function resolveModel(
|
||||
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
||||
} as Model<Api>);
|
||||
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
|
||||
return { model: fallbackModel, authStorage, modelRegistry };
|
||||
}
|
||||
return {
|
||||
error: `Unknown model: ${provider}/${modelId}`,
|
||||
@@ -97,9 +86,5 @@ export function resolveModel(
|
||||
modelRegistry,
|
||||
};
|
||||
}
|
||||
return {
|
||||
model: applyProviderModelOverrides(normalizeModelCompat(model)),
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
};
|
||||
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
|
||||
}
|
||||
@@ -184,8 +184,17 @@ export async function runEmbeddedPiAgent(
|
||||
lastProfileId = resolvedProfileId;
|
||||
return;
|
||||
}
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
lastProfileId = resolvedProfileId;
|
||||
if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } =
|
||||
await import("../../providers/github-copilot-token.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
lastProfileId = apiKeyInfo.profileId;
|
||||
};
|
||||
|
||||
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
||||
* - <invoke name="...">...</invoke> blocks
|
||||
* - </minimax:tool_call> closing tags
|
||||
*/
|
||||
function stripMinimaxToolCallXml(text: string): string {
|
||||
export function stripMinimaxToolCallXml(text: string): string {
|
||||
if (!text) return text;
|
||||
if (!/minimax:tool_call/i.test(text)) return text;
|
||||
|
||||
@@ -28,7 +28,7 @@ function stripMinimaxToolCallXml(text: string): string {
|
||||
* downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should
|
||||
* not be shown to users.
|
||||
*/
|
||||
function stripDowngradedToolCallText(text: string): string {
|
||||
export function stripDowngradedToolCallText(text: string): string {
|
||||
if (!text) return text;
|
||||
if (!/\[Tool (?:Call|Result)/i.test(text)) return text;
|
||||
|
||||
@@ -165,7 +165,7 @@ function stripDowngradedToolCallText(text: string): string {
|
||||
* This is a safety net for cases where the model outputs <think> tags
|
||||
* that slip through other filtering mechanisms.
|
||||
*/
|
||||
function stripThinkingTagsFromText(text: string): string {
|
||||
export 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;
|
||||
|
||||
@@ -15,15 +15,6 @@ const TURN_PREFIX_INSTRUCTIONS =
|
||||
const MAX_TOOL_FAILURES = 8;
|
||||
const MAX_TOOL_FAILURE_CHARS = 240;
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const name = "name" in err ? String(err.name) : "";
|
||||
if (name === "AbortError") return true;
|
||||
const message =
|
||||
"message" in err && typeof err.message === "string" ? err.message.toLowerCase() : "";
|
||||
return message.includes("aborted");
|
||||
}
|
||||
|
||||
type ToolFailure = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
@@ -260,9 +251,6 @@ async function summarizeWithFallback(params: {
|
||||
try {
|
||||
return await summarizeChunks(params);
|
||||
} catch (fullError) {
|
||||
if (params.signal.aborted || isAbortError(fullError)) {
|
||||
throw fullError;
|
||||
}
|
||||
console.warn(
|
||||
`Full summarization failed, trying partial: ${
|
||||
fullError instanceof Error ? fullError.message : String(fullError)
|
||||
@@ -295,9 +283,6 @@ async function summarizeWithFallback(params: {
|
||||
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
|
||||
return partialSummary + notes;
|
||||
} catch (partialError) {
|
||||
if (params.signal.aborted || isAbortError(partialError)) {
|
||||
throw partialError;
|
||||
}
|
||||
console.warn(
|
||||
`Partial summarization also failed: ${
|
||||
partialError instanceof Error ? partialError.message : String(partialError)
|
||||
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
describe("Claude/Gemini alias support", () => {
|
||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||
const base: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string", description: "Path" },
|
||||
content: { type: "string", description: "Body" },
|
||||
},
|
||||
},
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||
const params = patched.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
const props = params.properties ?? {};
|
||||
|
||||
expect(props.file_path).toEqual(props.path);
|
||||
expect(params.required ?? []).not.toContain("path");
|
||||
expect(params.required ?? []).not.toContain("file_path");
|
||||
});
|
||||
|
||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
||||
const browser = createBrowserTool();
|
||||
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.anyOf).toBeUndefined();
|
||||
});
|
||||
it("mentions Chrome extension relay in browser tool description", () => {
|
||||
const browser = createBrowserTool();
|
||||
expect(browser.description).toMatch(/Chrome extension/i);
|
||||
expect(browser.description).toMatch(/profile="chrome"/i);
|
||||
});
|
||||
it("keeps browser tool schema properties after normalization", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const browser = tools.find((tool) => tool.name === "browser");
|
||||
expect(browser).toBeDefined();
|
||||
const parameters = browser?.parameters as {
|
||||
anyOf?: unknown[];
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
expect(parameters.properties?.action).toBeDefined();
|
||||
expect(parameters.properties?.target).toBeDefined();
|
||||
expect(parameters.properties?.controlUrl).toBeDefined();
|
||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||
expect(parameters.properties?.request).toBeDefined();
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
});
|
||||
it("exposes raw for gateway config.apply tool calls", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const gateway = tools.find((tool) => tool.name === "gateway");
|
||||
expect(gateway).toBeDefined();
|
||||
|
||||
const parameters = gateway?.parameters as {
|
||||
type?: unknown;
|
||||
required?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
expect(parameters.type).toBe("object");
|
||||
expect(parameters.properties?.raw).toBeDefined();
|
||||
expect(parameters.required ?? []).not.toContain("raw");
|
||||
});
|
||||
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const browser = tools.find((tool) => tool.name === "browser");
|
||||
expect(browser).toBeDefined();
|
||||
|
||||
const parameters = browser?.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
const action = parameters.properties?.action as
|
||||
| {
|
||||
type?: unknown;
|
||||
enum?: unknown[];
|
||||
anyOf?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(action?.type).toBe("string");
|
||||
expect(action?.anyOf).toBeUndefined();
|
||||
expect(Array.isArray(action?.enum)).toBe(true);
|
||||
expect(action?.enum).toContain("act");
|
||||
|
||||
const snapshotFormat = parameters.properties?.snapshotFormat as
|
||||
| {
|
||||
type?: unknown;
|
||||
enum?: unknown[];
|
||||
anyOf?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
expect(snapshotFormat?.type).toBe("string");
|
||||
expect(snapshotFormat?.anyOf).toBeUndefined();
|
||||
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
||||
});
|
||||
it("inlines local $ref before removing unsupported keywords", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
foo: { $ref: "#/$defs/Foo" },
|
||||
},
|
||||
$defs: {
|
||||
Foo: { type: "string", enum: ["a", "b"] },
|
||||
},
|
||||
}) as {
|
||||
$defs?: unknown;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(cleaned.$defs).toBeUndefined();
|
||||
expect(cleaned.properties).toBeDefined();
|
||||
expect(cleaned.properties?.foo).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
});
|
||||
});
|
||||
it("cleans tuple items schemas", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuples: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "string", format: "uuid" },
|
||||
{ type: "number", minimum: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
||||
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
||||
const first = items[0] as { format?: unknown } | undefined;
|
||||
const second = items[1] as { minimum?: unknown } | undefined;
|
||||
|
||||
expect(first?.format).toBeUndefined();
|
||||
expect(second?.minimum).toBeUndefined();
|
||||
});
|
||||
it("drops null-only union variants without flattening other unions", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const parentId = cleaned.properties?.parentId as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
expect(parentId?.anyOf).toBeUndefined();
|
||||
expect(parentId?.oneOf).toBeUndefined();
|
||||
expect(parentId?.type).toBe("string");
|
||||
|
||||
const count = cleaned.properties?.count as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
expect(count?.anyOf).toBeUndefined();
|
||||
expect(Array.isArray(count?.oneOf)).toBe(true);
|
||||
});
|
||||
});
|
||||
+8
-72
@@ -1,74 +1,11 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
const defaultTools = createClawdbotCodingTools();
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
describe("Claude/Gemini alias support", () => {
|
||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||
const base: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string", description: "Path" },
|
||||
content: { type: "string", description: "Body" },
|
||||
},
|
||||
},
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||
const params = patched.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
const props = params.properties ?? {};
|
||||
|
||||
expect(props.file_path).toEqual(props.path);
|
||||
expect(params.required ?? []).not.toContain("path");
|
||||
expect(params.required ?? []).not.toContain("file_path");
|
||||
});
|
||||
|
||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves action enums in normalized schemas", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
||||
|
||||
const collectActionValues = (schema: unknown, values: Set<string>): void => {
|
||||
@@ -88,7 +25,7 @@ describe("createClawdbotCodingTools", () => {
|
||||
};
|
||||
|
||||
for (const name of toolNames) {
|
||||
const tool = tools.find((candidate) => candidate.name === name);
|
||||
const tool = defaultTools.find((candidate) => candidate.name === name);
|
||||
expect(tool).toBeDefined();
|
||||
const parameters = tool?.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
@@ -108,10 +45,9 @@ describe("createClawdbotCodingTools", () => {
|
||||
}
|
||||
});
|
||||
it("includes exec and process tools by default", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "process")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
||||
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
|
||||
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
|
||||
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
||||
});
|
||||
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
|
||||
const config: ClawdbotConfig = {
|
||||
|
||||
+5
-68
@@ -1,78 +1,15 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
const defaultTools = createClawdbotCodingTools();
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
describe("Claude/Gemini alias support", () => {
|
||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||
const base: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string", description: "Path" },
|
||||
content: { type: "string", description: "Body" },
|
||||
},
|
||||
},
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||
const params = patched.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
const props = params.properties ?? {};
|
||||
|
||||
expect(props.file_path).toEqual(props.path);
|
||||
expect(params.required ?? []).not.toContain("path");
|
||||
expect(params.required ?? []).not.toContain("file_path");
|
||||
});
|
||||
|
||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps read tool image metadata intact", async () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const readTool = tools.find((tool) => tool.name === "read");
|
||||
const readTool = defaultTools.find((tool) => tool.name === "read");
|
||||
expect(readTool).toBeDefined();
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
||||
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
describe("Claude/Gemini alias support", () => {
|
||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||
const base: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string", description: "Path" },
|
||||
content: { type: "string", description: "Body" },
|
||||
},
|
||||
},
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||
const params = patched.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
const props = params.properties ?? {};
|
||||
|
||||
expect(props.file_path).toEqual(props.path);
|
||||
expect(params.required ?? []).not.toContain("path");
|
||||
expect(params.required ?? []).not.toContain("file_path");
|
||||
});
|
||||
|
||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies tool profiles before allow/deny policies", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { profile: "messaging" } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("sessions_send")).toBe(true);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
it("expands group shorthands in global tool policy", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { allow: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("write")).toBe(true);
|
||||
expect(names.has("edit")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
it("expands group shorthands in global tool deny policy", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { deny: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(false);
|
||||
expect(names.has("write")).toBe(false);
|
||||
expect(names.has("edit")).toBe(false);
|
||||
expect(names.has("exec")).toBe(true);
|
||||
});
|
||||
it("lets agent profiles override global profiles", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
sessionKey: "agent:work:main",
|
||||
config: {
|
||||
tools: { profile: "coding" },
|
||||
agents: {
|
||||
list: [{ id: "work", tools: { profile: "messaging" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("read")).toBe(false);
|
||||
});
|
||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
|
||||
// Helper to recursively check schema for unsupported keywords
|
||||
const unsupportedKeywords = new Set([
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"$schema",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"examples",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
|
||||
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
|
||||
const found: string[] = [];
|
||||
if (!schema || typeof schema !== "object") return found;
|
||||
if (Array.isArray(schema)) {
|
||||
schema.forEach((item, i) => {
|
||||
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const record = schema as Record<string, unknown>;
|
||||
const properties =
|
||||
record.properties &&
|
||||
typeof record.properties === "object" &&
|
||||
!Array.isArray(record.properties)
|
||||
? (record.properties as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (properties) {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key === "properties") continue;
|
||||
if (unsupportedKeywords.has(key)) {
|
||||
found.push(`${path}.${key}`);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
for (const tool of tools) {
|
||||
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-66
@@ -1,74 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
describe("Claude/Gemini alias support", () => {
|
||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||
const base: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string", description: "Path" },
|
||||
content: { type: "string", description: "Body" },
|
||||
},
|
||||
},
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||
const params = patched.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
const props = params.properties ?? {};
|
||||
|
||||
expect(props.file_path).toEqual(props.path);
|
||||
expect(params.required ?? []).not.toContain("path");
|
||||
expect(params.required ?? []).not.toContain("file_path");
|
||||
});
|
||||
|
||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses workspaceDir for Read tool path resolution", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||
try {
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
describe("Claude/Gemini alias support", () => {
|
||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||
const base: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string", description: "Path" },
|
||||
content: { type: "string", description: "Body" },
|
||||
},
|
||||
},
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||
const params = patched.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
const props = params.properties ?? {};
|
||||
|
||||
expect(props.file_path).toEqual(props.path);
|
||||
expect(params.required ?? []).not.toContain("path");
|
||||
expect(params.required ?? []).not.toContain("file_path");
|
||||
});
|
||||
|
||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
description: "test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies sandbox path guards to file_path alias", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
|
||||
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
|
||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool(tmpDir);
|
||||
await expect(readTool.execute("tool-sbx-1", { file_path: outsidePath })).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(outsidePath, { force: true });
|
||||
}
|
||||
});
|
||||
it("falls back to process.cwd() when workspaceDir not provided", () => {
|
||||
const prevCwd = process.cwd();
|
||||
const tools = createClawdbotCodingTools();
|
||||
// Tools should be created without error
|
||||
expect(tools.some((tool) => tool.name === "read")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "write")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "edit")).toBe(true);
|
||||
// cwd should be unchanged
|
||||
expect(process.cwd()).toBe(prevCwd);
|
||||
});
|
||||
});
|
||||
+272
-2
@@ -1,7 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||
|
||||
const defaultTools = createClawdbotCodingTools();
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
describe("Claude/Gemini alias support", () => {
|
||||
@@ -67,8 +74,144 @@ describe("createClawdbotCodingTools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
||||
const browser = createBrowserTool();
|
||||
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.anyOf).toBeUndefined();
|
||||
});
|
||||
it("mentions Chrome extension relay in browser tool description", () => {
|
||||
const browser = createBrowserTool();
|
||||
expect(browser.description).toMatch(/Chrome extension/i);
|
||||
expect(browser.description).toMatch(/profile="chrome"/i);
|
||||
});
|
||||
it("keeps browser tool schema properties after normalization", () => {
|
||||
const browser = defaultTools.find((tool) => tool.name === "browser");
|
||||
expect(browser).toBeDefined();
|
||||
const parameters = browser?.parameters as {
|
||||
anyOf?: unknown[];
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
expect(parameters.properties?.action).toBeDefined();
|
||||
expect(parameters.properties?.target).toBeDefined();
|
||||
expect(parameters.properties?.controlUrl).toBeDefined();
|
||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||
expect(parameters.properties?.request).toBeDefined();
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
});
|
||||
it("exposes raw for gateway config.apply tool calls", () => {
|
||||
const gateway = defaultTools.find((tool) => tool.name === "gateway");
|
||||
expect(gateway).toBeDefined();
|
||||
|
||||
const parameters = gateway?.parameters as {
|
||||
type?: unknown;
|
||||
required?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
expect(parameters.type).toBe("object");
|
||||
expect(parameters.properties?.raw).toBeDefined();
|
||||
expect(parameters.required ?? []).not.toContain("raw");
|
||||
});
|
||||
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
||||
const browser = defaultTools.find((tool) => tool.name === "browser");
|
||||
expect(browser).toBeDefined();
|
||||
|
||||
const parameters = browser?.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
const action = parameters.properties?.action as
|
||||
| {
|
||||
type?: unknown;
|
||||
enum?: unknown[];
|
||||
anyOf?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(action?.type).toBe("string");
|
||||
expect(action?.anyOf).toBeUndefined();
|
||||
expect(Array.isArray(action?.enum)).toBe(true);
|
||||
expect(action?.enum).toContain("act");
|
||||
|
||||
const snapshotFormat = parameters.properties?.snapshotFormat as
|
||||
| {
|
||||
type?: unknown;
|
||||
enum?: unknown[];
|
||||
anyOf?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
expect(snapshotFormat?.type).toBe("string");
|
||||
expect(snapshotFormat?.anyOf).toBeUndefined();
|
||||
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
||||
});
|
||||
it("inlines local $ref before removing unsupported keywords", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
foo: { $ref: "#/$defs/Foo" },
|
||||
},
|
||||
$defs: {
|
||||
Foo: { type: "string", enum: ["a", "b"] },
|
||||
},
|
||||
}) as {
|
||||
$defs?: unknown;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(cleaned.$defs).toBeUndefined();
|
||||
expect(cleaned.properties).toBeDefined();
|
||||
expect(cleaned.properties?.foo).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
});
|
||||
});
|
||||
it("cleans tuple items schemas", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuples: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "string", format: "uuid" },
|
||||
{ type: "number", minimum: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
||||
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
||||
const first = items[0] as { format?: unknown } | undefined;
|
||||
const second = items[1] as { minimum?: unknown } | undefined;
|
||||
|
||||
expect(first?.format).toBeUndefined();
|
||||
expect(second?.minimum).toBeUndefined();
|
||||
});
|
||||
it("drops null-only union variants without flattening other unions", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const parentId = cleaned.properties?.parentId as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
const count = cleaned.properties?.count as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
|
||||
expect(parentId?.type).toBe("string");
|
||||
expect(parentId?.anyOf).toBeUndefined();
|
||||
expect(count?.oneOf).toBeDefined();
|
||||
});
|
||||
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const offenders: Array<{
|
||||
name: string;
|
||||
keyword: string;
|
||||
@@ -96,7 +239,7 @@ describe("createClawdbotCodingTools", () => {
|
||||
}
|
||||
};
|
||||
|
||||
for (const tool of tools) {
|
||||
for (const tool of defaultTools) {
|
||||
walk(tool.parameters, "", tool.name);
|
||||
}
|
||||
|
||||
@@ -192,4 +335,131 @@ describe("createClawdbotCodingTools", () => {
|
||||
});
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("applies tool profiles before allow/deny policies", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { profile: "messaging" } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("sessions_send")).toBe(true);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
it("expands group shorthands in global tool policy", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { allow: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("write")).toBe(true);
|
||||
expect(names.has("edit")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
it("expands group shorthands in global tool deny policy", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { deny: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(false);
|
||||
expect(names.has("write")).toBe(false);
|
||||
expect(names.has("edit")).toBe(false);
|
||||
expect(names.has("exec")).toBe(true);
|
||||
});
|
||||
it("lets agent profiles override global profiles", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
sessionKey: "agent:work:main",
|
||||
config: {
|
||||
tools: { profile: "coding" },
|
||||
agents: {
|
||||
list: [{ id: "work", tools: { profile: "messaging" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("read")).toBe(false);
|
||||
});
|
||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||
// Helper to recursively check schema for unsupported keywords
|
||||
const unsupportedKeywords = new Set([
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"$schema",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"examples",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
|
||||
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
|
||||
const found: string[] = [];
|
||||
if (!schema || typeof schema !== "object") return found;
|
||||
if (Array.isArray(schema)) {
|
||||
schema.forEach((item, i) => {
|
||||
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const record = schema as Record<string, unknown>;
|
||||
const properties =
|
||||
record.properties &&
|
||||
typeof record.properties === "object" &&
|
||||
!Array.isArray(record.properties)
|
||||
? (record.properties as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (properties) {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key === "properties") continue;
|
||||
if (unsupportedKeywords.has(key)) {
|
||||
found.push(`${path}.${key}`);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
for (const tool of defaultTools) {
|
||||
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
it("applies sandbox path guards to file_path alias", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
|
||||
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
|
||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool(tmpDir);
|
||||
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
|
||||
/sandbox root/i,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(outsidePath, { force: true });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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,4 +1,10 @@
|
||||
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";
|
||||
@@ -100,6 +106,15 @@ 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;
|
||||
@@ -110,10 +125,13 @@ 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" && text.trim()) {
|
||||
chunks.push(text);
|
||||
if (typeof text === "string") {
|
||||
const sanitized = sanitizeTextContent(text);
|
||||
if (sanitized.trim()) {
|
||||
chunks.push(sanitized);
|
||||
}
|
||||
}
|
||||
}
|
||||
const joined = chunks.join("").trim();
|
||||
return joined ? joined : undefined;
|
||||
return joined ? sanitizeUserFacingText(joined) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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,6 +7,7 @@ import {
|
||||
extractAssistantText,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
sanitizeTextContent,
|
||||
stripToolMessages,
|
||||
} from "../../agents/tools/sessions-helpers.js";
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
@@ -106,11 +107,14 @@ function normalizeMessageText(text: string) {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
export 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(content);
|
||||
const normalized = normalizeMessageText(
|
||||
shouldSanitize ? sanitizeTextContent(content) : content,
|
||||
);
|
||||
return normalized ? { role, text: normalized } : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
@@ -119,8 +123,11 @@ function extractMessageText(message: ChatMessage): { role: string; text: string
|
||||
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" && text.trim()) {
|
||||
chunks.push(text);
|
||||
if (typeof text === "string") {
|
||||
const value = shouldSanitize ? sanitizeTextContent(text) : text;
|
||||
if (value.trim()) {
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const joined = normalizeMessageText(chunks.join(" "));
|
||||
|
||||
@@ -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.",
|
||||
"Tip: enable Message Content Intent if you need message text. (Bot → Privileged Gateway Intents → Message Content Intent)",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
].join("\n"),
|
||||
"Discord bot token",
|
||||
|
||||
@@ -38,6 +38,19 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
loaded = false;
|
||||
}
|
||||
if (loaded) {
|
||||
try {
|
||||
await service.stop({ env: process.env, stdout });
|
||||
} catch {
|
||||
// Best-effort stop; final loaded check gates success.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await service.uninstall({ env: process.env, stdout });
|
||||
} catch (err) {
|
||||
@@ -45,12 +58,16 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
loaded = false;
|
||||
}
|
||||
if (loaded) {
|
||||
fail("Gateway service still loaded after uninstall.");
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "uninstalled",
|
||||
|
||||
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
||||
value: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude Code CLI + API key",
|
||||
choices: ["claude-cli", "token", "apiKey"],
|
||||
choices: ["token", "claude-cli", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "minimax",
|
||||
|
||||
@@ -198,10 +198,20 @@ export async function applyAuthChoiceAnthropic(
|
||||
}
|
||||
|
||||
if (params.authChoice === "apiKey") {
|
||||
if (params.opts?.tokenProvider && params.opts.tokenProvider !== "anthropic") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let nextConfig = params.config;
|
||||
let hasCredential = false;
|
||||
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
if (envKey) {
|
||||
|
||||
if (params.opts?.token) {
|
||||
await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential && envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
||||
initialValue: true,
|
||||
|
||||
@@ -56,7 +56,33 @@ export async function applyAuthChoiceApiProviders(
|
||||
);
|
||||
};
|
||||
|
||||
if (params.authChoice === "openrouter-api-key") {
|
||||
let authChoice = params.authChoice;
|
||||
if (
|
||||
authChoice === "apiKey" &&
|
||||
params.opts?.tokenProvider &&
|
||||
params.opts.tokenProvider !== "anthropic" &&
|
||||
params.opts.tokenProvider !== "openai"
|
||||
) {
|
||||
if (params.opts.tokenProvider === "openrouter") {
|
||||
authChoice = "openrouter-api-key";
|
||||
} else if (params.opts.tokenProvider === "vercel-ai-gateway") {
|
||||
authChoice = "ai-gateway-api-key";
|
||||
} else if (params.opts.tokenProvider === "moonshot") {
|
||||
authChoice = "moonshot-api-key";
|
||||
} else if (params.opts.tokenProvider === "kimi-code") {
|
||||
authChoice = "kimi-code-api-key";
|
||||
} else if (params.opts.tokenProvider === "google") {
|
||||
authChoice = "gemini-api-key";
|
||||
} else if (params.opts.tokenProvider === "zai") {
|
||||
authChoice = "zai-api-key";
|
||||
} else if (params.opts.tokenProvider === "synthetic") {
|
||||
authChoice = "synthetic-api-key";
|
||||
} else if (params.opts.tokenProvider === "opencode") {
|
||||
authChoice = "opencode-zen";
|
||||
}
|
||||
}
|
||||
|
||||
if (authChoice === "openrouter-api-key") {
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
@@ -82,6 +108,11 @@ export async function applyAuthChoiceApiProviders(
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") {
|
||||
await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
const envKey = resolveEnvApiKey("openrouter");
|
||||
if (envKey) {
|
||||
@@ -129,8 +160,18 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "ai-gateway-api-key") {
|
||||
if (authChoice === "ai-gateway-api-key") {
|
||||
let hasCredential = false;
|
||||
|
||||
if (
|
||||
!hasCredential &&
|
||||
params.opts?.token &&
|
||||
params.opts?.tokenProvider === "vercel-ai-gateway"
|
||||
) {
|
||||
await setVercelAiGatewayApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey("vercel-ai-gateway");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -171,8 +212,14 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "moonshot-api-key") {
|
||||
if (authChoice === "moonshot-api-key") {
|
||||
let hasCredential = false;
|
||||
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "moonshot") {
|
||||
await setMoonshotApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey("moonshot");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -212,15 +259,22 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "kimi-code-api-key") {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Kimi Code uses a dedicated endpoint and API key.",
|
||||
"Get your API key at: https://www.kimi.com/code/en",
|
||||
].join("\n"),
|
||||
"Kimi Code",
|
||||
);
|
||||
if (authChoice === "kimi-code-api-key") {
|
||||
let hasCredential = false;
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kimi-code") {
|
||||
await setKimiCodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Kimi Code uses a dedicated endpoint and API key.",
|
||||
"Get your API key at: https://www.kimi.com/code/en",
|
||||
].join("\n"),
|
||||
"Kimi Code",
|
||||
);
|
||||
}
|
||||
const envKey = resolveEnvApiKey("kimi-code");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -261,8 +315,14 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "gemini-api-key") {
|
||||
if (authChoice === "gemini-api-key") {
|
||||
let hasCredential = false;
|
||||
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "google") {
|
||||
await setGeminiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey("google");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -302,8 +362,14 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "zai-api-key") {
|
||||
if (authChoice === "zai-api-key") {
|
||||
let hasCredential = false;
|
||||
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") {
|
||||
await setZaiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey("zai");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -359,12 +425,16 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "synthetic-api-key") {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Synthetic API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
await setSyntheticApiKey(String(key).trim(), params.agentDir);
|
||||
if (authChoice === "synthetic-api-key") {
|
||||
if (params.opts?.token && params.opts?.tokenProvider === "synthetic") {
|
||||
await setSyntheticApiKey(String(params.opts.token).trim(), params.agentDir);
|
||||
} else {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Synthetic API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
await setSyntheticApiKey(String(key).trim(), params.agentDir);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "synthetic:default",
|
||||
provider: "synthetic",
|
||||
@@ -387,16 +457,23 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "opencode-zen") {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
"Requires an active OpenCode Zen subscription.",
|
||||
].join("\n"),
|
||||
"OpenCode Zen",
|
||||
);
|
||||
if (authChoice === "opencode-zen") {
|
||||
let hasCredential = false;
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") {
|
||||
await setOpencodeZenApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
"Requires an active OpenCode Zen subscription.",
|
||||
].join("\n"),
|
||||
"OpenCode Zen",
|
||||
);
|
||||
}
|
||||
const envKey = resolveEnvApiKey("opencode");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot(
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "github-copilot:github",
|
||||
provider: "github-copilot",
|
||||
mode: "oauth",
|
||||
mode: "token",
|
||||
});
|
||||
|
||||
if (params.setDefaultModel) {
|
||||
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
export async function applyAuthChoiceOpenAI(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
if (params.authChoice === "openai-api-key") {
|
||||
let authChoice = params.authChoice;
|
||||
if (authChoice === "apiKey" && params.opts?.tokenProvider === "openai") {
|
||||
authChoice = "openai-api-key";
|
||||
}
|
||||
|
||||
if (authChoice === "openai-api-key") {
|
||||
const envKey = resolveEnvApiKey("openai");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
@@ -43,10 +48,16 @@ export async function applyAuthChoiceOpenAI(
|
||||
}
|
||||
}
|
||||
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter OpenAI API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
let key: string | undefined;
|
||||
if (params.opts?.token && params.opts?.tokenProvider === "openai") {
|
||||
key = params.opts.token;
|
||||
} else {
|
||||
key = await params.prompter.text({
|
||||
message: "Enter OpenAI API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmed = normalizeApiKeyInput(String(key));
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
|
||||
@@ -21,6 +21,10 @@ export type ApplyAuthChoiceParams = {
|
||||
agentDir?: string;
|
||||
setDefaultModel: boolean;
|
||||
agentId?: string;
|
||||
opts?: {
|
||||
tokenProvider?: string;
|
||||
token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApplyAuthChoiceResult = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { openUrl, resolveBrowserOpenCommand, resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
|
||||
@@ -21,9 +21,17 @@ 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.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "");
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
const url =
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&response_type=code&redirect_uri=http%3A%2F%2Flocalhost";
|
||||
@@ -39,15 +47,18 @@ describe("openUrl", () => {
|
||||
timeoutMs: 5_000,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
|
||||
platformSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBrowserOpenCommand", () => {
|
||||
it("marks win32 commands as quoteUrl=true", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const platformSpy = 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,6 +192,7 @@ 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;
|
||||
@@ -218,6 +219,7 @@ 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;
|
||||
@@ -308,6 +310,11 @@ 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,18 +4,43 @@ 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";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
const gatewayClientCalls: Array<{
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
onHelloOk?: () => void;
|
||||
onClose?: (code: number, reason: string) => void;
|
||||
}> = [];
|
||||
|
||||
vi.mock("../gateway/client.js", () => ({
|
||||
GatewayClient: class {
|
||||
params: {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
onHelloOk?: () => void;
|
||||
};
|
||||
constructor(params: {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
onHelloOk?: () => void;
|
||||
}) {
|
||||
this.params = params;
|
||||
gatewayClientCalls.push(params);
|
||||
}
|
||||
async request() {
|
||||
return { ok: true };
|
||||
}
|
||||
start() {
|
||||
queueMicrotask(() => this.params.onHelloOk?.());
|
||||
}
|
||||
stop() {}
|
||||
},
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
@@ -41,85 +66,6 @@ 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) => {
|
||||
@@ -152,7 +98,6 @@ 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;
|
||||
};
|
||||
|
||||
@@ -207,8 +152,9 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
runtime,
|
||||
);
|
||||
|
||||
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
||||
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
|
||||
const { resolveConfigPath } = await import("../config/paths.js");
|
||||
const configPath = resolveConfigPath(process.env, stateDir);
|
||||
const cfg = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
gateway?: { auth?: { mode?: string; token?: string } };
|
||||
agents?: { defaults?: { workspace?: string } };
|
||||
};
|
||||
@@ -217,25 +163,12 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
expect(cfg?.gateway?.auth?.mode).toBe("token");
|
||||
expect(cfg?.gateway?.auth?.token).toBe(token);
|
||||
|
||||
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" });
|
||||
}
|
||||
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);
|
||||
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
@@ -244,43 +177,37 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
const stateDir = await initStateDir("state-remote-");
|
||||
const port = await getFreePort();
|
||||
const token = "tok_remote_123";
|
||||
const { startGatewayServer } = await import("../gateway/server.js");
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
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 } };
|
||||
};
|
||||
|
||||
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 } };
|
||||
};
|
||||
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);
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
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 });
|
||||
}
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
it("auto-enables token auth when binding LAN and persists the token", async () => {
|
||||
@@ -336,27 +263,12 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
const token = cfg.gateway?.auth?.token ?? "";
|
||||
expect(token.length).toBeGreaterThan(8);
|
||||
|
||||
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" });
|
||||
}
|
||||
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);
|
||||
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -36,6 +36,7 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
updateLastRouteMock.mockReset();
|
||||
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { ChannelType, MessageType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDiscordMessageHandler } from "./monitor.js";
|
||||
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
|
||||
import { __resetDiscordThreadStarterCacheForTest } from "./monitor/threading.js";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
const reactMock = vi.fn();
|
||||
@@ -41,12 +44,12 @@ beforeEach(() => {
|
||||
});
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
vi.resetModules();
|
||||
__resetDiscordChannelInfoCacheForTest();
|
||||
__resetDiscordThreadStarterCacheForTest();
|
||||
});
|
||||
|
||||
describe("discord tool result dispatch", () => {
|
||||
it("sends status replies with responsePrefix", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -116,7 +119,6 @@ describe("discord tool result dispatch", () => {
|
||||
}, 30_000);
|
||||
|
||||
it("caches channel info lookups between messages", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -189,7 +191,6 @@ describe("discord tool result dispatch", () => {
|
||||
});
|
||||
|
||||
it("includes forwarded message snapshots in body", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
let capturedBody = "";
|
||||
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||
capturedBody = ctx.Body ?? "";
|
||||
|
||||
@@ -30,6 +30,10 @@ type DiscordThreadParentInfo = {
|
||||
|
||||
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
||||
|
||||
export function __resetDiscordThreadStarterCacheForTest() {
|
||||
DISCORD_THREAD_STARTER_CACHE.clear();
|
||||
}
|
||||
|
||||
function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
||||
return (
|
||||
type === ChannelType.PublicThread ||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, 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();
|
||||
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" });
|
||||
});
|
||||
|
||||
async function startServerWithDefaultConfig(port: number) {
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
@@ -82,8 +94,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
});
|
||||
|
||||
it("handles request validation and routing", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
const port = enabledPort;
|
||||
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
||||
@@ -330,13 +341,12 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
// shared server
|
||||
}
|
||||
});
|
||||
|
||||
it("streams SSE chunks when stream=true", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
const port = enabledPort;
|
||||
try {
|
||||
{
|
||||
agentCommand.mockReset();
|
||||
@@ -416,7 +426,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
expect(fallbackText).toContain("hello");
|
||||
}
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
// shared server
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, 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();
|
||||
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" });
|
||||
});
|
||||
|
||||
async function startServerWithDefaultConfig(port: number) {
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
@@ -72,7 +84,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",
|
||||
@@ -81,7 +93,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
expect(res.status).toBe(404);
|
||||
await ensureResponseConsumed(res);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
// shared server
|
||||
}
|
||||
|
||||
const disabledPort = await getFreePort();
|
||||
@@ -101,8 +113,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
});
|
||||
|
||||
it("handles OpenResponses request parsing and validation", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
const port = enabledPort;
|
||||
const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => {
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockResolvedValueOnce({ payloads, meta } as never);
|
||||
@@ -406,14 +417,12 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
);
|
||||
await ensureResponseConsumed(resNoUser);
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
// shared server
|
||||
}
|
||||
});
|
||||
|
||||
it("streams OpenResponses SSE events", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startServer(port);
|
||||
|
||||
const port = enabledPort;
|
||||
try {
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||
@@ -489,7 +498,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
expect(event.event).toBe(parsed.type);
|
||||
}
|
||||
} finally {
|
||||
await server.close({ reason: "test done" });
|
||||
// shared server
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -173,7 +173,7 @@ export function createAgentEventHandler({
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
};
|
||||
|
||||
const shouldEmitVerboseEvents = (runId: string, sessionKey?: string) => {
|
||||
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
|
||||
const runContext = getAgentRunContext(runId);
|
||||
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
|
||||
if (runVerbose) return runVerbose === "on";
|
||||
@@ -198,10 +198,7 @@ export function createAgentEventHandler({
|
||||
// Include sessionKey so Control UI can filter tool streams per session.
|
||||
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
|
||||
const last = agentRunSeq.get(evt.runId) ?? 0;
|
||||
if (
|
||||
(evt.stream === "tool" || evt.stream === "compaction") &&
|
||||
!shouldEmitVerboseEvents(evt.runId, sessionKey)
|
||||
) {
|
||||
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
|
||||
agentRunSeq.set(evt.runId, evt.seq);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
||||
export const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
||||
|
||||
export const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||
const DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||
let maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
|
||||
|
||||
export const getMaxChatHistoryMessagesBytes = () => maxChatHistoryMessagesBytes;
|
||||
|
||||
export const __setMaxChatHistoryMessagesBytesForTest = (value?: number) => {
|
||||
if (!process.env.VITEST && process.env.NODE_ENV !== "test") return;
|
||||
if (value === undefined) {
|
||||
maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
maxChatHistoryMessagesBytes = value;
|
||||
}
|
||||
};
|
||||
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
export const getHandshakeTimeoutMs = () => {
|
||||
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
validateChatInjectParams,
|
||||
validateChatSendParams,
|
||||
} from "../protocol/index.js";
|
||||
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js";
|
||||
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
loadSessionEntry,
|
||||
@@ -66,7 +66,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
const max = Math.min(hardMax, requested);
|
||||
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||
const sanitized = stripEnvelopeFromMessages(sliced);
|
||||
const capped = capArrayByJsonBytes(sanitized, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
|
||||
const capped = capArrayByJsonBytes(sanitized, getMaxChatHistoryMessagesBytes()).items;
|
||||
let thinkingLevel = entry?.thinkingLevel;
|
||||
if (!thinkingLevel) {
|
||||
const configured = cfg.agents?.defaults?.thinkingDefault;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterAll, beforeAll, 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,
|
||||
@@ -14,7 +15,22 @@ import {
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
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();
|
||||
});
|
||||
|
||||
const registryState = vi.hoisted(() => ({
|
||||
registry: {
|
||||
@@ -43,6 +59,11 @@ vi.mock("./server-plugins.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const setRegistry = (registry: PluginRegistry) => {
|
||||
registryState.registry = registry;
|
||||
setActivePluginRegistry(registry);
|
||||
};
|
||||
|
||||
const BASE_IMAGE_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
|
||||
|
||||
@@ -142,7 +163,7 @@ const defaultRegistry = createRegistry([
|
||||
|
||||
describe("gateway server agent", () => {
|
||||
test("agent marks implicit delivery when lastTo is stale", async () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
testState.allowFrom = ["+436769770569"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
@@ -156,10 +177,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -175,14 +192,11 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -193,10 +207,6 @@ 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",
|
||||
@@ -211,13 +221,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
testState.agentsConfig = { list: [{ id: "ops" }] };
|
||||
@@ -230,10 +237,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
agentId: "ops",
|
||||
@@ -245,16 +248,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
setRegistry(defaultRegistry);
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
replyChannel: "unknown-channel",
|
||||
@@ -265,18 +262,11 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
testState.agentsConfig = { list: [{ id: "ops" }] };
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
agentId: "ops",
|
||||
@@ -288,13 +278,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
@@ -309,10 +296,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -329,14 +312,11 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
@@ -351,10 +331,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -369,14 +345,11 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
@@ -391,10 +364,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -410,14 +379,11 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
@@ -432,10 +398,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -449,14 +411,11 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -467,10 +426,6 @@ 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",
|
||||
@@ -497,13 +452,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
@@ -515,10 +467,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -533,14 +481,11 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -553,10 +498,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -574,13 +515,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -593,10 +531,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -613,13 +547,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -632,10 +563,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -652,13 +579,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -671,10 +595,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -691,13 +611,10 @@ 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 () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
@@ -710,10 +627,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -730,8 +643,5 @@ 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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, 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,7 +22,24 @@ import {
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
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();
|
||||
});
|
||||
|
||||
const registryState = vi.hoisted(() => ({
|
||||
registry: {
|
||||
@@ -130,10 +147,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -150,9 +163,6 @@ 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 () => {
|
||||
@@ -177,10 +187,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const resIMessage = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -208,15 +214,9 @@ 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,9 +225,6 @@ 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 () => {
|
||||
@@ -244,10 +241,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -264,9 +257,6 @@ 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 () => {
|
||||
@@ -282,10 +272,6 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
@@ -302,15 +288,9 @@ 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",
|
||||
@@ -333,15 +313,9 @@ 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",
|
||||
@@ -367,9 +341,6 @@ 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 () => {
|
||||
@@ -433,8 +404,9 @@ describe("gateway server agent", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
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",
|
||||
@@ -446,7 +418,7 @@ describe("gateway server agent", () => {
|
||||
registerAgentRunContext("run-auto-1", { sessionKey: "main" });
|
||||
|
||||
const finalChatP = onceMessage(
|
||||
ws,
|
||||
webchatWs,
|
||||
(o) => {
|
||||
if (o.type !== "event" || o.event !== "chat") return false;
|
||||
const payload = o.payload as { state?: unknown; runId?: unknown } | undefined;
|
||||
@@ -474,7 +446,6 @@ describe("gateway server agent", () => {
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
expect(payload.runId).toBe("run-auto-1");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
webchatWs.close();
|
||||
});
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
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" });
|
||||
|
||||
const compactionEvtP = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "agent" &&
|
||||
o.payload?.runId === "run-tool-off" &&
|
||||
o.payload?.stream === "compaction",
|
||||
1000,
|
||||
);
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-tool-off",
|
||||
stream: "compaction",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await expect(compactionEvtP).rejects.toThrow("timeout");
|
||||
}
|
||||
|
||||
{
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
+210
-189
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "./test-helpers.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean> {
|
||||
if (ws.readyState === WebSocket.CLOSED) return true;
|
||||
@@ -26,129 +26,226 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
|
||||
});
|
||||
}
|
||||
|
||||
const openWs = async (port: number) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
return ws;
|
||||
};
|
||||
|
||||
describe("gateway server auth/connect", () => {
|
||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||
vi.useRealTimers();
|
||||
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
|
||||
try {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
|
||||
expect(closed).toBe(true);
|
||||
describe("default auth", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
} finally {
|
||||
if (prevHandshakeTimeout === undefined) {
|
||||
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||
const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js");
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws);
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as
|
||||
| {
|
||||
type?: unknown;
|
||||
snapshot?: { configPath?: string; stateDir?: string };
|
||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||
vi.useRealTimers();
|
||||
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
|
||||
try {
|
||||
const ws = await openWs(port);
|
||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
|
||||
expect(closed).toBe(true);
|
||||
} finally {
|
||||
if (prevHandshakeTimeout === undefined) {
|
||||
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.type).toBe("hello-ok");
|
||||
expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDBOT);
|
||||
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDBOT);
|
||||
}
|
||||
});
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||
const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js");
|
||||
const ws = await openWs(port);
|
||||
|
||||
test("sends connect challenge on open", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const evtPromise = onceMessage<{ payload?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "connect.challenge",
|
||||
const res = await connectReq(ws);
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as
|
||||
| {
|
||||
type?: unknown;
|
||||
snapshot?: { configPath?: string; stateDir?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.type).toBe("hello-ok");
|
||||
expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDBOT);
|
||||
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDBOT);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sends connect challenge on open", async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const evtPromise = onceMessage<{ payload?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "connect.challenge",
|
||||
);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
const evt = await evtPromise;
|
||||
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
|
||||
expect(typeof nonce).toBe("string");
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rejects protocol mismatch", async () => {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
minProtocol: PROTOCOL_VERSION + 1,
|
||||
maxProtocol: PROTOCOL_VERSION + 2,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
} catch {
|
||||
// If the server closed before we saw the frame, that's acceptable.
|
||||
}
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rejects non-connect first request", async () => {
|
||||
const ws = await openWs(port);
|
||||
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
|
||||
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "h1",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||
});
|
||||
|
||||
test(
|
||||
"invalid connect params surface in response and close reason",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
const ws = await openWs(port);
|
||||
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "h-bad",
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "bad-client",
|
||||
version: "dev",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
},
|
||||
device: {
|
||||
id: 123,
|
||||
publicKey: "bad",
|
||||
signature: "bad",
|
||||
signedAt: "bad",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await onceMessage<{
|
||||
ok: boolean;
|
||||
error?: { message?: string };
|
||||
}>(
|
||||
ws,
|
||||
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(String(res.error?.message ?? "")).toContain("invalid connect params");
|
||||
|
||||
const closeInfo = await closeInfoPromise;
|
||||
expect(closeInfo.code).toBe(1008);
|
||||
expect(closeInfo.reason).toContain("invalid connect params");
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
const evt = await evtPromise;
|
||||
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
|
||||
expect(typeof nonce).toBe("string");
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects protocol mismatch", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
try {
|
||||
describe("password auth", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("accepts password auth when configured", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, { password: "secret" });
|
||||
expect(res.ok).toBe(true);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rejects invalid password", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, { password: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("token auth", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port: number;
|
||||
let prevToken: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, { token: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rejects control ui without device identity by default", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, {
|
||||
minProtocol: PROTOCOL_VERSION + 1,
|
||||
maxProtocol: PROTOCOL_VERSION + 2,
|
||||
token: "secret",
|
||||
device: null,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
} catch {
|
||||
// If the server closed before we saw the frame, that's acceptable.
|
||||
}
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
const res = await connectReq(ws, { token: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts password auth when configured", async () => {
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { password: "secret" });
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects control ui without device identity by default", async () => {
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
device: null,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
expect(res.error?.message ?? "").toContain("secure context");
|
||||
ws.close();
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("secure context");
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("allows control ui without device identity when insecure auth is enabled", async () => {
|
||||
@@ -327,81 +424,5 @@ describe("gateway server auth/connect", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid password", async () => {
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { password: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects non-connect first request", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
|
||||
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "h1",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test(
|
||||
"invalid connect params surface in response and close reason",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "h-bad",
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "bad-client",
|
||||
version: "dev",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
},
|
||||
device: {
|
||||
id: 123,
|
||||
publicKey: "bad",
|
||||
signature: "bad",
|
||||
signedAt: "bad",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await onceMessage<{
|
||||
ok: boolean;
|
||||
error?: { message?: string };
|
||||
}>(
|
||||
ws,
|
||||
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(String(res.error?.message ?? "")).toContain("invalid connect params");
|
||||
|
||||
const closeInfo = await closeInfoPromise;
|
||||
expect(closeInfo.code).toBe(1008);
|
||||
expect(closeInfo.reason).toContain("invalid connect params");
|
||||
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
// Remaining tests require isolated gateway state.
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterAll, beforeAll, 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,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
|
||||
const loadConfigHelpers = async () => await import("../config/config.js");
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const registryState = vi.hoisted(() => ({
|
||||
registry: {
|
||||
@@ -131,30 +132,31 @@ const defaultRegistry = createRegistry([
|
||||
},
|
||||
]);
|
||||
|
||||
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
|
||||
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
|
||||
let ws: Awaited<ReturnType<typeof startServerWithClient>>["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));
|
||||
beforeAll(async () => {
|
||||
setRegistry(defaultRegistry);
|
||||
const started = await startServerWithClient();
|
||||
server = started.server;
|
||||
ws = started.ws;
|
||||
await connectOk(ws);
|
||||
});
|
||||
|
||||
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);
|
||||
registryState.registry = defaultRegistry;
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
setRegistry(defaultRegistry);
|
||||
const res = await rpcReq<{
|
||||
channels?: Record<
|
||||
string,
|
||||
@@ -181,12 +183,7 @@ describe("gateway server channels", () => {
|
||||
});
|
||||
|
||||
test("channels.logout reports no session when missing", async () => {
|
||||
registryState.registry = defaultRegistry;
|
||||
const result = await startServerWithClient();
|
||||
servers.push(result);
|
||||
const { ws } = result;
|
||||
await connectOk(ws);
|
||||
|
||||
setRegistry(defaultRegistry);
|
||||
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", {
|
||||
channel: "whatsapp",
|
||||
});
|
||||
@@ -197,7 +194,7 @@ describe("gateway server channels", () => {
|
||||
|
||||
test("channels.logout clears telegram bot token from config", async () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||
registryState.registry = defaultRegistry;
|
||||
setRegistry(defaultRegistry);
|
||||
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
|
||||
await writeConfigFile({
|
||||
channels: {
|
||||
@@ -207,12 +204,6 @@ 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,8 @@ import {
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
installGatewayTestHooks();
|
||||
import { __setMaxChatHistoryMessagesBytesForTest } from "./server-constants.js";
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -38,355 +39,90 @@ 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", () => {
|
||||
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",
|
||||
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 {
|
||||
const historyMaxBytes = 192 * 1024;
|
||||
__setMaxChatHistoryMessagesBytesForTest(historyMaxBytes);
|
||||
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(4_000);
|
||||
const largeLines: string[] = [];
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
largeLines.push(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `${i}:${bigText}` }],
|
||||
timestamp: Date.now() + i,
|
||||
},
|
||||
}),
|
||||
);
|
||||
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,
|
||||
{
|
||||
}
|
||||
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(historyMaxBytes);
|
||||
expect(cappedMsgs.length).toBeLessThan(60);
|
||||
|
||||
await writeStore({
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
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", {
|
||||
});
|
||||
const routeRes = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-status-1",
|
||||
idempotencyKey: "idem-route",
|
||||
});
|
||||
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;
|
||||
});
|
||||
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;
|
||||
spy.mockImplementationOnce(async (opts) => {
|
||||
agentStartedResolve?.();
|
||||
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
@@ -396,127 +132,357 @@ describe("gateway server chat", () => {
|
||||
});
|
||||
const sendResP = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "send-mismatch-1",
|
||||
10_000,
|
||||
(o) => o.type === "res" && o.id === "send-abort-1",
|
||||
8000,
|
||||
);
|
||||
sendReq(ws, "send-mismatch-1", "chat.send", {
|
||||
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-mismatch-1",
|
||||
idempotencyKey: "idem-abort-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 withSessionStore(
|
||||
tempDirs,
|
||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
||||
async () => {
|
||||
resetSpy();
|
||||
spy.mockResolvedValueOnce(undefined);
|
||||
sendReq(ws, "send-complete-1", "chat.send", {
|
||||
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", {
|
||||
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 < 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));
|
||||
if (again.ok && again.payload?.status === "ok") {
|
||||
completedRun = true;
|
||||
break;
|
||||
}
|
||||
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 })));
|
||||
}
|
||||
});
|
||||
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 {
|
||||
__setMaxChatHistoryMessagesBytesForTest();
|
||||
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,9 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import {
|
||||
agentCommand,
|
||||
connectOk,
|
||||
@@ -15,7 +16,24 @@ import {
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
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();
|
||||
});
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -29,12 +47,9 @@ 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, {
|
||||
@@ -240,9 +255,182 @@ 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,4 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 { resolveConfigSnapshotHash } from "../config/config.js";
|
||||
|
||||
@@ -6,16 +9,31 @@ import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
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();
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -100,15 +118,9 @@ 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({
|
||||
@@ -145,15 +157,9 @@ 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({
|
||||
@@ -192,8 +198,108 @@ describe("gateway config.patch", () => {
|
||||
);
|
||||
expect(set2Res.ok).toBe(false);
|
||||
expect(set2Res.error?.message).toContain("base hash");
|
||||
});
|
||||
});
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
@@ -36,32 +35,23 @@ async function rmTempDir(dir: string) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function waitForCronFinished(ws: { send: (data: string) => void }, jobId: string) {
|
||||
await onceMessage(
|
||||
ws as never,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "cron" &&
|
||||
o.payload?.action === "finished" &&
|
||||
o.payload?.jobId === jobId,
|
||||
10_000,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const startedAt = process.hrtime.bigint();
|
||||
for (;;) {
|
||||
const raw = await fs.readFile(pathname, "utf-8").catch(() => "");
|
||||
if (raw.trim().length > 0) return raw;
|
||||
if (Date.now() >= deadline) {
|
||||
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||
if (elapsedMs >= timeoutMs) {
|
||||
throw new Error(`timeout waiting for file ${pathname}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway server cron", () => {
|
||||
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
|
||||
const prevSkipCron = process.env.CLAWDBOT_SKIP_CRON;
|
||||
process.env.CLAWDBOT_SKIP_CRON = "0";
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||
testState.sessionConfig = { mainKey: "primary" };
|
||||
@@ -265,10 +255,17 @@ describe("gateway server cron", () => {
|
||||
testState.cronStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
testState.cronEnabled = undefined;
|
||||
if (prevSkipCron === undefined) {
|
||||
delete process.env.CLAWDBOT_SKIP_CRON;
|
||||
} else {
|
||||
process.env.CLAWDBOT_SKIP_CRON = prevSkipCron;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("writes cron run history and auto-runs due jobs", async () => {
|
||||
const prevSkipCron = process.env.CLAWDBOT_SKIP_CRON;
|
||||
process.env.CLAWDBOT_SKIP_CRON = "0";
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
|
||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||
testState.cronEnabled = undefined;
|
||||
@@ -293,13 +290,10 @@ describe("gateway server cron", () => {
|
||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||
expect(jobId.length > 0).toBe(true);
|
||||
|
||||
const finishedP = waitForCronFinished(ws, jobId);
|
||||
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
|
||||
expect(runRes.ok).toBe(true);
|
||||
await finishedP;
|
||||
|
||||
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
|
||||
const raw = await waitForNonEmptyFile(logPath);
|
||||
const raw = await waitForNonEmptyFile(logPath, 5000);
|
||||
const line = raw
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
@@ -345,16 +339,7 @@ describe("gateway server cron", () => {
|
||||
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
|
||||
expect(autoJobId.length > 0).toBe(true);
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const autoFinishedP = waitForCronFinished(ws, autoJobId);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await autoFinishedP;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`));
|
||||
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`), 5000);
|
||||
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
|
||||
| { entries?: Array<{ jobId?: unknown }> }
|
||||
| undefined;
|
||||
@@ -367,6 +352,11 @@ describe("gateway server cron", () => {
|
||||
await rmTempDir(dir);
|
||||
testState.cronStorePath = undefined;
|
||||
testState.cronEnabled = undefined;
|
||||
if (prevSkipCron === undefined) {
|
||||
delete process.env.CLAWDBOT_SKIP_CRON;
|
||||
} else {
|
||||
process.env.CLAWDBOT_SKIP_CRON = prevSkipCron;
|
||||
}
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
+128
-184
@@ -10,206 +10,150 @@ import {
|
||||
waitForSystemEvent,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const resolveMainKey = () => resolveMainSessionKeyFromConfig();
|
||||
|
||||
describe("gateway server hooks", () => {
|
||||
test("hooks wake requires auth", async () => {
|
||||
test("handles auth, wake, and agent flows", 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" },
|
||||
body: JSON.stringify({ text: "Ping" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
await server.close();
|
||||
});
|
||||
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);
|
||||
|
||||
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();
|
||||
});
|
||||
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 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 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 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();
|
||||
});
|
||||
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 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 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 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 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 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 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 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 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 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 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 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 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 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();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
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())),
|
||||
);
|
||||
});
|
||||
});
|
||||
+194
-26
@@ -1,19 +1,93 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { 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();
|
||||
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([]);
|
||||
|
||||
describe("gateway server models + voicewake", () => {
|
||||
const setTempHome = (homeDir: string) => {
|
||||
@@ -68,9 +142,6 @@ 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"]);
|
||||
@@ -104,9 +175,6 @@ describe("gateway server models + voicewake", () => {
|
||||
expect(onDisk.triggers).toEqual(["hi", "there"]);
|
||||
expect(typeof onDisk.updatedAtMs).toBe("number");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
restoreHome();
|
||||
},
|
||||
);
|
||||
@@ -115,9 +183,6 @@ 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 }>(
|
||||
@@ -159,9 +224,6 @@ describe("gateway server models + voicewake", () => {
|
||||
]);
|
||||
|
||||
nodeWs.close();
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
restoreHome();
|
||||
});
|
||||
|
||||
@@ -189,9 +251,6 @@ describe("gateway server models + voicewake", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res1 = await rpcReq<{
|
||||
models: Array<{
|
||||
id: string;
|
||||
@@ -241,23 +300,132 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
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())),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js";
|
||||
import {
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const cronInstances: Array<{
|
||||
@@ -158,7 +165,7 @@ vi.mock("./config-reload.js", () => ({
|
||||
startGatewayConfigReloader: hoisted.startGatewayConfigReloader,
|
||||
}));
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
describe("gateway hot reload", () => {
|
||||
let prevSkipChannels: string | undefined;
|
||||
@@ -184,7 +191,7 @@ describe("gateway hot reload", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("applies hot reload actions for providers + services", async () => {
|
||||
it("applies hot reload actions and emits restart signal", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
|
||||
@@ -263,13 +270,6 @@ describe("gateway hot reload", () => {
|
||||
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("imessage");
|
||||
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("imessage");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("emits SIGUSR1 on restart plan when listener exists", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
|
||||
const onRestart = hoisted.getOnRestart();
|
||||
expect(onRestart).toBeTypeOf("function");
|
||||
|
||||
@@ -298,3 +298,15 @@ 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();
|
||||
});
|
||||
});
|
||||
+137
-13
@@ -1,15 +1,48 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
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 { 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();
|
||||
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 sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -65,11 +98,99 @@ 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(
|
||||
@@ -96,8 +217,12 @@ describe("gateway node command allowlist", () => {
|
||||
return nodeId;
|
||||
};
|
||||
|
||||
let systemClient: GatewayClient | undefined;
|
||||
let emptyClient: GatewayClient | undefined;
|
||||
let allowedClient: GatewayClient | undefined;
|
||||
|
||||
try {
|
||||
const systemClient = await connectNodeClient({
|
||||
systemClient = await connectNodeClient({
|
||||
port,
|
||||
commands: ["system.run"],
|
||||
instanceId: "node-system-run",
|
||||
@@ -115,7 +240,7 @@ describe("gateway node command allowlist", () => {
|
||||
systemClient.stop();
|
||||
await waitForConnectedCount(0);
|
||||
|
||||
const emptyClient = await connectNodeClient({
|
||||
emptyClient = await connectNodeClient({
|
||||
port,
|
||||
commands: [],
|
||||
instanceId: "node-empty",
|
||||
@@ -138,7 +263,7 @@ describe("gateway node command allowlist", () => {
|
||||
new Promise<{ id?: string; nodeId?: string }>((resolve) => {
|
||||
resolveInvoke = resolve;
|
||||
});
|
||||
const allowedClient = await connectNodeClient({
|
||||
allowedClient = await connectNodeClient({
|
||||
port,
|
||||
commands: ["canvas.snapshot"],
|
||||
instanceId: "node-allowed",
|
||||
@@ -187,11 +312,10 @@ describe("gateway node command allowlist", () => {
|
||||
});
|
||||
const invokeNullRes = await invokeNullResP;
|
||||
expect(invokeNullRes.ok).toBe(true);
|
||||
|
||||
allowedClient.stop();
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
systemClient?.stop();
|
||||
emptyClient?.stop();
|
||||
allowedClient?.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, 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,30 +11,38 @@ import {
|
||||
startGatewayServer,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const servers: Array<Awaited<ReturnType<typeof startGatewayServer>>> = [];
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let gatewayPort: number;
|
||||
let prevGatewayPort: string | undefined;
|
||||
let prevGatewayToken: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
@@ -78,8 +86,6 @@ 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");
|
||||
|
||||
@@ -104,12 +110,6 @@ 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,13 +171,6 @@ 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");
|
||||
|
||||
@@ -192,13 +185,6 @@ 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");
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -554,3 +554,4 @@ vi.mock("../cli/deps.js", async () => {
|
||||
});
|
||||
|
||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||
@@ -0,0 +1,578 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js";
|
||||
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
describe("fetchAntigravityUsage", () => {
|
||||
it("returns 3 windows when both endpoints succeed", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 750,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
planType: "Standard",
|
||||
currentTier: { id: "tier1", name: "Standard Tier" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-pro-1.5": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0.6,
|
||||
resetTime: "2026-01-08T00:00:00Z",
|
||||
isExhausted: false,
|
||||
},
|
||||
},
|
||||
"gemini-flash-2.0": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0.8,
|
||||
resetTime: "2026-01-08T00:00:00Z",
|
||||
isExhausted: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.provider).toBe("google-antigravity");
|
||||
expect(snapshot.displayName).toBe("Antigravity");
|
||||
expect(snapshot.windows).toHaveLength(3);
|
||||
expect(snapshot.plan).toBe("Standard Tier");
|
||||
expect(snapshot.error).toBeUndefined();
|
||||
|
||||
const creditsWindow = snapshot.windows.find((w) => w.label === "Credits");
|
||||
expect(creditsWindow?.usedPercent).toBe(25); // (1000 - 750) / 1000 * 100
|
||||
|
||||
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-1.5");
|
||||
expect(proWindow?.usedPercent).toBe(40); // (1 - 0.6) * 100
|
||||
expect(proWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime());
|
||||
|
||||
const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-2.0");
|
||||
expect(flashWindow?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100
|
||||
expect(flashWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime());
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns Credits only when loadCodeAssist succeeds but fetchAvailableModels fails", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 250,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
currentTier: { name: "Free" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(403, { error: { message: "Permission denied" } });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.provider).toBe("google-antigravity");
|
||||
expect(snapshot.windows).toHaveLength(1);
|
||||
expect(snapshot.plan).toBe("Free");
|
||||
expect(snapshot.error).toBeUndefined();
|
||||
|
||||
const creditsWindow = snapshot.windows[0];
|
||||
expect(creditsWindow?.label).toBe("Credits");
|
||||
expect(creditsWindow?.usedPercent).toBe(75); // (1000 - 250) / 1000 * 100
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns model IDs when fetchAvailableModels succeeds but loadCodeAssist fails", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Internal server error");
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-pro-1.5": {
|
||||
quotaInfo: { remainingFraction: 0.5, resetTime: "2026-01-08T00:00:00Z" },
|
||||
},
|
||||
"gemini-flash-2.0": {
|
||||
quotaInfo: { remainingFraction: 0.7, resetTime: "2026-01-08T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.provider).toBe("google-antigravity");
|
||||
expect(snapshot.windows).toHaveLength(2);
|
||||
expect(snapshot.error).toBeUndefined();
|
||||
|
||||
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-1.5");
|
||||
expect(proWindow?.usedPercent).toBe(50); // (1 - 0.5) * 100
|
||||
|
||||
const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-2.0");
|
||||
expect(flashWindow?.usedPercent).toBeCloseTo(30, 1); // (1 - 0.7) * 100
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses cloudaicompanionProject string as project id", async () => {
|
||||
let capturedBody: string | undefined;
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
|
||||
async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 900,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
cloudaicompanionProject: "projects/alpha",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
capturedBody = init?.body?.toString();
|
||||
return makeResponse(200, { models: {} });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
},
|
||||
);
|
||||
|
||||
await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(capturedBody).toBe(JSON.stringify({ project: "projects/alpha" }));
|
||||
});
|
||||
|
||||
it("uses cloudaicompanionProject object id when present", async () => {
|
||||
let capturedBody: string | undefined;
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
|
||||
async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 900,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
cloudaicompanionProject: { id: "projects/beta" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
capturedBody = init?.body?.toString();
|
||||
return makeResponse(200, { models: {} });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
},
|
||||
);
|
||||
|
||||
await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(capturedBody).toBe(JSON.stringify({ project: "projects/beta" }));
|
||||
});
|
||||
|
||||
it("returns error snapshot when both endpoints fail", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(403, { error: { message: "Access denied" } });
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(403, "Forbidden");
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.provider).toBe("google-antigravity");
|
||||
expect(snapshot.windows).toHaveLength(0);
|
||||
expect(snapshot.error).toBe("Access denied");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns Token expired when fetchAvailableModels returns 401 and no windows", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Boom");
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(401, { error: { message: "Unauthorized" } });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.error).toBe("Token expired");
|
||||
expect(snapshot.windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("extracts plan info from currentTier.name", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 500,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
planType: "Basic",
|
||||
currentTier: { id: "tier2", name: "Premium Tier" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.plan).toBe("Premium Tier");
|
||||
});
|
||||
|
||||
it("falls back to planType when currentTier.name is missing", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 500,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
planType: "Basic Plan",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.plan).toBe("Basic Plan");
|
||||
});
|
||||
|
||||
it("includes reset times in model windows", async () => {
|
||||
const resetTime = "2026-01-10T12:00:00Z";
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-pro-experimental": {
|
||||
quotaInfo: { remainingFraction: 0.3, resetTime },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-experimental");
|
||||
expect(proWindow?.resetAt).toBe(new Date(resetTime).getTime());
|
||||
});
|
||||
|
||||
it("parses string numbers correctly", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: "600",
|
||||
planInfo: { monthlyPromptCredits: "1000" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-flash-lite": {
|
||||
quotaInfo: { remainingFraction: "0.9" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.windows).toHaveLength(2);
|
||||
|
||||
const creditsWindow = snapshot.windows.find((w) => w.label === "Credits");
|
||||
expect(creditsWindow?.usedPercent).toBe(40); // (1000 - 600) / 1000 * 100
|
||||
|
||||
const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-lite");
|
||||
expect(flashWindow?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100
|
||||
});
|
||||
|
||||
it("skips internal models", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 500,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
cloudaicompanionProject: "projects/internal",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
chat_hidden: { quotaInfo: { remainingFraction: 0.1 } },
|
||||
tab_hidden: { quotaInfo: { remainingFraction: 0.2 } },
|
||||
"gemini-pro-1.5": { quotaInfo: { remainingFraction: 0.7 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.windows.map((w) => w.label)).toEqual(["Credits", "gemini-pro-1.5"]);
|
||||
});
|
||||
|
||||
it("sorts models by usage and shows individual model IDs", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-pro-1.0": {
|
||||
quotaInfo: { remainingFraction: 0.8 },
|
||||
},
|
||||
"gemini-pro-1.5": {
|
||||
quotaInfo: { remainingFraction: 0.3 },
|
||||
},
|
||||
"gemini-flash-1.5": {
|
||||
quotaInfo: { remainingFraction: 0.6 },
|
||||
},
|
||||
"gemini-flash-2.0": {
|
||||
quotaInfo: { remainingFraction: 0.9 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.windows).toHaveLength(4);
|
||||
// Should be sorted by usage (highest first)
|
||||
expect(snapshot.windows[0]?.label).toBe("gemini-pro-1.5");
|
||||
expect(snapshot.windows[0]?.usedPercent).toBe(70); // (1 - 0.3) * 100
|
||||
expect(snapshot.windows[1]?.label).toBe("gemini-flash-1.5");
|
||||
expect(snapshot.windows[1]?.usedPercent).toBe(40); // (1 - 0.6) * 100
|
||||
expect(snapshot.windows[2]?.label).toBe("gemini-pro-1.0");
|
||||
expect(snapshot.windows[2]?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100
|
||||
expect(snapshot.windows[3]?.label).toBe("gemini-flash-2.0");
|
||||
expect(snapshot.windows[3]?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100
|
||||
});
|
||||
|
||||
it("returns Token expired error on 401 from loadCodeAssist", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(401, { error: { message: "Unauthorized" } });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.error).toBe("Token expired");
|
||||
expect(snapshot.windows).toHaveLength(0);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // Should stop early on 401
|
||||
});
|
||||
|
||||
it("handles empty models array gracefully", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 800,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, { models: {} });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.windows).toHaveLength(1);
|
||||
const creditsWindow = snapshot.windows[0];
|
||||
expect(creditsWindow?.label).toBe("Credits");
|
||||
expect(creditsWindow?.usedPercent).toBe(20);
|
||||
});
|
||||
|
||||
it("handles missing credits fields gracefully", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, { planType: "Free" });
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-flash-experimental": {
|
||||
quotaInfo: { remainingFraction: 0.5 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.windows).toHaveLength(1);
|
||||
const flashWindow = snapshot.windows[0];
|
||||
expect(flashWindow?.label).toBe("gemini-flash-experimental");
|
||||
expect(flashWindow?.usedPercent).toBe(50);
|
||||
expect(snapshot.plan).toBe("Free");
|
||||
});
|
||||
|
||||
it("handles invalid reset time gracefully", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-pro-test": {
|
||||
quotaInfo: { remainingFraction: 0.4, resetTime: "invalid-date" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-test");
|
||||
expect(proWindow?.usedPercent).toBe(60);
|
||||
expect(proWindow?.resetAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles network errors with graceful degradation", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
throw new Error("Network failure");
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return makeResponse(200, {
|
||||
models: {
|
||||
"gemini-flash-stable": {
|
||||
quotaInfo: { remainingFraction: 0.85 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
expect(snapshot.windows).toHaveLength(1);
|
||||
const flashWindow = snapshot.windows[0];
|
||||
expect(flashWindow?.label).toBe("gemini-flash-stable");
|
||||
expect(flashWindow?.usedPercent).toBeCloseTo(15, 1);
|
||||
expect(snapshot.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { logDebug } from "../logger.js";
|
||||
import { fetchJson } from "./provider-usage.fetch.shared.js";
|
||||
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
|
||||
|
||||
type LoadCodeAssistResponse = {
|
||||
availablePromptCredits?: number | string;
|
||||
planInfo?: { monthlyPromptCredits?: number | string };
|
||||
planType?: string;
|
||||
currentTier?: { id?: string; name?: string };
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
|
||||
type FetchAvailableModelsResponse = {
|
||||
models?: Record<
|
||||
string,
|
||||
{
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number | string;
|
||||
resetTime?: string;
|
||||
isExhausted?: boolean;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
type ModelQuota = {
|
||||
remainingFraction: number;
|
||||
resetTime?: number;
|
||||
};
|
||||
|
||||
type CreditsInfo = {
|
||||
available: number;
|
||||
monthly: number;
|
||||
};
|
||||
|
||||
const BASE_URL = "https://cloudcode-pa.googleapis.com";
|
||||
const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist";
|
||||
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
|
||||
|
||||
const METADATA = {
|
||||
ideType: "ANTIGRAVITY",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
};
|
||||
|
||||
function parseNumber(value: number | string | undefined): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseEpochMs(isoString: string | undefined): number | undefined {
|
||||
if (!isoString?.trim()) return undefined;
|
||||
try {
|
||||
const ms = Date.parse(isoString);
|
||||
if (Number.isFinite(ms)) return ms;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function parseErrorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await res.json()) as { error?: { message?: string } };
|
||||
const message = data?.error?.message?.trim();
|
||||
if (message) return message;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
|
||||
function extractCredits(data: LoadCodeAssistResponse): CreditsInfo | undefined {
|
||||
const available = parseNumber(data.availablePromptCredits);
|
||||
const monthly = parseNumber(data.planInfo?.monthlyPromptCredits);
|
||||
if (available === undefined || monthly === undefined || monthly <= 0) return undefined;
|
||||
return { available, monthly };
|
||||
}
|
||||
|
||||
function extractPlanInfo(data: LoadCodeAssistResponse): string | undefined {
|
||||
const tierName = data.currentTier?.name?.trim();
|
||||
if (tierName) return tierName;
|
||||
const planType = data.planType?.trim();
|
||||
if (planType) return planType;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractProjectId(data: LoadCodeAssistResponse): string | undefined {
|
||||
const project = data.cloudaicompanionProject;
|
||||
if (!project) return undefined;
|
||||
if (typeof project === "string") return project.trim() ? project : undefined;
|
||||
const projectId = typeof project.id === "string" ? project.id.trim() : undefined;
|
||||
return projectId || undefined;
|
||||
}
|
||||
|
||||
function extractModelQuotas(data: FetchAvailableModelsResponse): Map<string, ModelQuota> {
|
||||
const result = new Map<string, ModelQuota>();
|
||||
if (!data.models || typeof data.models !== "object") return result;
|
||||
|
||||
for (const [modelId, modelInfo] of Object.entries(data.models)) {
|
||||
const quotaInfo = modelInfo.quotaInfo;
|
||||
if (!quotaInfo) continue;
|
||||
|
||||
const remainingFraction = parseNumber(quotaInfo.remainingFraction);
|
||||
if (remainingFraction === undefined) continue;
|
||||
|
||||
const resetTime = parseEpochMs(quotaInfo.resetTime);
|
||||
result.set(modelId, { remainingFraction, resetTime });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildUsageWindows(opts: {
|
||||
credits?: CreditsInfo;
|
||||
modelQuotas?: Map<string, ModelQuota>;
|
||||
}): UsageWindow[] {
|
||||
const windows: UsageWindow[] = [];
|
||||
|
||||
// Credits window (overall)
|
||||
if (opts.credits) {
|
||||
const { available, monthly } = opts.credits;
|
||||
const used = monthly - available;
|
||||
const usedPercent = clampPercent((used / monthly) * 100);
|
||||
windows.push({ label: "Credits", usedPercent });
|
||||
}
|
||||
|
||||
// Individual model windows
|
||||
if (opts.modelQuotas && opts.modelQuotas.size > 0) {
|
||||
const modelWindows: UsageWindow[] = [];
|
||||
|
||||
for (const [modelId, quota] of opts.modelQuotas) {
|
||||
const lowerModelId = modelId.toLowerCase();
|
||||
|
||||
// Skip internal models
|
||||
if (lowerModelId.includes("chat_") || lowerModelId.includes("tab_")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const usedPercent = clampPercent((1 - quota.remainingFraction) * 100);
|
||||
const window: UsageWindow = { label: modelId, usedPercent };
|
||||
if (quota.resetTime) window.resetAt = quota.resetTime;
|
||||
modelWindows.push(window);
|
||||
}
|
||||
|
||||
// Sort by usage (highest first) and take top 10
|
||||
modelWindows.sort((a, b) => b.usedPercent - a.usedPercent);
|
||||
const topModels = modelWindows.slice(0, 10);
|
||||
logDebug(
|
||||
`[antigravity] Built ${topModels.length} model windows from ${opts.modelQuotas.size} total models`,
|
||||
);
|
||||
for (const w of topModels) {
|
||||
logDebug(
|
||||
`[antigravity] ${w.label}: ${w.usedPercent.toFixed(1)}% used${w.resetAt ? ` (resets at ${new Date(w.resetAt).toISOString()})` : ""}`,
|
||||
);
|
||||
}
|
||||
windows.push(...topModels);
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
export async function fetchAntigravityUsage(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
fetchFn: typeof fetch,
|
||||
): Promise<ProviderUsageSnapshot> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "antigravity",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
};
|
||||
|
||||
let credits: CreditsInfo | undefined;
|
||||
let modelQuotas: Map<string, ModelQuota> | undefined;
|
||||
let planInfo: string | undefined;
|
||||
let lastError: string | undefined;
|
||||
let projectId: string | undefined;
|
||||
|
||||
// Fetch loadCodeAssist (credits + plan info)
|
||||
try {
|
||||
const res = await fetchJson(
|
||||
`${BASE_URL}${LOAD_CODE_ASSIST_PATH}`,
|
||||
{ method: "POST", headers, body: JSON.stringify({ metadata: METADATA }) },
|
||||
timeoutMs,
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as LoadCodeAssistResponse;
|
||||
|
||||
// Extract project ID for subsequent calls
|
||||
projectId = extractProjectId(data);
|
||||
|
||||
credits = extractCredits(data);
|
||||
planInfo = extractPlanInfo(data);
|
||||
logDebug(
|
||||
`[antigravity] Credits: ${credits ? `${credits.available}/${credits.monthly}` : "none"}${planInfo ? ` (plan: ${planInfo})` : ""}`,
|
||||
);
|
||||
} else {
|
||||
lastError = await parseErrorMessage(res);
|
||||
// Fatal auth errors - stop early
|
||||
if (res.status === 401) {
|
||||
return {
|
||||
provider: "google-antigravity",
|
||||
displayName: PROVIDER_LABELS["google-antigravity"],
|
||||
windows: [],
|
||||
error: "Token expired",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lastError = "Network error";
|
||||
}
|
||||
|
||||
// Fetch fetchAvailableModels (model quotas)
|
||||
if (!projectId) {
|
||||
logDebug("[antigravity] Missing project id; requesting available models without project");
|
||||
}
|
||||
try {
|
||||
const body = JSON.stringify(projectId ? { project: projectId } : {});
|
||||
const res = await fetchJson(
|
||||
`${BASE_URL}${FETCH_AVAILABLE_MODELS_PATH}`,
|
||||
{ method: "POST", headers, body },
|
||||
timeoutMs,
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as FetchAvailableModelsResponse;
|
||||
modelQuotas = extractModelQuotas(data);
|
||||
logDebug(`[antigravity] Extracted ${modelQuotas.size} model quotas from API`);
|
||||
for (const [modelId, quota] of modelQuotas) {
|
||||
logDebug(
|
||||
`[antigravity] ${modelId}: ${(quota.remainingFraction * 100).toFixed(1)}% remaining${quota.resetTime ? ` (resets ${new Date(quota.resetTime).toISOString()})` : ""}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const err = await parseErrorMessage(res);
|
||||
if (res.status === 401) {
|
||||
lastError = "Token expired";
|
||||
} else if (!lastError) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!lastError) lastError = "Network error";
|
||||
}
|
||||
|
||||
// Build windows from available data
|
||||
const windows = buildUsageWindows({ credits, modelQuotas });
|
||||
|
||||
// Return error only if we got nothing
|
||||
if (windows.length === 0 && lastError) {
|
||||
logDebug(`[antigravity] Returning error snapshot: ${lastError}`);
|
||||
return {
|
||||
provider: "google-antigravity",
|
||||
displayName: PROVIDER_LABELS["google-antigravity"],
|
||||
windows: [],
|
||||
error: lastError,
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot: ProviderUsageSnapshot = {
|
||||
provider: "google-antigravity",
|
||||
displayName: PROVIDER_LABELS["google-antigravity"],
|
||||
windows,
|
||||
plan: planInfo,
|
||||
};
|
||||
|
||||
logDebug(
|
||||
`[antigravity] Returning snapshot with ${windows.length} windows${planInfo ? ` (plan: ${planInfo})` : ""}`,
|
||||
);
|
||||
logDebug(`[antigravity] Snapshot: ${JSON.stringify(snapshot, null, 2)}`);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js";
|
||||
export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
|
||||
export { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
|
||||
export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
|
||||
import {
|
||||
fetchAntigravityUsage,
|
||||
fetchClaudeUsage,
|
||||
fetchCodexUsage,
|
||||
fetchCopilotUsage,
|
||||
@@ -57,8 +58,9 @@ export async function loadProviderUsageSummary(
|
||||
return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn);
|
||||
case "github-copilot":
|
||||
return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn);
|
||||
case "google-gemini-cli":
|
||||
case "google-antigravity":
|
||||
return await fetchAntigravityUsage(auth.token, timeoutMs, fetchFn);
|
||||
case "google-gemini-cli":
|
||||
return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider);
|
||||
case "openai-codex":
|
||||
return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn);
|
||||
|
||||
@@ -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), 50)),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 1000)),
|
||||
]);
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ 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();
|
||||
@@ -37,6 +38,18 @@ 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"));
|
||||
@@ -44,6 +57,7 @@ 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: 200 }, () => line).join("\n");
|
||||
const content = Array.from({ length: 50 }, () => 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: 12 }, () => line).join("\n");
|
||||
const content = Array.from({ length: 4 }, () => 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: 20 }, () => line).join("\n");
|
||||
const content = Array.from({ length: 8 }, () => 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: 12 }, () => line).join("\n");
|
||||
const content = Array.from({ length: 4 }, () => line).join("\n");
|
||||
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-06.md"), content);
|
||||
|
||||
let calls = 0;
|
||||
@@ -162,6 +162,19 @@ 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: {
|
||||
@@ -183,15 +196,18 @@ describe("memory embedding batches", () => {
|
||||
expect(result.manager).not.toBeNull();
|
||||
if (!result.manager) throw new Error("manager missing");
|
||||
manager = result.manager;
|
||||
|
||||
await manager.sync({ force: true });
|
||||
try {
|
||||
await manager.sync({ force: true });
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(calls).toBe(3);
|
||||
}, 10000);
|
||||
|
||||
it("retries embeddings on transient 5xx errors", async () => {
|
||||
const line = "e".repeat(120);
|
||||
const content = Array.from({ length: 12 }, () => line).join("\n");
|
||||
const content = Array.from({ length: 4 }, () => line).join("\n");
|
||||
await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-08.md"), content);
|
||||
|
||||
let calls = 0;
|
||||
@@ -203,6 +219,19 @@ 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: {
|
||||
@@ -224,8 +253,11 @@ describe("memory embedding batches", () => {
|
||||
expect(result.manager).not.toBeNull();
|
||||
if (!result.manager) throw new Error("manager missing");
|
||||
manager = result.manager;
|
||||
|
||||
await manager.sync({ force: true });
|
||||
try {
|
||||
await manager.sync({ force: true });
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(calls).toBe(3);
|
||||
}, 10000);
|
||||
|
||||
@@ -98,8 +98,34 @@ describe("loadClawdbotPlugins", () => {
|
||||
expect(enabled?.status).toBe("loaded");
|
||||
});
|
||||
|
||||
it("loads bundled telegram plugin when enabled", { timeout: 120_000 }, () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = path.join(process.cwd(), "extensions");
|
||||
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;
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
cache: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { intro, note, outro, select, spinner, text, isCancel } from "@clack/prompts";
|
||||
import { intro, note, outro, spinner } from "@clack/prompts";
|
||||
|
||||
import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import { updateConfig } from "../commands/models/shared.js";
|
||||
@@ -6,22 +6,10 @@ import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import {
|
||||
normalizeGithubCopilotDomain,
|
||||
resolveGithubCopilotBaseUrl,
|
||||
resolveGithubCopilotUserAgent,
|
||||
} from "./github-copilot-utils.js";
|
||||
|
||||
const CLIENT_ID = "Ov23li8tweQw6odWQebz";
|
||||
const DEFAULT_DOMAIN = "github.com";
|
||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
|
||||
|
||||
function getUrls(domain: string) {
|
||||
return {
|
||||
deviceCodeUrl: `https://${domain}/login/device/code`,
|
||||
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
|
||||
};
|
||||
}
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
@@ -50,21 +38,17 @@ function parseJsonResponse<T>(value: unknown): T {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
async function requestDeviceCode(params: {
|
||||
scope: string;
|
||||
domain: string;
|
||||
}): Promise<DeviceCodeResponse> {
|
||||
const body = JSON.stringify({
|
||||
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
scope: params.scope,
|
||||
});
|
||||
|
||||
const res = await fetch(getUrls(params.domain).deviceCodeUrl, {
|
||||
const res = await fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": resolveGithubCopilotUserAgent(),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
});
|
||||
@@ -81,27 +65,24 @@ async function requestDeviceCode(params: {
|
||||
}
|
||||
|
||||
async function pollForAccessToken(params: {
|
||||
domain: string;
|
||||
deviceCode: string;
|
||||
intervalMs: number;
|
||||
expiresAt: number;
|
||||
}): Promise<string> {
|
||||
const bodyBase = {
|
||||
const bodyBase = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: params.deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
};
|
||||
const urls = getUrls(params.domain);
|
||||
});
|
||||
|
||||
while (Date.now() < params.expiresAt) {
|
||||
const res = await fetch(urls.accessTokenUrl, {
|
||||
const res = await fetch(ACCESS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": resolveGithubCopilotUserAgent(),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: JSON.stringify(bodyBase),
|
||||
body: bodyBase,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -115,14 +96,11 @@ async function pollForAccessToken(params: {
|
||||
|
||||
const err = "error" in json ? json.error : "unknown";
|
||||
if (err === "authorization_pending") {
|
||||
await new Promise((r) => setTimeout(r, params.intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS));
|
||||
await new Promise((r) => setTimeout(r, params.intervalMs));
|
||||
continue;
|
||||
}
|
||||
if (err === "slow_down") {
|
||||
const serverInterval =
|
||||
"interval" in json && typeof json.interval === "number" ? json.interval : undefined;
|
||||
const nextInterval = serverInterval ? serverInterval * 1000 : params.intervalMs + 5000;
|
||||
await new Promise((r) => setTimeout(r, nextInterval + OAUTH_POLLING_SAFETY_MARGIN_MS));
|
||||
await new Promise((r) => setTimeout(r, params.intervalMs + 2000));
|
||||
continue;
|
||||
}
|
||||
if (err === "expired_token") {
|
||||
@@ -159,42 +137,9 @@ export async function githubCopilotLoginCommand(
|
||||
);
|
||||
}
|
||||
|
||||
const deployment = await select({
|
||||
message: "Select GitHub deployment type",
|
||||
options: [
|
||||
{ label: "GitHub.com", value: DEFAULT_DOMAIN, hint: "Public" },
|
||||
{ label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
|
||||
],
|
||||
});
|
||||
if (isCancel(deployment)) {
|
||||
throw new Error("GitHub login cancelled");
|
||||
}
|
||||
|
||||
let domain = DEFAULT_DOMAIN;
|
||||
let enterpriseDomain: string | null = null;
|
||||
if (deployment === "enterprise") {
|
||||
const enterpriseInput = await text({
|
||||
message: "Enter your GitHub Enterprise URL or domain",
|
||||
placeholder: "company.ghe.com or https://company.ghe.com",
|
||||
validate: (value) => {
|
||||
if (!value) return "URL or domain is required";
|
||||
return normalizeGithubCopilotDomain(value) ? undefined : "Enter a valid URL or domain";
|
||||
},
|
||||
});
|
||||
if (isCancel(enterpriseInput)) {
|
||||
throw new Error("GitHub login cancelled");
|
||||
}
|
||||
const normalized = normalizeGithubCopilotDomain(enterpriseInput);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid GitHub Enterprise URL/domain");
|
||||
}
|
||||
enterpriseDomain = normalized;
|
||||
domain = normalized;
|
||||
}
|
||||
|
||||
const spin = spinner();
|
||||
spin.start("Requesting device code from GitHub...");
|
||||
const device = await requestDeviceCode({ scope: "read:user", domain });
|
||||
const device = await requestDeviceCode({ scope: "read:user" });
|
||||
spin.stop("Device code ready");
|
||||
|
||||
note(
|
||||
@@ -208,7 +153,6 @@ export async function githubCopilotLoginCommand(
|
||||
const polling = spinner();
|
||||
polling.start("Waiting for GitHub authorization...");
|
||||
const accessToken = await pollForAccessToken({
|
||||
domain,
|
||||
deviceCode: device.device_code,
|
||||
intervalMs,
|
||||
expiresAt,
|
||||
@@ -218,13 +162,11 @@ export async function githubCopilotLoginCommand(
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
refresh: accessToken,
|
||||
access: accessToken,
|
||||
// Copilot access tokens are treated as non-expiring (see resolveApiKeyForProfile).
|
||||
expires: 0,
|
||||
enterpriseUrl: enterpriseDomain ?? undefined,
|
||||
token: accessToken,
|
||||
// GitHub device flow token doesn't reliably include expiry here.
|
||||
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
|
||||
},
|
||||
});
|
||||
|
||||
@@ -232,13 +174,12 @@ export async function githubCopilotLoginCommand(
|
||||
applyAuthProfileConfig(cfg, {
|
||||
provider: "github-copilot",
|
||||
profileId,
|
||||
mode: "oauth",
|
||||
mode: "token",
|
||||
}),
|
||||
);
|
||||
|
||||
logConfigUpdated(runtime);
|
||||
runtime.log(`Auth profile: ${profileId} (github-copilot/oauth)`);
|
||||
runtime.log(`Base URL: ${resolveGithubCopilotBaseUrl(enterpriseDomain ?? undefined)}`);
|
||||
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
|
||||
|
||||
outro("Done");
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "./github-copilot-utils.js";
|
||||
|
||||
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
||||
|
||||
@@ -54,7 +53,7 @@ function parseCopilotTokenResponse(value: unknown): {
|
||||
return { token, expiresAt: expiresAtMs };
|
||||
}
|
||||
|
||||
export const DEFAULT_COPILOT_API_BASE_URL = DEFAULT_GITHUB_COPILOT_BASE_URL;
|
||||
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
||||
|
||||
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
|
||||
const trimmed = token.trim();
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export const DEFAULT_GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com";
|
||||
|
||||
export function resolveGithubCopilotUserAgent(): string {
|
||||
const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown";
|
||||
return `clawdbot/${version}`;
|
||||
}
|
||||
|
||||
export function normalizeGithubCopilotDomain(input: string | null | undefined): string | null {
|
||||
const trimmed = (input ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGithubCopilotBaseUrl(enterpriseDomain?: string | null): string {
|
||||
if (enterpriseDomain && enterpriseDomain.trim()) {
|
||||
return `https://copilot-api.${enterpriseDomain.trim()}`;
|
||||
}
|
||||
return DEFAULT_GITHUB_COPILOT_BASE_URL;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import type { 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";
|
||||
|
||||
@@ -56,7 +57,7 @@ function normalizeEmoji(raw: string) {
|
||||
|
||||
async function getClient(opts: SlackActionClientOpts = {}) {
|
||||
const token = resolveToken(opts.token, opts.accountId);
|
||||
return opts.client ?? new WebClient(token);
|
||||
return opts.client ?? createSlackWebClient(token);
|
||||
}
|
||||
|
||||
async function resolveBotUserId(client: WebClient) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
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 { WebClient } from "@slack/web-api";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
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 = new WebClient(token);
|
||||
const client = createSlackWebClient(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 = new WebClient(token);
|
||||
const client = createSlackWebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const channels: SlackChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
@@ -130,16 +131,19 @@ 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 { WebClient } from "@slack/web-api";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
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 = new WebClient(token);
|
||||
const client = createSlackWebClient(token);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await withTimeout(client.auth.test(), timeoutMs);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
export type SlackChannelLookup = {
|
||||
id: string;
|
||||
@@ -84,7 +86,7 @@ export async function resolveSlackChannelAllowlist(params: {
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
}): Promise<SlackChannelResolution[]> {
|
||||
const client = params.client ?? new WebClient(params.token);
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const channels = await listSlackChannels(client);
|
||||
const results: SlackChannelResolution[] = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
export type SlackUserLookup = {
|
||||
id: string;
|
||||
@@ -101,7 +103,7 @@ export async function resolveSlackUserAllowlist(params: {
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
}): Promise<SlackUserResolution[]> {
|
||||
const client = params.client ?? new WebClient(params.token);
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const users = await listSlackUsers(client);
|
||||
const results: SlackUserResolution[] = [];
|
||||
|
||||
|
||||
+4
-2
@@ -1,4 +1,6 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
export type SlackScopesResult = {
|
||||
ok: boolean;
|
||||
@@ -81,7 +83,7 @@ export async function fetchSlackScopes(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
): Promise<SlackScopesResult> {
|
||||
const client = new WebClient(token, { timeout: timeoutMs });
|
||||
const client = createSlackWebClient(token, { timeout: timeoutMs });
|
||||
const attempts: SlackScopesSource[] = ["auth.scopes", "apps.permissions.info"];
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
|
||||
import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api";
|
||||
|
||||
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -137,7 +138,7 @@ export async function sendMessageSlack(
|
||||
fallbackToken: account.botToken,
|
||||
fallbackSource: account.botTokenSource,
|
||||
});
|
||||
const client = opts.client ?? new WebClient(token);
|
||||
const client = opts.client ?? createSlackWebClient(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(client, recipient);
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||
|
||||
@@ -21,6 +21,7 @@ const apiStub: ApiStub = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetInboundDedupe();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
||||
const { listSkillCommandsForAgents } = vi.hoisted(() => ({
|
||||
listSkillCommandsForAgents: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("../auto-reply/skill-commands.js", () => ({
|
||||
listSkillCommandsForAgents,
|
||||
}));
|
||||
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";
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user