Compare commits
15 commits
e2ef9c7c67
...
e396e6d163
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e396e6d163 | ||
| e390365810 | |||
|
|
62ea507974 | ||
|
|
a85217f753 | ||
| ce54b4c824 | |||
|
|
714e0c01e5 | ||
|
|
5869c15f9d | ||
|
|
90f96bdbf4 | ||
|
|
57cd3d4a43 | ||
| 67433d63fa | |||
|
|
aeb4bd5b59 | ||
| 419d0990bf | |||
|
|
3dd75ddc30 | ||
|
|
5718284518 | ||
|
|
00132c6d22 |
18 changed files with 1078 additions and 168 deletions
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -13,7 +13,8 @@
|
|||
"discord.js": "^14.16.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"yaml": "^2.6.0"
|
||||
"yaml": "^2.6.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
|
|
@ -1862,15 +1863,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
|
||||
|
|
@ -1883,6 +1875,15 @@
|
|||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -2756,7 +2757,8 @@
|
|||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
|
|
@ -2885,13 +2887,6 @@
|
|||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
|
|
@ -2931,6 +2926,13 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
|
|
@ -4343,7 +4345,15 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
"description": "Discord-bot-powered multi-agent workspace manager built on Claude Code",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsc && node dist/index.js",
|
||||
"dev": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\" && node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
|
@ -24,7 +24,8 @@
|
|||
"discord.js": "^14.16.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"yaml": "^2.6.0"
|
||||
"yaml": "^2.6.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,17 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as YAML from "yaml";
|
||||
import { ZodError } from "zod";
|
||||
import { AgentYamlSchema } from "./schema";
|
||||
import type { AgentYaml as AgentIdentity } from "./schema";
|
||||
|
||||
export type { AgentYaml as AgentIdentity } from "./schema";
|
||||
|
||||
/**
|
||||
* Metadata stored in agent.yaml -- DisClaw-specific fields only.
|
||||
* Identity, personality, and instructions now live in CLAUDE.md,
|
||||
* which Claude Code reads automatically.
|
||||
*/
|
||||
export interface AgentIdentity {
|
||||
name: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads agent.yaml from the workspace folder and returns the parsed identity.
|
||||
* Loads agent.yaml from the workspace folder and returns the validated identity.
|
||||
* Re-read on every call so edits take effect immediately.
|
||||
*/
|
||||
export function loadAgentIdentity(workspacePath: string): AgentIdentity {
|
||||
export function loadAgentIdentity(workspacePath: string): import("./schema").AgentYaml {
|
||||
const yamlPath = path.join(workspacePath, "agent.yaml");
|
||||
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
|
|
@ -26,7 +19,20 @@ export function loadAgentIdentity(workspacePath: string): AgentIdentity {
|
|||
}
|
||||
|
||||
const raw = fs.readFileSync(yamlPath, "utf-8");
|
||||
return YAML.parse(raw) as AgentIdentity;
|
||||
const rawParsed = YAML.parse(raw);
|
||||
|
||||
try {
|
||||
return AgentYamlSchema.parse(rawParsed);
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
const first = err.errors[0];
|
||||
const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)";
|
||||
throw new Error(
|
||||
`Invalid agent.yaml at ${yamlPath}: field "${fieldPath}" -- ${first.message}`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { loadDisclawConfig } from "../config/loader";
|
|||
import { resolveClaude } from "../runtime/resolve-claude";
|
||||
import { sanitizedEnv } from "../runtime/env";
|
||||
import { Semaphore } from "../runtime/concurrency";
|
||||
import type { RunResult } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level semaphore — lazy-initialised on first runAgent() call so the
|
||||
|
|
@ -167,7 +168,7 @@ function parseClaudeJsonOutput(raw: string): string {
|
|||
* Timeout defaults to 120 seconds. The process is killed if it exceeds
|
||||
* the timeout.
|
||||
*/
|
||||
export async function runAgent(options: RunAgentOptions): Promise<string> {
|
||||
export async function runAgent(options: RunAgentOptions): Promise<RunResult> {
|
||||
const {
|
||||
workspacePath,
|
||||
channelName,
|
||||
|
|
@ -198,7 +199,7 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
|||
await semaphore.acquire();
|
||||
|
||||
try {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
return await new Promise<RunResult>((resolve) => {
|
||||
// NOTE: --bare is intentionally NOT used here. --bare disables OAuth/keychain
|
||||
// auth and requires ANTHROPIC_API_KEY, which breaks Claude Pro/Max subscriptions.
|
||||
// CLAUDE.md isolation is handled structurally: workspaces live under
|
||||
|
|
@ -242,7 +243,8 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
// Timeout handling
|
||||
// Timeout handling — kill the process; the "close" handler will resolve
|
||||
// with { status: "timeout" } once the process exits (killed flag is set).
|
||||
const timer = setTimeout(() => {
|
||||
killed = true;
|
||||
child.kill("SIGTERM");
|
||||
|
|
@ -267,15 +269,20 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
|||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timer);
|
||||
if (err.code === "ENOENT") {
|
||||
reject(
|
||||
new Error(
|
||||
resolve({
|
||||
status: "cli-error",
|
||||
message:
|
||||
`Claude Code CLI not found at "${claudeCommand}". ` +
|
||||
`Install it with: npm install -g @anthropic-ai/claude-code\n` +
|
||||
`Or set CLAUDE_PATH in your .env file.`
|
||||
)
|
||||
);
|
||||
`Or set CLAUDE_PATH in your .env file.`,
|
||||
stderr: undefined,
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`Failed to start Claude Code CLI: ${err.message}`));
|
||||
resolve({
|
||||
status: "cli-error",
|
||||
message: `Failed to start Claude Code CLI: ${err.message}`,
|
||||
stderr: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -283,18 +290,22 @@ export async function runAgent(options: RunAgentOptions): Promise<string> {
|
|||
clearTimeout(timer);
|
||||
|
||||
if (killed) {
|
||||
reject(new Error(`Claude Code CLI timed out after ${timeoutMs / 1000} seconds.`));
|
||||
resolve({ status: "timeout", timeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
|
||||
reject(new Error(`Claude Code CLI error: ${errorDetail}`));
|
||||
resolve({
|
||||
status: "cli-error",
|
||||
message: `Claude Code CLI error: ${errorDetail}`,
|
||||
stderr: stderr.trim() || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = parseClaudeJsonOutput(stdout);
|
||||
resolve(response);
|
||||
const text = parseClaudeJsonOutput(stdout);
|
||||
resolve({ status: "success", text });
|
||||
});
|
||||
|
||||
// Close stdin immediately since we pass everything via args
|
||||
|
|
|
|||
19
src/agent/schema.ts
Normal file
19
src/agent/schema.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const AgentYamlSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
display_name: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
channel_id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type AgentYaml = z.infer<typeof AgentYamlSchema>;
|
||||
|
||||
export const ClaudeJsonOutputSchema = z.union([
|
||||
z.object({ result: z.string() }),
|
||||
z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })) }),
|
||||
z.array(z.object({ type: z.string(), text: z.string().optional() })),
|
||||
z.string(),
|
||||
]);
|
||||
|
||||
export type ClaudeJsonOutput = z.infer<typeof ClaudeJsonOutputSchema>;
|
||||
5
src/agent/types.ts
Normal file
5
src/agent/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type RunResult =
|
||||
| { status: "success"; text: string; sessionId?: string }
|
||||
| { status: "timeout"; timeoutMs: number }
|
||||
| { status: "cli-error"; message: string; stderr?: string }
|
||||
| { status: "parse-error"; message: string; raw: string };
|
||||
|
|
@ -3,19 +3,15 @@ import * as os from "os";
|
|||
import * as path from "path";
|
||||
import * as dotenv from "dotenv";
|
||||
import * as YAML from "yaml";
|
||||
import { ZodError } from "zod";
|
||||
import { childLogger } from "../runtime/logger";
|
||||
import { DisclawConfigSchema } from "./schema";
|
||||
import type { DisclawConfig } from "./schema";
|
||||
|
||||
export type { DisclawConfig } from "./schema";
|
||||
|
||||
const log = childLogger("config");
|
||||
|
||||
export interface DisclawConfig {
|
||||
workspaces_root: string;
|
||||
management_channel: string;
|
||||
claude_command: string;
|
||||
claude_timeout_seconds: number;
|
||||
env_blocklist: string[];
|
||||
max_concurrent_agents: number;
|
||||
}
|
||||
|
||||
export interface EnvConfig {
|
||||
DISCORD_BOT_TOKEN: string;
|
||||
DISCORD_GUILD_ID?: string;
|
||||
|
|
@ -78,18 +74,25 @@ export function loadDisclawConfig(rootDir: string): DisclawConfig {
|
|||
}
|
||||
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = YAML.parse(raw) as Partial<DisclawConfig>;
|
||||
const rawParsed = YAML.parse(raw);
|
||||
|
||||
// Apply defaults
|
||||
const rawWorkspacesRoot = parsed.workspaces_root ?? "~/.disclaw/workspaces";
|
||||
let validated: DisclawConfig;
|
||||
try {
|
||||
validated = DisclawConfigSchema.parse(rawParsed);
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
const first = err.errors[0];
|
||||
const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)";
|
||||
throw new Error(
|
||||
`Invalid disclaw.yaml: field "${fieldPath}" -- ${first.message}`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const config: DisclawConfig = {
|
||||
workspaces_root: expandWorkspacesRoot(rawWorkspacesRoot, rootDir),
|
||||
management_channel: parsed.management_channel ?? "disclaw",
|
||||
claude_command: parsed.claude_command ?? "claude",
|
||||
claude_timeout_seconds: parsed.claude_timeout_seconds ?? 120,
|
||||
env_blocklist: parsed.env_blocklist ?? [],
|
||||
max_concurrent_agents: parsed.max_concurrent_agents ?? 4,
|
||||
...validated,
|
||||
workspaces_root: expandWorkspacesRoot(validated.workspaces_root, rootDir),
|
||||
};
|
||||
|
||||
return config;
|
||||
|
|
|
|||
12
src/config/schema.ts
Normal file
12
src/config/schema.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const DisclawConfigSchema = z.object({
|
||||
workspaces_root: z.string().min(1),
|
||||
management_channel: z.string().min(1).default("disclaw"),
|
||||
claude_command: z.string().min(1).default("claude"),
|
||||
claude_timeout_seconds: z.number().int().positive().default(120),
|
||||
max_concurrent_agents: z.number().int().positive().default(4),
|
||||
env_blocklist: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export type DisclawConfig = z.infer<typeof DisclawConfigSchema>;
|
||||
|
|
@ -2,29 +2,7 @@ import Database from "better-sqlite3";
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id TEXT NOT NULL UNIQUE,
|
||||
guild_id TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL UNIQUE,
|
||||
workspace_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
|
||||
discord_msg_id TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_workspace
|
||||
ON conversations(workspace_id, created_at);
|
||||
`;
|
||||
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8");
|
||||
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
|
|
@ -61,7 +39,7 @@ export class DisclawDatabase {
|
|||
}
|
||||
|
||||
private runMigrations(): void {
|
||||
this.db.exec(SCHEMA_SQL);
|
||||
this.db.exec(schema);
|
||||
}
|
||||
|
||||
// --- Workspace queries ---
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
-- DisClaw SQLite schema (SSOT)
|
||||
-- All statements use IF NOT EXISTS for idempotent initialization.
|
||||
|
||||
-- Workspaces: maps a Discord channel to a local agent workspace directory.
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id TEXT NOT NULL UNIQUE,
|
||||
|
|
@ -7,6 +11,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
|
|||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Conversations: stores per-message history for each workspace.
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
|
||||
|
|
@ -17,5 +22,6 @@ CREATE TABLE IF NOT EXISTS conversations (
|
|||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Index for efficient conversation history retrieval ordered by time.
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_workspace
|
||||
ON conversations(workspace_id, created_at);
|
||||
|
|
|
|||
93
src/discord/split.ts
Normal file
93
src/discord/split.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const DISCORD_HARD_LIMIT = 2000;
|
||||
|
||||
/**
|
||||
* Splits text into chunks that fit within Discord's character limit while
|
||||
* keeping Markdown fenced code blocks intact. When a chunk boundary falls
|
||||
* inside a fence, the current chunk is closed with ``` and the next chunk
|
||||
* reopens with ```<lang> so the fence state is preserved across chunks.
|
||||
*
|
||||
* @param text The full text to split.
|
||||
* @param limit Soft target size per chunk (default 1900). Must be <= 2000.
|
||||
*/
|
||||
export function splitForDiscord(text: string, limit = 1900): string[] {
|
||||
if (text.length === 0) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
if (text.length <= limit) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const effectiveLimit = Math.min(limit, DISCORD_HARD_LIMIT);
|
||||
|
||||
const chunks: string[] = [];
|
||||
const lines = text.split("\n");
|
||||
|
||||
let current = "";
|
||||
let inFence = false;
|
||||
let fenceLang = "";
|
||||
|
||||
const flush = () => {
|
||||
// Hard-split if current exceeds the Discord hard limit (e.g. because
|
||||
// a fence closure "\n```" was appended after the soft-limit check).
|
||||
while (current.length > DISCORD_HARD_LIMIT) {
|
||||
chunks.push(current.slice(0, DISCORD_HARD_LIMIT));
|
||||
current = current.slice(DISCORD_HARD_LIMIT);
|
||||
}
|
||||
if (current.length > 0) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// Detect fence toggle: line starts with ```
|
||||
const fenceMatch = line.match(/^```(\S*)/);
|
||||
const isFenceLine = fenceMatch !== null;
|
||||
|
||||
// Calculate the length this line would add to current chunk
|
||||
const separator = current.length > 0 ? "\n" : "";
|
||||
const addition = separator + line;
|
||||
|
||||
// Reserve space for a closing fence (``` + newline) when inside a code block
|
||||
const fenceOverhead = inFence ? 4 : 0; // "\n```".length
|
||||
|
||||
if (current.length + addition.length + fenceOverhead > effectiveLimit) {
|
||||
// Need to flush before adding this line
|
||||
if (inFence) {
|
||||
// Close the open fence before flushing
|
||||
current += "\n```";
|
||||
}
|
||||
flush();
|
||||
|
||||
// Reopen fence in new chunk if we were inside one
|
||||
if (inFence) {
|
||||
current = "```" + fenceLang;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle fence state after deciding to flush (so reopening uses correct lang)
|
||||
if (isFenceLine) {
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceLang = fenceMatch[1] ?? "";
|
||||
} else {
|
||||
inFence = false;
|
||||
fenceLang = "";
|
||||
}
|
||||
}
|
||||
|
||||
const sep = current.length > 0 ? "\n" : "";
|
||||
current += sep + line;
|
||||
|
||||
// Safety: if a single line alone exceeds the hard limit, hard-split it
|
||||
while (current.length > DISCORD_HARD_LIMIT) {
|
||||
chunks.push(current.slice(0, DISCORD_HARD_LIMIT));
|
||||
current = current.slice(DISCORD_HARD_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return chunks;
|
||||
}
|
||||
135
src/router.ts
135
src/router.ts
|
|
@ -1,55 +1,11 @@
|
|||
import * as os from "os";
|
||||
import { Message, TextChannel } from "discord.js";
|
||||
import { DisclawDatabase } from "./db/database";
|
||||
import { DisclawConfig } from "./config/loader";
|
||||
import { runAgent } from "./agent/runner";
|
||||
|
||||
const DISCORD_MAX_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Splits a message at line breaks so each chunk stays under the Discord
|
||||
* character limit. If a single line exceeds the limit, it is split at
|
||||
* the character boundary as a last resort.
|
||||
*/
|
||||
function splitMessage(text: string): string[] {
|
||||
if (text.length <= DISCORD_MAX_LENGTH) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
const lines = text.split("\n");
|
||||
let current = "";
|
||||
|
||||
for (const line of lines) {
|
||||
// If adding this line would exceed the limit, flush current chunk
|
||||
if (current.length + line.length + 1 > DISCORD_MAX_LENGTH) {
|
||||
if (current.length > 0) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
}
|
||||
|
||||
// Handle lines that are themselves too long
|
||||
if (line.length > DISCORD_MAX_LENGTH) {
|
||||
let remaining = line;
|
||||
while (remaining.length > DISCORD_MAX_LENGTH) {
|
||||
chunks.push(remaining.slice(0, DISCORD_MAX_LENGTH));
|
||||
remaining = remaining.slice(DISCORD_MAX_LENGTH);
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
current = remaining;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current += (current.length > 0 ? "\n" : "") + line;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
chunks.push(current);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
import { splitForDiscord } from "./discord/split";
|
||||
import { sanitizeForDiscord } from "./runtime/sanitize";
|
||||
import type { RunResult } from "./agent/types";
|
||||
|
||||
/**
|
||||
* Routes a Discord message to the correct agent based on channel_id.
|
||||
|
|
@ -84,51 +40,78 @@ export async function routeMessage(
|
|||
// Send immediately as well
|
||||
await channel.sendTyping().catch(() => {});
|
||||
|
||||
const sanitizeOpts = {
|
||||
repoRoot: workspace.workspace_path,
|
||||
home: os.homedir(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Load recent conversation history for context
|
||||
const history = db.getConversationHistory(workspace.id, 30);
|
||||
|
||||
// Run the agent
|
||||
const response = await runAgent({
|
||||
const result: RunResult = await runAgent({
|
||||
workspacePath: workspace.workspace_path,
|
||||
channelName: channel.name,
|
||||
userMessage: message.content,
|
||||
conversationHistory: history,
|
||||
});
|
||||
|
||||
// Store user message
|
||||
db.addConversation(
|
||||
workspace.id,
|
||||
message.id,
|
||||
"user",
|
||||
message.content,
|
||||
message.author.username
|
||||
);
|
||||
|
||||
// Split and send response
|
||||
const chunks = splitMessage(response);
|
||||
let firstMsgId: string | null = null;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const sent = await channel.send(chunk);
|
||||
if (!firstMsgId) {
|
||||
firstMsgId = sent.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Store assistant response (use first message id for dedup)
|
||||
if (firstMsgId) {
|
||||
if (result.status === "success") {
|
||||
// Store user message
|
||||
db.addConversation(
|
||||
workspace.id,
|
||||
firstMsgId,
|
||||
"assistant",
|
||||
response,
|
||||
null
|
||||
message.id,
|
||||
"user",
|
||||
message.content,
|
||||
message.author.username
|
||||
);
|
||||
|
||||
// Sanitize the full response once, then use the sanitized version
|
||||
// for both Discord output and conversation history storage.
|
||||
// This prevents a feedback loop where raw paths in stored history
|
||||
// get fed back to the agent as context, causing it to reproduce them.
|
||||
const sanitizedResponse = sanitizeForDiscord(result.text, sanitizeOpts);
|
||||
|
||||
// Split and send response
|
||||
const chunks = splitForDiscord(sanitizedResponse);
|
||||
let firstMsgId: string | null = null;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const sent = await channel.send(chunk);
|
||||
if (!firstMsgId) {
|
||||
firstMsgId = sent.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Store sanitized assistant response (use first message id for dedup)
|
||||
if (firstMsgId) {
|
||||
db.addConversation(
|
||||
workspace.id,
|
||||
firstMsgId,
|
||||
"assistant",
|
||||
sanitizedResponse,
|
||||
null
|
||||
);
|
||||
}
|
||||
} else if (result.status === "timeout") {
|
||||
await channel
|
||||
.send(`⏱️ Agent-Timeout nach ${result.timeoutMs / 1000}s`)
|
||||
.catch(() => {});
|
||||
} else if (result.status === "cli-error") {
|
||||
await channel
|
||||
.send(sanitizeForDiscord(`❌ CLI-Fehler: ${result.message}`, sanitizeOpts))
|
||||
.catch(() => {});
|
||||
} else if (result.status === "parse-error") {
|
||||
await channel
|
||||
.send(`⚠️ Antwort konnte nicht gelesen werden`)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
await channel.send(`Fehler bei der Verarbeitung: ${msg}`).catch(() => {});
|
||||
await channel
|
||||
.send(sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, sanitizeOpts))
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
|
|
|
|||
58
src/runtime/sanitize.ts
Normal file
58
src/runtime/sanitize.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface SanitizeOptions {
|
||||
repoRoot?: string; // e.g. /home/user/.disclaw
|
||||
home?: string; // os.homedir()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes agent output before it is sent to a Discord channel.
|
||||
* Replaces absolute paths and sensitive tokens so they are never leaked.
|
||||
*
|
||||
* Replacement order is intentional — longest/most-specific patterns first:
|
||||
* 1. repoRoot → <disclaw>
|
||||
* 2. home → ~
|
||||
* 3. Discord bot token pattern → <redacted>
|
||||
* 4. Anthropic API key pattern → <redacted>
|
||||
*/
|
||||
/** Returns both backslash and forward-slash variants of a path (for Windows). */
|
||||
function pathVariants(p: string): string[] {
|
||||
const fwd = p.replace(/\\/g, "/");
|
||||
const bwd = p.replace(/\//g, "\\");
|
||||
return fwd === bwd ? [p] : [p, fwd, bwd];
|
||||
}
|
||||
|
||||
function replaceAll(text: string, search: string, replacement: string): string {
|
||||
return text.split(search).join(replacement);
|
||||
}
|
||||
|
||||
export function sanitizeForDiscord(
|
||||
text: string,
|
||||
opts: SanitizeOptions = {}
|
||||
): string {
|
||||
let result = text;
|
||||
|
||||
// 1. Replace repo root before home so that sub-paths are caught by the
|
||||
// more specific substitution first (repoRoot is usually inside home).
|
||||
if (opts.repoRoot) {
|
||||
for (const variant of pathVariants(opts.repoRoot)) {
|
||||
result = replaceAll(result, variant, "<disclaw>");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replace home directory with ~ (both slash styles for Windows)
|
||||
if (opts.home) {
|
||||
for (const variant of pathVariants(opts.home)) {
|
||||
result = replaceAll(result, variant, "~");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Discord bot token — format: [MN]<23 base64url>.<6 base64url>.<27 base64url>
|
||||
result = result.replace(
|
||||
/[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g,
|
||||
"<redacted>"
|
||||
);
|
||||
|
||||
// 4. Anthropic API key — format: sk-ant-<20+ base64url chars>
|
||||
result = result.replace(/sk-ant-[A-Za-z0-9\-_]{20,}/g, "<redacted>");
|
||||
|
||||
return result;
|
||||
}
|
||||
97
tests/integration/db-init.test.ts
Normal file
97
tests/integration/db-init.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { DisclawDatabase } from "../../src/db/database";
|
||||
|
||||
/**
|
||||
* Integration tests for DisclawDatabase schema initialization.
|
||||
*
|
||||
* Each test uses a fresh SQLite file in os.tmpdir() to ensure full isolation.
|
||||
* The DB file is cleaned up after each test.
|
||||
*/
|
||||
|
||||
function makeTmpDbPath(): string {
|
||||
return path.join(os.tmpdir(), `disclaw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
}
|
||||
|
||||
describe("db-init: schema initialization", () => {
|
||||
let dbPath: string;
|
||||
let db: DisclawDatabase;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = makeTmpDbPath();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// ignore if already closed
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
// (a) All expected tables exist after initialization
|
||||
it("creates all expected tables on first init", () => {
|
||||
db = new DisclawDatabase(dbPath);
|
||||
|
||||
// Query sqlite_master for user-created tables
|
||||
const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db;
|
||||
const tables = rawDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
expect(tableNames).toContain("workspaces");
|
||||
expect(tableNames).toContain("conversations");
|
||||
});
|
||||
|
||||
// (b) Initializing the same DB path twice is idempotent (IF NOT EXISTS)
|
||||
it("is idempotent when initialized twice (IF NOT EXISTS)", () => {
|
||||
db = new DisclawDatabase(dbPath);
|
||||
db.close();
|
||||
|
||||
// Second initialization must not throw
|
||||
expect(() => {
|
||||
db = new DisclawDatabase(dbPath);
|
||||
}).not.toThrow();
|
||||
|
||||
const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db;
|
||||
const tables = rawDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
expect(tableNames).toContain("workspaces");
|
||||
expect(tableNames).toContain("conversations");
|
||||
});
|
||||
|
||||
// (c) Basic CRUD: create a workspace and retrieve it by channel_id
|
||||
it("supports basic workspace CRUD", () => {
|
||||
db = new DisclawDatabase(dbPath);
|
||||
|
||||
const created = db.createWorkspace(
|
||||
"channel-001",
|
||||
"guild-001",
|
||||
"test-agent",
|
||||
"/tmp/test-agent"
|
||||
);
|
||||
|
||||
expect(created.id).toBeGreaterThan(0);
|
||||
expect(created.channel_id).toBe("channel-001");
|
||||
expect(created.guild_id).toBe("guild-001");
|
||||
expect(created.agent_name).toBe("test-agent");
|
||||
expect(created.workspace_path).toBe("/tmp/test-agent");
|
||||
expect(created.created_at).toBeTruthy();
|
||||
|
||||
const fetched = db.getWorkspaceByChannelId("channel-001");
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched!.id).toBe(created.id);
|
||||
expect(fetched!.agent_name).toBe("test-agent");
|
||||
});
|
||||
});
|
||||
337
tests/unit/runner-result.test.ts
Normal file
337
tests/unit/runner-result.test.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Unit tests for the RunResult discriminated union returned by runAgent().
|
||||
*
|
||||
* cross-spawn is mocked so no real process is launched. Each test controls
|
||||
* what the fake child process emits (stdout, exit code, error event) to cover
|
||||
* all four RunResult status variants.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable mock state — mutated per test via setMockBehavior()
|
||||
// ---------------------------------------------------------------------------
|
||||
interface MockBehavior {
|
||||
/** stdout data to emit (default: valid JSON success payload) */
|
||||
stdoutData?: string;
|
||||
/** exit code the child closes with (default: 0) */
|
||||
exitCode?: number;
|
||||
/** if set, child emits an "error" event with this error object */
|
||||
spawnError?: NodeJS.ErrnoException;
|
||||
/** if true, close event is never emitted (simulates a hanging process) */
|
||||
neverClose?: boolean;
|
||||
/** delay in ms before emitting close (default: 0) */
|
||||
closeDelayMs?: number;
|
||||
}
|
||||
|
||||
let mockBehavior: MockBehavior = {};
|
||||
|
||||
function setMockBehavior(b: MockBehavior) {
|
||||
mockBehavior = b;
|
||||
}
|
||||
|
||||
vi.mock("cross-spawn", () => {
|
||||
function makeMockChild() {
|
||||
const listeners: Record<string, Array<(...a: unknown[]) => void>> = {};
|
||||
|
||||
const emit = (event: string, ...args: unknown[]) => {
|
||||
for (const cb of listeners[event] ?? []) {
|
||||
cb(...args);
|
||||
}
|
||||
};
|
||||
|
||||
const stdoutListeners: Record<string, Array<(...a: unknown[]) => void>> =
|
||||
{};
|
||||
|
||||
const stdoutStream = {
|
||||
setEncoding: () => {},
|
||||
on: (event: string, cb: (...a: unknown[]) => void) => {
|
||||
if (!stdoutListeners[event]) stdoutListeners[event] = [];
|
||||
stdoutListeners[event].push(cb);
|
||||
|
||||
if (event === "data" && !mockBehavior.spawnError) {
|
||||
const data =
|
||||
mockBehavior.stdoutData ??
|
||||
JSON.stringify({ result: "hello from agent" });
|
||||
Promise.resolve().then(() => {
|
||||
for (const handler of stdoutListeners["data"] ?? []) {
|
||||
handler(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const child = {
|
||||
stdout: stdoutStream,
|
||||
stderr: {
|
||||
setEncoding: () => {},
|
||||
on: () => {},
|
||||
},
|
||||
stdin: { end: () => {} },
|
||||
on: (event: string, cb: (...a: unknown[]) => void) => {
|
||||
if (!listeners[event]) listeners[event] = [];
|
||||
listeners[event].push(cb);
|
||||
|
||||
if (event === "error" && mockBehavior.spawnError) {
|
||||
Promise.resolve().then(() => emit("error", mockBehavior.spawnError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "close" && !mockBehavior.neverClose) {
|
||||
// Emit close after stdout data (two microtask ticks away)
|
||||
const delay = mockBehavior.closeDelayMs ?? 0;
|
||||
const schedule = delay > 0
|
||||
? (fn: () => void) => setTimeout(fn, delay)
|
||||
: (fn: () => void) =>
|
||||
Promise.resolve()
|
||||
.then(() => Promise.resolve())
|
||||
.then(fn);
|
||||
|
||||
schedule(() => emit("close", mockBehavior.exitCode ?? 0));
|
||||
}
|
||||
},
|
||||
kill: () => {},
|
||||
};
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
return {
|
||||
default: (_cmd: string, _args: string[]) => makeMockChild(),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock registration
|
||||
import { runAgent, _resetSemaphore } from "../../src/agent/runner";
|
||||
import { _resetResolveClaudeCache } from "../../src/runtime/resolve-claude";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared workspace fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RunResult discriminated union", () => {
|
||||
let workspaceDir: string;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeAll(() => {
|
||||
workspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "disclaw-runresult-")
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(workspaceDir, "agent.yaml"),
|
||||
`name: result-agent\ndisplay_name: Result Agent\nrole: Testing\nchannel_id: "999"\n`,
|
||||
"utf-8"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(workspaceDir, "CLAUDE.md"),
|
||||
"# Result Agent\nYou are a test agent.\n",
|
||||
"utf-8"
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockBehavior = {};
|
||||
_resetResolveClaudeCache();
|
||||
_resetSemaphore(4);
|
||||
process.env.CLAUDE_PATH = "/fake/claude";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
_resetResolveClaudeCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// (a) success
|
||||
// -------------------------------------------------------------------------
|
||||
it('(a) success: valid JSON stdout → status "success" with correct text', async () => {
|
||||
setMockBehavior({
|
||||
stdoutData: JSON.stringify({ result: "Agent says hi!" }),
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await runAgent({
|
||||
workspacePath: workspaceDir,
|
||||
channelName: "test",
|
||||
userMessage: "hello",
|
||||
conversationHistory: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("success");
|
||||
if (result.status === "success") {
|
||||
expect(result.text).toBe("Agent says hi!");
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// (b) timeout
|
||||
// -------------------------------------------------------------------------
|
||||
it('(b) timeout: process never closes → status "timeout" with timeoutMs', async () => {
|
||||
// Never emit close so the timeout fires. Use a very short timeout by
|
||||
// writing a disclaw.yaml that sets claude_timeout_seconds to something tiny.
|
||||
// Instead we rely on the fact that the timeout logic in runner.ts will kill
|
||||
// the child (kill() is a no-op in the mock) and then the close event must
|
||||
// still fire. We simulate this by making close fire *after* a delay longer
|
||||
// than the timeout but still having `killed = true` set by the timer.
|
||||
//
|
||||
// The cleanest approach: let the mock emit close immediately but set
|
||||
// killed=true before it fires by abusing the AbortController signal to
|
||||
// trigger the kill path, and then emit close with code 0.
|
||||
//
|
||||
// Actually the simplest: set closeDelayMs to something > timeout, but
|
||||
// the default timeout is 120s which is way too long for a test. Instead
|
||||
// we use a custom disclaw.yaml in the workspace.
|
||||
|
||||
const configPath = path.join(workspaceDir, "disclaw.yaml");
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`workspace_root: /tmp\nmanagement_channel: disclaw\nclaude_command: claude\nclaude_timeout_seconds: 0.05\nmax_concurrent_agents: 4\nenv_blocklist: []\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
// The mock's kill() is a no-op, so the close event will never arrive via
|
||||
// the normal path. We need close to fire after the timeout kills the child.
|
||||
// Set a small delay so close fires *after* the 50ms timeout.
|
||||
setMockBehavior({
|
||||
neverClose: false,
|
||||
closeDelayMs: 200, // fires after the 50ms timeout
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
// Override __dirname resolution: runner.ts resolves rootDir as 2 levels
|
||||
// up from __dirname (src/agent -> root). We need to point it to our temp
|
||||
// workspace. Use DISCLAW_ROOT env var is not available, so instead we
|
||||
// write disclaw.yaml to the project root's expected location. This is
|
||||
// tricky in a unit test — easier to just accept the default 120s timeout
|
||||
// won't fire and instead mock the behavior differently.
|
||||
//
|
||||
// Simplest working approach: use AbortController to abort → child.kill()
|
||||
// is called → mock emits close immediately with code 0 but killed=true.
|
||||
// BUT abort doesn't set `killed` in the timeout branch — it sets it in
|
||||
// the abort branch, and close code 0 after abort currently resolves success.
|
||||
//
|
||||
// The cleanest unit-testable path: just verify the timeout branch by
|
||||
// patching the timeout to a very small value via the config file in the
|
||||
// project root (since runner.ts uses loadDisclawConfig from 2 levels up).
|
||||
|
||||
// Clean up config to avoid affecting other tests
|
||||
try {
|
||||
fs.unlinkSync(configPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Alternative approach: write config to project root temporarily
|
||||
const projectRoot = path.resolve(__dirname, "../..");
|
||||
const projectConfig = path.join(projectRoot, "disclaw.yaml");
|
||||
const originalConfig = fs.existsSync(projectConfig)
|
||||
? fs.readFileSync(projectConfig, "utf-8")
|
||||
: null;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
projectConfig,
|
||||
`workspace_root: /tmp\nmanagement_channel: disclaw\nclaude_command: claude\nclaude_timeout_seconds: 0.05\nmax_concurrent_agents: 4\nenv_blocklist: []\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
setMockBehavior({
|
||||
neverClose: false,
|
||||
closeDelayMs: 200,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await runAgent({
|
||||
workspacePath: workspaceDir,
|
||||
channelName: "test",
|
||||
userMessage: "hello",
|
||||
conversationHistory: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("timeout");
|
||||
if (result.status === "timeout") {
|
||||
expect(result.timeoutMs).toBe(50);
|
||||
}
|
||||
} finally {
|
||||
if (originalConfig !== null) {
|
||||
fs.writeFileSync(projectConfig, originalConfig, "utf-8");
|
||||
} else {
|
||||
try {
|
||||
fs.unlinkSync(projectConfig);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// (c) cli-error: non-zero exit code
|
||||
// -------------------------------------------------------------------------
|
||||
it('(c) cli-error: exit code != 0 → status "cli-error"', async () => {
|
||||
setMockBehavior({
|
||||
stdoutData: "",
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
const result = await runAgent({
|
||||
workspacePath: workspaceDir,
|
||||
channelName: "test",
|
||||
userMessage: "hello",
|
||||
conversationHistory: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("cli-error");
|
||||
if (result.status === "cli-error") {
|
||||
expect(result.message).toMatch(/Claude Code CLI error/);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// (d) cli-error ENOENT
|
||||
// -------------------------------------------------------------------------
|
||||
it('(d) cli-error ENOENT: spawn error → status "cli-error" with "not found" message', async () => {
|
||||
const enoentError = Object.assign(new Error("spawn ENOENT"), {
|
||||
code: "ENOENT",
|
||||
}) as NodeJS.ErrnoException;
|
||||
|
||||
setMockBehavior({
|
||||
spawnError: enoentError,
|
||||
});
|
||||
|
||||
const result = await runAgent({
|
||||
workspacePath: workspaceDir,
|
||||
channelName: "test",
|
||||
userMessage: "hello",
|
||||
conversationHistory: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("cli-error");
|
||||
if (result.status === "cli-error") {
|
||||
expect(result.message).toMatch(/not found/i);
|
||||
expect(result.stderr).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
87
tests/unit/sanitize-for-discord.test.ts
Normal file
87
tests/unit/sanitize-for-discord.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeForDiscord } from "../../src/runtime/sanitize";
|
||||
|
||||
describe("sanitizeForDiscord", () => {
|
||||
// (a) Repo-root path is replaced with <disclaw>
|
||||
it("replaces repoRoot with <disclaw>", () => {
|
||||
const text = "Reading config from /home/user/project/disclaw.yaml";
|
||||
const result = sanitizeForDiscord(text, { repoRoot: "/home/user/project" });
|
||||
expect(result).toBe("Reading config from <disclaw>/disclaw.yaml");
|
||||
expect(result).not.toContain("/home/user/project");
|
||||
});
|
||||
|
||||
// (b) Home directory is replaced with ~
|
||||
it("replaces home directory with ~", () => {
|
||||
const text = "Workspace located at /home/user/.disclaw/workspaces/agent-x";
|
||||
const result = sanitizeForDiscord(text, { home: "/home/user" });
|
||||
expect(result).toBe("Workspace located at ~/.disclaw/workspaces/agent-x");
|
||||
expect(result).not.toContain("/home/user");
|
||||
});
|
||||
|
||||
// (c) Discord bot token pattern is redacted
|
||||
it("redacts Discord bot tokens", () => {
|
||||
const token = "MTA1NjYwNDczNTA3NDU2NjE2.GbXkRa.abcdefghijklmnopqrstuvwxyz123";
|
||||
const text = `Bot initialized with token ${token} — ready.`;
|
||||
const result = sanitizeForDiscord(text);
|
||||
expect(result).not.toContain(token);
|
||||
expect(result).toContain("<redacted>");
|
||||
});
|
||||
|
||||
// (d) Anthropic API key is redacted
|
||||
it("redacts Anthropic API keys", () => {
|
||||
const key = "sk-ant-api03-ABCDEFGHIJ1234567890abcdefghij";
|
||||
const text = `Using key ${key} for requests.`;
|
||||
const result = sanitizeForDiscord(text);
|
||||
expect(result).not.toContain(key);
|
||||
expect(result).toContain("<redacted>");
|
||||
});
|
||||
|
||||
// (e) Harmless text is not modified (no false positives)
|
||||
it("does not modify harmless text", () => {
|
||||
const text = "Hello! The agent processed your request successfully in 120ms.";
|
||||
const result = sanitizeForDiscord(text, { home: "/home/user", repoRoot: "/home/user/project" });
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
// (f) Empty string returns empty string
|
||||
it("handles empty string input", () => {
|
||||
expect(sanitizeForDiscord("")).toBe("");
|
||||
expect(sanitizeForDiscord("", { home: "/home/user", repoRoot: "/home/user/project" })).toBe("");
|
||||
});
|
||||
|
||||
// (g) No opts provided — plain text stays unchanged when no token patterns present
|
||||
it("returns text unchanged when no opts and no token patterns", () => {
|
||||
const text = "This is a perfectly normal response from the agent.";
|
||||
expect(sanitizeForDiscord(text)).toBe(text);
|
||||
});
|
||||
|
||||
// Ordering: repoRoot (sub-path of home) is replaced before home
|
||||
it("replaces repoRoot before home so nested paths are handled correctly", () => {
|
||||
const text = "/home/user/project/src/index.ts and /home/user/other.ts";
|
||||
const result = sanitizeForDiscord(text, {
|
||||
repoRoot: "/home/user/project",
|
||||
home: "/home/user",
|
||||
});
|
||||
expect(result).toBe("<disclaw>/src/index.ts and ~/other.ts");
|
||||
});
|
||||
|
||||
// Windows-style backslash paths are sanitized
|
||||
it("replaces Windows backslash paths", () => {
|
||||
const text = "File at C:\\Users\\dev\\.disclaw\\workspaces\\agent-x\\src\\main.ts";
|
||||
const result = sanitizeForDiscord(text, {
|
||||
repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x",
|
||||
home: "C:\\Users\\dev",
|
||||
});
|
||||
expect(result).toBe("File at <disclaw>\\src\\main.ts");
|
||||
});
|
||||
|
||||
// Mixed slash styles (common on Windows when tools output forward slashes)
|
||||
it("replaces forward-slash variants of Windows paths", () => {
|
||||
const text = "File at C:/Users/dev/.disclaw/workspaces/agent-x/src/main.ts";
|
||||
const result = sanitizeForDiscord(text, {
|
||||
repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x",
|
||||
home: "C:\\Users\\dev",
|
||||
});
|
||||
expect(result).toBe("File at <disclaw>/src/main.ts");
|
||||
});
|
||||
});
|
||||
84
tests/unit/schemas.test.ts
Normal file
84
tests/unit/schemas.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { DisclawConfigSchema } from "../../src/config/schema";
|
||||
import { AgentYamlSchema } from "../../src/agent/schema";
|
||||
|
||||
describe("DisclawConfigSchema", () => {
|
||||
it("(a) parses valid disclaw.yaml data correctly", () => {
|
||||
const input = {
|
||||
workspaces_root: "/home/user/.disclaw/workspaces",
|
||||
management_channel: "disclaw",
|
||||
claude_command: "claude",
|
||||
claude_timeout_seconds: 60,
|
||||
max_concurrent_agents: 2,
|
||||
env_blocklist: ["SECRET_KEY"],
|
||||
};
|
||||
|
||||
const result = DisclawConfigSchema.parse(input);
|
||||
|
||||
expect(result.workspaces_root).toBe("/home/user/.disclaw/workspaces");
|
||||
expect(result.management_channel).toBe("disclaw");
|
||||
expect(result.claude_command).toBe("claude");
|
||||
expect(result.claude_timeout_seconds).toBe(60);
|
||||
expect(result.max_concurrent_agents).toBe(2);
|
||||
expect(result.env_blocklist).toEqual(["SECRET_KEY"]);
|
||||
});
|
||||
|
||||
it("(b) throws ZodError when workspaces_root is missing", () => {
|
||||
const input = {
|
||||
management_channel: "disclaw",
|
||||
};
|
||||
|
||||
expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError);
|
||||
});
|
||||
|
||||
it("(c) throws ZodError when claude_timeout_seconds is a string", () => {
|
||||
const input = {
|
||||
workspaces_root: "/some/path",
|
||||
claude_timeout_seconds: "abc",
|
||||
};
|
||||
|
||||
expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError);
|
||||
});
|
||||
|
||||
it("(d) applies defaults when optional fields are not provided", () => {
|
||||
const input = {
|
||||
workspaces_root: "/some/path",
|
||||
};
|
||||
|
||||
const result = DisclawConfigSchema.parse(input);
|
||||
|
||||
expect(result.management_channel).toBe("disclaw");
|
||||
expect(result.claude_command).toBe("claude");
|
||||
expect(result.claude_timeout_seconds).toBe(120);
|
||||
expect(result.max_concurrent_agents).toBe(4);
|
||||
expect(result.env_blocklist).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentYamlSchema", () => {
|
||||
it("(e) parses valid agent.yaml data correctly", () => {
|
||||
const input = {
|
||||
name: "my-agent",
|
||||
display_name: "My Agent",
|
||||
role: "Backend developer",
|
||||
channel_id: "1234567890",
|
||||
};
|
||||
|
||||
const result = AgentYamlSchema.parse(input);
|
||||
|
||||
expect(result.name).toBe("my-agent");
|
||||
expect(result.display_name).toBe("My Agent");
|
||||
expect(result.role).toBe("Backend developer");
|
||||
expect(result.channel_id).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("(f) throws ZodError when channel_id is missing", () => {
|
||||
const input = {
|
||||
name: "my-agent",
|
||||
role: "Backend developer",
|
||||
};
|
||||
|
||||
expect(() => AgentYamlSchema.parse(input)).toThrow(ZodError);
|
||||
});
|
||||
});
|
||||
120
tests/unit/split-for-discord.test.ts
Normal file
120
tests/unit/split-for-discord.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { splitForDiscord } from "../../src/discord/split";
|
||||
|
||||
const DISCORD_HARD_LIMIT = 2000;
|
||||
|
||||
describe("splitForDiscord", () => {
|
||||
// (a) Short message below limit → returned as single unchanged chunk
|
||||
it("returns a single chunk for short messages", () => {
|
||||
const text = "Hello, world!";
|
||||
const result = splitForDiscord(text);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(text);
|
||||
});
|
||||
|
||||
// (b) Text exactly at the limit → single chunk
|
||||
it("returns a single chunk when text is exactly at the limit", () => {
|
||||
const text = "a".repeat(1900);
|
||||
const result = splitForDiscord(text, 1900);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(text);
|
||||
});
|
||||
|
||||
// (c) Long plain text → split at line boundaries
|
||||
it("splits long plain text at line boundaries", () => {
|
||||
const line = "x".repeat(100);
|
||||
// 25 lines = 25*100 + 24 newlines = 2524 chars → must split with limit 1900
|
||||
const text = Array.from({ length: 25 }, () => line).join("\n");
|
||||
const result = splitForDiscord(text, 1900);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
// Reassembled text must equal original
|
||||
expect(result.join("\n")).toBe(text);
|
||||
});
|
||||
|
||||
// (d) Code block spanning a split → fence is closed and reopened with lang tag
|
||||
it("closes and reopens fenced code blocks across chunk boundaries", () => {
|
||||
const preamble = "Here is some code:\n";
|
||||
const fenceOpen = "```typescript\n";
|
||||
// Fill enough code lines to force a split inside the fence
|
||||
const codeLine = "const x = 1; // " + "y".repeat(80) + "\n";
|
||||
const codeLines = Array.from({ length: 20 }, () => codeLine).join("");
|
||||
const fenceClose = "```\n";
|
||||
const epilogue = "End of message.";
|
||||
|
||||
const text = preamble + fenceOpen + codeLines + fenceClose + epilogue;
|
||||
const result = splitForDiscord(text, 1900);
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
|
||||
// Every chunk that starts mid-fence must open with ```typescript
|
||||
// Every chunk that ends mid-fence must close with ```
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
const chunk = result[i];
|
||||
// Count fence toggles to determine if we end inside a fence
|
||||
const fenceToggles = (chunk.match(/^```/gm) ?? []).length;
|
||||
if (fenceToggles % 2 === 1) {
|
||||
// Odd number means we opened but didn't close — the splitter must close it
|
||||
expect(chunk.trimEnd()).toMatch(/```$/);
|
||||
// And the next chunk must reopen with the same language
|
||||
expect(result[i + 1]).toMatch(/^```typescript/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// (e) No chunk exceeds 2000 characters
|
||||
it("never produces a chunk exceeding 2000 characters", () => {
|
||||
const line = "z".repeat(150);
|
||||
const text = Array.from({ length: 50 }, () => line).join("\n");
|
||||
const result = splitForDiscord(text, 1900);
|
||||
for (const chunk of result) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(DISCORD_HARD_LIMIT);
|
||||
}
|
||||
});
|
||||
|
||||
// (f) Empty input → no crash, returns consistent value
|
||||
it("handles empty input without crashing", () => {
|
||||
expect(() => splitForDiscord("")).not.toThrow();
|
||||
const result = splitForDiscord("");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
// Additional: fence language tag is preserved correctly
|
||||
it("preserves the fence language tag when reopening after a split", () => {
|
||||
const limit = 200;
|
||||
const header = "Intro\n";
|
||||
const fenceOpen = "```python\n";
|
||||
// Enough lines to exceed 200 chars inside the fence
|
||||
const codeLine = "print('hello')\n";
|
||||
const codeLines = Array.from({ length: 20 }, () => codeLine).join("");
|
||||
const fenceClose = "```";
|
||||
|
||||
const text = header + fenceOpen + codeLines + fenceClose;
|
||||
const result = splitForDiscord(text, limit);
|
||||
|
||||
// Find the first chunk that ends with ``` (closed fence)
|
||||
const closingChunk = result.find(
|
||||
(c, i) => i < result.length - 1 && c.trimEnd().endsWith("```")
|
||||
);
|
||||
expect(closingChunk).toBeDefined();
|
||||
|
||||
// The chunk following a mid-fence close should start with ```python
|
||||
const idx = result.indexOf(closingChunk!);
|
||||
expect(result[idx + 1]).toMatch(/^```python/);
|
||||
});
|
||||
|
||||
// Regression: fence closure "\n```" must not push a chunk beyond the 2000 hard limit
|
||||
it("does not exceed 2000 chars even when fence closure is appended at limit boundary", () => {
|
||||
const fenceOpen = "```typescript\n";
|
||||
// Line long enough that current is near 2000 when fence closure is added
|
||||
const longLine = "x".repeat(1984);
|
||||
const nextLine = "y".repeat(10);
|
||||
const fenceClose = "\n```";
|
||||
|
||||
const text = fenceOpen + longLine + "\n" + nextLine + fenceClose;
|
||||
const result = splitForDiscord(text, 2000);
|
||||
|
||||
for (const chunk of result) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(DISCORD_HARD_LIMIT);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue