diff --git a/.env.example b/.env.example index 24abea3..733af3c 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,17 @@ DISCORD_ALLOWED_USER=your_discord_user_id_here # TIMEOUT_MS=300000 # LOG_LEVEL=info # debug, info, warn, error # TIMEZONE=Asia/Tokyo + +# Autonomous heartbeat +# HEARTBEAT_ENABLED=true +# HEARTBEAT_CHANNEL_ID=123456789 +# HEARTBEAT_MIN_INTERVAL_MS=1800000 # 30 minutes +# HEARTBEAT_MAX_INTERVAL_MS=7200000 # 2 hours +# HEARTBEAT_IDLE_THRESHOLD_MS=600000 # 10 minutes + +# Event triggers (morning/evening/weekly) +# TRIGGER_ENABLED=true +# TRIGGER_CHANNEL_ID=123456789 +# TRIGGER_MORNING_HOUR=8 +# TRIGGER_EVENING_HOUR=22 +# TRIGGER_WEEKLY_DAY=0 # 0=Sun, 1=Mon, ..., 6=Sat diff --git a/CLAUDE.md b/CLAUDE.md index f6a30c7..15a687a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ bun run check:fix # Biome auto-fix bun run typecheck # Type check only (tsc --noEmit) bun run test # Run tests (vitest run) bun run test:watch # Test watch mode -bun vitest run tests/sessions.test.ts # Run a single test +bun vitest run tests/settings.test.ts # Run a single test ``` Docker: `docker compose up thor -d --build` @@ -29,19 +29,20 @@ thor is a wrapper that invokes Claude Code from Discord chat. Designed for singl | Layer | Files | Role | |-------|-------|------| -| Chat | `index.ts` | Discord client, message routing | -| Agent | `agent-runner.ts`, `base-runner.ts` | Abstract interface for AI CLI | -| CLI Adapter | `claude-code.ts` | Claude Code adapter implementation | -| Process | `persistent-runner.ts`, `runner-manager.ts`, `process-manager.ts` | Persistent process management, queue, circuit breaker | -| Scheduler | `scheduler.ts`, `schedule-cli.ts` | Cron/one-shot schedules, JSON persistence | -| Skills | `skills.ts` | Load skills from `workspace/skills/` | -| Config | `config.ts`, `constants.ts`, `settings.ts`, `sessions.ts` | Environment variables, constants, runtime settings, session management | +| Entry | `index.ts` | Bootstrap, Discord client setup, Brain/Scheduler wiring | +| Discord | `discord-client.ts`, `agent-response.ts`, `slash-commands.ts` | Message routing, streaming response, slash commands | +| Brain | `brain/brain.ts`, `brain/heartbeat.ts`, `brain/triggers.ts` | Priority queue, autonomous heartbeat/triggers | +| Agent | `agent-runner.ts`, `sdk-runner.ts`, `system-prompt.ts` | Agent SDK runner, system prompt construction | +| MCP | `mcp/server.ts`, `mcp/discord-tools.ts`, `mcp/schedule-tools.ts` | MCP tools for Discord and scheduler operations | +| Scheduler | `scheduler.ts`, `schedule-handler.ts`, `scheduler-discord.ts` | Cron/one-shot schedules, slash command handler, Discord bridge | +| Config | `config.ts`, `constants.ts`, `settings.ts` | Environment variables, constants, runtime settings | ### Key Data Flows -- `index.ts` receives Discord messages → forwards to AI CLI via `processPrompt()` -- Detects `!discord` / `!schedule` / `SYSTEM_COMMAND:` in AI output and executes them autonomously -- `persistent-runner.ts` runs Claude Code as a persistent process using `--input-format=stream-json` +- `index.ts` boots Discord client → `discord-client.ts` routes messages via `routeMessage()` +- `agent-response.ts` streams AI responses to Discord with live editing +- `SYSTEM_COMMAND:` in AI output triggers `system-commands.ts` (e.g., restart) +- MCP tools (`discord-tools.ts`, `schedule-tools.ts`) provide Discord/scheduler access to the AI agent - Scheduler runs periodic tasks with `node-cron` and sends results to channels ## Development Practices diff --git a/README.md b/README.md index c799ccf..f689fc2 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,37 @@ # thor -thor is a personal AI assistant for Discord. -It uses Claude Code as its backend. +thor is a personal AI assistant for Discord, powered by the Claude Agent SDK. +Designed for single-user use. ## Features -- **Chat** — Talk to Claude directly in Discord. Responses stream in real time. +- **Chat** — Talk to Claude directly in Discord. Responses stream in real time with tool progress visibility. - **Persistent sessions** — Conversation context is retained per channel. Use `/new` to reset. +- **Brain** — Priority-based task queue. User messages automatically preempt lower-priority tasks (heartbeat, triggers). +- **Autonomous heartbeat** — Periodically checks a `HEARTBEAT.md` checklist in the workspace and acts on pending items. Stays silent when there's nothing to do. +- **Event triggers** — Time-based triggers (morning, evening, weekly reflection) that fire prompts automatically. +- **MCP tools** — The agent can interact with Discord (send messages, read history, list channels) and manage schedules through MCP tools. - **Scheduler** — Set one-time or recurring tasks with natural language (`in 30 minutes`, `every day 9:00`, cron expressions, etc.). -- **Skills** — Extend thor with custom skill files. List with `/skills`, run with `/skill`. -- **File sending** — thor can send files (images, PDFs, etc.) back to Discord. -- **Startup tasks** — Run agent prompts automatically on bot startup. +- **Personality** — Customize the agent's personality and user context via `SOUL.md` and `USER.md` in the workspace. +- **File sending** — thor can send files (images, PDFs, etc.) back to Discord via `MEDIA:/path/to/file`. + +## Architecture + +``` +Discord messages + ↓ +Brain (priority queue) + ├── USER — chat messages (highest priority, preempts others) + ├── EVENT — scheduled triggers + └── HEARTBEAT — autonomous heartbeat (lowest priority) + ↓ +Agent SDK (sdk-runner.ts) + ↓ +MCP Server + ├── Discord tools (send, channels, history, delete) + └── Schedule tools (create, list, remove, toggle) +``` ## Prerequisites @@ -31,6 +51,7 @@ cp .env.example .env Set the following in `.env`: ```bash +WORKSPACE_PATH=./workspace DISCORD_TOKEN=your_discord_bot_token DISCORD_ALLOWED_USER=123456789012345678 ``` @@ -50,16 +71,67 @@ Follow the browser authentication prompt. Credentials are stored in a Docker vol docker compose up thor -d ``` -## Core Commands - -- `/new` Start a new session -- `/stop` Stop the running task -- `/status` Check current status -- `/settings` Show current settings -- `/restart` Restart the bot -- `/schedule` Manage schedules -- `/skills` List skills -- `/skill` Execute a skill +## Configuration + +### Required + +| Variable | Description | +|----------|-------------| +| `WORKSPACE_PATH` | Path to the workspace directory | +| `DISCORD_TOKEN` | Discord bot token | +| `DISCORD_ALLOWED_USER` | Your Discord user ID (single user only) | + +### Optional + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTO_REPLY_CHANNELS` | — | Comma-separated channel IDs for auto-reply | +| `AGENT_MODEL` | — | Override the agent model | +| `TIMEOUT_MS` | `300000` | Agent timeout in milliseconds | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | +| `TIMEZONE` | `Asia/Tokyo` | Timezone for schedules and triggers | + +### Heartbeat + +| Variable | Default | Description | +|----------|---------|-------------| +| `HEARTBEAT_ENABLED` | `false` | Enable autonomous heartbeat | +| `HEARTBEAT_CHANNEL_ID` | — | Channel to send heartbeat results | +| `HEARTBEAT_MIN_INTERVAL_MS` | `1800000` | Minimum interval (30 min) | +| `HEARTBEAT_MAX_INTERVAL_MS` | `7200000` | Maximum interval (2 hours) | +| `HEARTBEAT_IDLE_THRESHOLD_MS` | `600000` | Skip if user active within this time (10 min) | + +### Triggers + +| Variable | Default | Description | +|----------|---------|-------------| +| `TRIGGER_ENABLED` | `false` | Enable event triggers | +| `TRIGGER_CHANNEL_ID` | — | Channel to send trigger results | +| `TRIGGER_MORNING_HOUR` | `8` | Morning trigger hour | +| `TRIGGER_EVENING_HOUR` | `22` | Evening trigger hour | +| `TRIGGER_WEEKLY_DAY` | `0` | Weekly reflection day (0=Sun … 6=Sat) | + +## Workspace Files + +Place these files in your `WORKSPACE_PATH` to customize behavior: + +| File | Purpose | +|------|---------| +| `USER.md` | User info and preferences (injected into system prompt) | +| `SOUL.md` | Personality and values (injected into system prompt) | +| `HEARTBEAT.md` | Checklist for autonomous heartbeat | + +## Slash Commands + +| Command | Description | +|---------|-------------| +| `/new` | Start a new session | +| `/stop` | Stop the running task | +| `/status` | Show brain status (busy, queue length, session) | +| `/schedule add` | Add a schedule | +| `/schedule list` | List all schedules | +| `/schedule remove` | Remove a schedule | +| `/schedule toggle` | Enable/disable a schedule | ## Inspired by diff --git a/bun.lock b/bun.lock index f137d96..e5bb2a7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "thor-dev", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.66", "consola": "^3.4.2", "discord.js": "^14.16.3", "node-cron": "^4.2.1", @@ -22,6 +23,8 @@ }, }, "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.66", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-hJL+j35yIkhcryR0kkszLz00lsEWBMu3qx8hazIHh55QV2nnZ6cn3p6oi04/VT84J9YU90jfmDffTQ66RD7YZg=="], + "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], @@ -104,6 +107,38 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], diff --git a/docs/agent-sdk-migration.md b/docs/agent-sdk-migration.md new file mode 100644 index 0000000..f3ec409 --- /dev/null +++ b/docs/agent-sdk-migration.md @@ -0,0 +1,171 @@ +# Agent SDK Migration: Architecture Document + +## Executive Summary + +thor の AI バックエンドを **Claude CLI のサブプロセス管理** から **Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) のインプロセス呼び出し** へ移行した。 +同時に、AI 出力中のテキストコマンド (`!discord send ...`) を正規表現で解析・実行していたフィードバックループを廃止し、**MCP (Model Context Protocol) Tools** として再実装した。 + +### Before / After + +| 観点 | Before | After | +|------|--------|-------| +| AI 呼び出し | `child_process.spawn("claude")` で持続プロセスを管理 | `query()` による per-request 関数呼び出し | +| Discord/Schedule 操作 | AI 出力テキストを正規表現パース → 実行 → 結果を再注入 | AI が MCP Tool を直接呼び出し | +| エラー回復 | Circuit breaker, backoff, buffer flush | SDK 側で管理、不要に | +| セッション継続 | stdin/stdout の持続プロセスで実現 | `resume: sessionId` オプション | + +--- + +## Architecture + +``` +Discord User + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ Discord Client (discord.js) │ +│ message-handler.ts / discord-client.ts │ +└──────────┬───────────────────────────────────────┘ + │ getBrain().runStream(prompt, callbacks) + ▼ +┌──────────────────────────────────────────────────┐ +│ Brain (brain.ts) │ +│ Priority queue + preemption │ +│ USER(0) > EVENT(1) > HEARTBEAT(2) │ +└──────────┬───────────────────────────────────────┘ + │ runner.runStream(prompt, callbacks) + ▼ +┌──────────────────────────────────────────────────┐ +│ SdkRunner (sdk-runner.ts) │ +│ per-request query() + AbortController │ +│ async generator で SDKMessage をストリーミング処理│ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ MCP Server "thor" (server.ts) │ │ +│ │ ├── discord_send │ │ +│ │ ├── discord_channels │ │ +│ │ ├── discord_history │ │ +│ │ ├── discord_delete │ │ +│ │ ├── schedule_create │ │ +│ │ ├── schedule_list │ │ +│ │ ├── schedule_remove │ │ +│ │ └── schedule_toggle │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ + │ + ▼ + Claude Code (Anthropic API) +``` + +### Data Flow + +1. Discord メッセージ受信 → `message-handler.ts` がプロンプトを構築 +2. `Brain.runStream()` が priority queue でスケジューリング(USER メッセージは HEARTBEAT タスクをプリエンプト) +3. `SdkRunner.runStream()` が SDK `query()` を呼び出し、`AsyncGenerator` をイテレート +4. AI が Discord 送信やスケジュール操作を行いたい場合、**MCP Tool を直接呼び出す**(テキスト出力を経由しない) +5. ストリーミング中のテキスト delta を `onText` callback 経由で Discord に逐次表示 +6. 完了時に `onComplete` で最終テキストを Discord に送信 + +--- + +## Technology Stack + +| Layer | Technology | Version | Purpose | +|-------|-----------|---------|---------| +| Runtime | Bun | latest | TypeScript 実行、パッケージ管理 | +| AI SDK | `@anthropic-ai/claude-agent-sdk` | ^0.2.66 | Claude Code のプログラマティック呼び出し | +| Chat | `discord.js` | ^14.16.3 | Discord Bot | +| Schema | `zod` | ^4.3.6 | MCP Tool の入力スキーマ定義 | +| Scheduler | `node-cron` | ^4.2.1 | 定期実行タスク | +| Lint | `@biomejs/biome` | 2.4.4 | Linter / Formatter | +| Test | `vitest` | ^4.0.18 | Unit test | +| Type | `typescript` | ^5.7.2 | Static typing | + +--- + +## Key Design Decisions + +### 1. Per-request `query()` (持続プロセスではなく) + +**選択**: リクエストごとに `query()` を呼び出し、`AsyncGenerator` でメッセージをストリーミング処理。 + +**理由**: +- Brain の priority queue はタスクをキャンセル・プリエンプトする必要があり、per-request + `AbortController` が最もシンプル +- セッション継続は SDK の `resume: sessionId` オプションで実現できるため、持続プロセスは不要 +- プロセスクラッシュ回復(circuit breaker, backoff, buffer management)が完全に不要になる + +### 2. In-process MCP Server + +**選択**: `createSdkMcpServer()` でインプロセス MCP サーバーを作成し、ツール関数が Discord client / Scheduler を直接参照。 + +**理由**: +- IPC オーバーヘッドゼロ +- ツール関数は通常の TypeScript 関数として実装でき、テストも容易 +- 型安全: `zod/v4` でスキーマを定義し、SDK の `tool()` ヘルパーで型推論が効く + +### 3. `RunContext` による channel 情報の受け渡し + +**選択**: mutable な `RunContext` オブジェクトを `query()` 呼び出し前に同期的にセットし、MCP Tool 内で参照。 + +**理由**: +- Brain が実行を直列化しているためレースコンディションは発生しない +- 各ツールが呼び出し元の channelId/guildId を知る必要があるが、MCP プロトコルにはリクエストコンテキストの概念がないため、アプリケーションレベルで注入 + +### 4. Lazy Brain 初期化 + +**選択**: `getBrain: () => Brain` パターンで Discord client に lazy accessor を渡す。 + +**理由**: +- SdkRunner の構築には Discord client(MCP ツール用)と Scheduler が必要 +- Discord client のセットアップには Brain への参照が必要 +- 循環依存を lazy accessor で解消 + +### 5. テキストベースコマンドの維持 (`SYSTEM_COMMAND:restart`, `MEDIA:`) + +**選択**: プロセス再起動やファイル添付は引き続きテキスト出力で処理。 + +**理由**: +- `SYSTEM_COMMAND:restart` はプロセス自体の終了を伴うため、ツール呼び出しのレスポンスを返せない +- `MEDIA:` はファイルパスを出力テキストに含める軽量な仕組みで、MCP Tool 化のメリットが薄い + +--- + +## What Was Removed + +移行に伴い以下を削除(約 800 行の削減): + +| File | Role | +|------|------| +| `persistent-runner.ts` | `child_process.spawn` によるプロセス管理 | +| `claude-code.ts` | CLI アダプタ(引数構築、パス解決) | +| `feedback-loop.ts` | AI 出力パース → コマンド実行 → 結果再注入 | +| `response-parser.ts` | `!discord` / `!schedule` コマンドの正規表現パーサ | +| 関連テスト 4 ファイル | 上記の単体テスト | + +削除されたインフラ: +- Circuit breaker(`CIRCUIT_BREAKER_PREFIX`, `BACKOFF_BASE_MS`, `BACKOFF_MAX_MS`) +- Buffer management(`MAX_BUFFER_SIZE`) +- Process health monitoring(alive/dead 状態管理) +- `handleResponseFeedback()` フィードバックループ + +--- + +## What Was Added + +| File | Role | +|------|------| +| `src/mcp/context.ts` | リクエストコンテキスト(channelId, guildId) | +| `src/mcp/discord-tools.ts` | Discord MCP Tools (send, channels, history, delete) | +| `src/mcp/schedule-tools.ts` | Scheduler MCP Tools (create, list, remove, toggle) | +| `src/mcp/server.ts` | MCP サーバーファクトリ | +| `src/agent/sdk-runner.ts` | SDK ベースのランナー | + +--- + +## Verification + +| Check | Result | +|-------|--------| +| `bun run typecheck` | Pass (0 errors) | +| `bun run test` | 207 tests passed (18 files) | +| `bun run check` | Pass (Biome lint + format) | diff --git a/package.json b/package.json index e985f73..2a874d6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "author": "amaotone", "license": "MIT", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.66", "consola": "^3.4.2", "discord.js": "^14.16.3", "node-cron": "^4.2.1", diff --git a/prompts/THOR_COMMANDS.md b/prompts/THOR_COMMANDS.md index 73043ca..7c1c23f 100644 --- a/prompts/THOR_COMMANDS.md +++ b/prompts/THOR_COMMANDS.md @@ -3,91 +3,20 @@ Commands, settings, and operational rules specific to thor. **Read this at the start of every session.** -## Discord Operation Commands +## MCP Tools (Discord & Schedule) -**⚠️ `!discord` commands are NOT Bash commands!** -Write them directly in your response text. Running them with the Bash tool will cause a `command not found` error. -thor processes text output line by line. +thor provides MCP tools that you can call directly. These are available as `mcp__thor__*` tools: -**📏 Formatting Rules (all commands):** -- **Must be at the start of a line** — Each line is trimmed then checked with `startsWith`, so commands written mid-line won't be recognized -- **Ignored inside code blocks** — Commands within ` ``` ` blocks are not executed (safe for documentation examples) -- `!discord`, `!schedule`, `SYSTEM_COMMAND:` must all be at the start of a line -- `MEDIA:` is the exception — it is recognized even mid-line +- `discord_send` — Send a message to a Discord channel +- `discord_channels` — List text channels in the current guild +- `discord_history` — Fetch recent messages from a channel +- `discord_delete` — Delete a bot message by ID or link +- `schedule_create` — Create a schedule (cron, one-time, relative) +- `schedule_list` — List all schedules +- `schedule_remove` — Remove a schedule by ID +- `schedule_toggle` — Enable/disable a schedule -### Send a Message to Another Channel - -``` -!discord send <#channelID> message content -``` - -**Examples:** -``` -!discord send <#1469606785672417383> hello! -!discord send <#1466570723639165072> Starting work now -``` - -**Notes:** -- Follow the `<#channelID>` format (wrap with `<#` and `>`) -- When asked "say XX in YY channel", use this command - -### List Channels - -``` -!discord channels -``` - -### Get Channel History - -``` -!discord history [count] [<#channelID>] -``` - -Get the latest messages from a channel. **Results are returned to your context, not sent to Discord.** - -- Default count is 10, maximum 100 -- If channel ID is omitted, uses the current channel -- Use `offset:N` to go further back (fetch 30 at a time to prevent timeouts) -- Each message includes `(ID:messageID)`, which can be used with `!discord delete` - -**Examples:** -``` -!discord history # Latest 10 messages in current channel -!discord history 50 # Latest 50 messages in current channel -!discord history 20 <#1466570723639165072> # 20 messages from specified channel -!discord history 30 offset:30 # Get messages 30-60 -!discord history 30 offset:60 # Get messages 60-90 -!discord history 30 offset:30 <#1466570723639165072> # Go back in another channel -``` - -**When fetching large amounts of history (to avoid timeouts):** -Instead of fetching 100 at once, paginate 30 at a time: -1. `!discord history 30` → Latest 30 messages -2. `!discord history 30 offset:30` → Messages 30-60 -3. `!discord history 30 offset:60` → Messages 60-90 - -**Use cases:** -- Regain conversation context after a session reset -- Reference past conversations for decision-making -- Incrementally fetch a full day's conversation history for journaling - -### Delete a Message - -``` -!discord delete # Delete a specific message -!discord delete # Delete message at link (works across channels) -``` - -- A message ID or link is required (cannot be called without arguments) -- Only your own (bot) messages can be deleted (cannot delete others' messages) -- Message links use the `https://discord.com/channels/...` format - -**⚠️ Important: Deletion steps when a user pastes a message link:** -1. Do **NOT** run `!discord history` (unnecessary) -2. Pass the link directly to `!discord delete ` -3. Example: User pastes `https://discord.com/channels/111/222/333` and says "delete it" → `!discord delete https://discord.com/channels/111/222/333` - -Only check history for the ID when neither a link nor ID is provided, e.g., "delete the last one." +Use these tools instead of writing commands in your response text. --- @@ -121,43 +50,6 @@ Include the following in your response to control the system (must be at the sta When the user requests a restart, include `SYSTEM_COMMAND:restart`. Slash commands `/restart` and `/settings` are also available. - -## Schedules & Reminders - -Use `!schedule` commands to set up reminders and recurring tasks. - -### Configuration File - -Stored in `.thor/schedules.json`. Can also be edited manually. - -### Commands - -``` -!schedule add # Add a schedule -!schedule list # List all schedules -!schedule remove # Remove a schedule -!schedule toggle # Enable/disable toggle -``` - -### Schedule Format - -- `in 30 minutes meeting` — In N minutes (seconds/hours also work) -- `15:00 review` — At that time today (next day if already past) -- `2025-03-01 14:00 deadline` — Specific date and time -- `every day 9:00 good morning` — Daily at a fixed time -- `every hour check` — At the top of every hour -- `every Monday 10:00 weekly meeting` — Weekly (Mon-Sun supported) -- `cron 0 9 * * * good morning` — Direct cron expression - -### Sending to Another Channel - -Prefix with `-c <#channelID>` or `<#channelID>` to send to a specific channel. - -``` -!schedule add -c <#1469606785672417383> in 3 minutes test message -!schedule add <#1469606785672417383> every day 9:00 good morning -``` - ## Auto-Expansion Features (Read-Only) These are handled automatically by thor — no commands needed: diff --git a/src/agent/agent-runner.ts b/src/agent/agent-runner.ts index 59045d7..e522a05 100644 --- a/src/agent/agent-runner.ts +++ b/src/agent/agent-runner.ts @@ -1,9 +1,7 @@ -import type { AgentConfig } from '../lib/config.js'; -import { RunnerManager } from './runner-manager.js'; - export interface RunOptions { sessionId?: string; - channelId?: string; // プロセス管理用 + channelId?: string; + guildId?: string; } export interface RunResult { @@ -21,18 +19,6 @@ export interface StreamCallbacks { /** * AIエージェントランナーの統一インターフェース */ -export interface ChannelStatus { - channelId: string; - idleSeconds: number; - alive: boolean; -} - -export interface RunnerStatus { - poolSize: number; - maxProcesses: number; - channels: ChannelStatus[]; -} - export interface AgentRunner { run(prompt: string, options?: RunOptions): Promise; runStream(prompt: string, callbacks: StreamCallbacks, options?: RunOptions): Promise; @@ -40,23 +26,8 @@ export interface AgentRunner { cancel?(channelId?: string): boolean; /** 現在処理中のリクエスト+キュー内の全リクエストをキャンセル */ cancelAll?(channelId?: string): number; - /** 指定チャンネルのランナーを完全に破棄(/new用) */ - destroy?(channelId: string): boolean; /** シャットダウン */ shutdown?(): void; - /** ランナープールの状態を取得 */ - getStatus?(): RunnerStatus; - /** 指定チャンネルのセッションIDを取得 */ - getSessionId?(channelId: string): string | undefined; - /** 指定チャンネルのセッションをクリア */ - deleteSession?(channelId: string): void; -} - -/** - * 設定に基づいてAgentRunnerを作成 - */ -export function createAgentRunner(config: AgentConfig): AgentRunner { - return new RunnerManager(config); } /** diff --git a/src/agent/claude-code.ts b/src/agent/claude-code.ts deleted file mode 100644 index a7bddaf..0000000 --- a/src/agent/claude-code.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { spawn } from 'node:child_process'; -import { DEFAULT_TIMEOUT_MS, SESSION_ID_DISPLAY_LENGTH } from '../lib/constants.js'; -import { createLogger } from '../lib/logger.js'; -import type { RunOptions, RunResult, StreamCallbacks } from './agent-runner.js'; -import { mergeTexts } from './agent-runner.js'; -import { buildSystemPrompt } from './base-runner.js'; - -const logger = createLogger('claude-code'); - -export interface ClaudeCodeOptions { - model?: string; - timeoutMs?: number; - workdir?: string; -} - -interface ClaudeCodeResponse { - type: 'result'; - subtype: 'success' | 'error'; - is_error: boolean; - result: string; - session_id: string; - total_cost_usd: number; - duration_ms: number; -} - -/** - * Claude Code CLI を実行するランナー - */ -export class ClaudeCodeRunner { - private model?: string; - private timeoutMs: number; - private workdir?: string; - private systemPrompt: string; - - constructor(options?: ClaudeCodeOptions) { - this.model = options?.model; - this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; // デフォルト5分 - this.workdir = options?.workdir; - this.systemPrompt = buildSystemPrompt(this.workdir); - } - - async run(prompt: string, options?: RunOptions): Promise { - const args: string[] = ['-p', '--output-format', 'json', '--dangerously-skip-permissions']; - - // セッション継続 - if (options?.sessionId) { - args.push('--resume', options.sessionId); - } - - if (this.model) { - args.push('--model', this.model); - } - - // チャットプラットフォーム連携のシステムプロンプト + AGENTS.md - args.push('--append-system-prompt', this.systemPrompt); - - args.push(prompt); - - const sessionInfo = options?.sessionId - ? ` (session: ${options.sessionId.slice(0, SESSION_ID_DISPLAY_LENGTH)}...)` - : ' (new)'; - logger.info(`Executing in ${this.workdir || 'default dir'}${sessionInfo}`); - - const result = await this.execute(args); - const response = this.parseResponse(result); - - return { - result: response.result, - sessionId: response.session_id, - }; - } - - private execute(args: string[]): Promise { - return new Promise((resolve, reject) => { - const proc = spawn('claude', args, { - stdio: ['ignore', 'pipe', 'pipe'], - cwd: this.workdir, - detached: process.platform !== 'win32', - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - const timeout = setTimeout(() => { - proc.kill(); - reject(new Error(`Claude Code CLI timed out after ${this.timeoutMs}ms`)); - }, this.timeoutMs); - - proc.on('close', (code) => { - clearTimeout(timeout); - - if (code !== 0) { - reject(new Error(`Claude Code CLI exited with code ${code}: ${stderr}`)); - return; - } - - resolve(stdout); - }); - - proc.on('error', (err) => { - clearTimeout(timeout); - reject(new Error(`Failed to spawn Claude Code CLI: ${err.message}`)); - }); - }); - } - - private parseResponse(output: string): ClaudeCodeResponse { - try { - const response = JSON.parse(output.trim()) as ClaudeCodeResponse; - - if (response.is_error) { - throw new Error(`Claude Code CLI returned error: ${response.result}`); - } - - return response; - } catch (err) { - if (err instanceof SyntaxError) { - throw new Error(`Failed to parse Claude Code CLI response: ${output}`); - } - throw err; - } - } - - /** - * ストリーミング実行 - */ - async runStream( - prompt: string, - callbacks: StreamCallbacks, - options?: RunOptions - ): Promise { - const args: string[] = [ - '-p', - '--output-format', - 'stream-json', - '--verbose', - '--dangerously-skip-permissions', - ]; - - if (options?.sessionId) { - args.push('--resume', options.sessionId); - } - - if (this.model) { - args.push('--model', this.model); - } - - // チャットプラットフォーム連携のシステムプロンプト + AGENTS.md - args.push('--append-system-prompt', this.systemPrompt); - - args.push(prompt); - - const sessionInfo = options?.sessionId - ? ` (session: ${options.sessionId.slice(0, SESSION_ID_DISPLAY_LENGTH)}...)` - : ' (new)'; - logger.info(`Streaming in ${this.workdir || 'default dir'}${sessionInfo}`); - - return this.executeStream(args, callbacks); - } - - private executeStream(args: string[], callbacks: StreamCallbacks): Promise { - return new Promise((resolve, reject) => { - const proc = spawn('claude', args, { - stdio: ['ignore', 'pipe', 'pipe'], - cwd: this.workdir, - detached: process.platform !== 'win32', - }); - - let fullText = ''; - let sessionId = ''; - let buffer = ''; - - proc.stdout.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const json = JSON.parse(line); - if (json.type === 'assistant' && json.message?.content) { - for (const block of json.message.content) { - if (block.type === 'text') { - fullText += block.text; - callbacks.onText?.(block.text, fullText); - } - if (block.type === 'tool_use' && block.name) { - callbacks.onProgress?.(block.name, block.input); - } - } - } else if (json.type === 'result') { - sessionId = json.session_id; - if (json.is_error) { - const error = new Error(json.result); - callbacks.onError?.(error); - reject(error); - return; - } - // ストリーミング中の累積テキストと最終 result をマージ - // (ツール呼び出し前のテキストが result から消えるのを防ぐ) - if (json.result) { - fullText = mergeTexts(fullText, json.result); - } - } - } catch { - // JSONパースエラーは無視 - } - } - }); - - proc.stderr.on('data', (data) => { - logger.debug('stderr:', data.toString()); - }); - - const timeout = setTimeout(() => { - proc.kill(); - const error = new Error(`Claude Code CLI timed out after ${this.timeoutMs}ms`); - callbacks.onError?.(error); - reject(error); - }, this.timeoutMs); - - proc.on('close', (code) => { - clearTimeout(timeout); - - // 残りのバッファを処理 - if (buffer.trim()) { - try { - const json = JSON.parse(buffer); - if (json.type === 'assistant' && json.message?.content) { - for (const block of json.message.content) { - if (block.type === 'text') { - fullText += block.text; - } - } - } else if (json.type === 'result') { - sessionId = json.session_id; - // ストリーミング中の累積テキストと最終 result をマージ - if (json.result) { - fullText = mergeTexts(fullText, json.result); - } - } - } catch { - // JSONパースエラーは無視 - } - } - - if (code !== 0) { - const error = new Error(`Claude Code CLI exited with code ${code}`); - callbacks.onError?.(error); - reject(error); - return; - } - - const result: RunResult = { result: fullText, sessionId }; - callbacks.onComplete?.(result); - resolve(result); - }); - - proc.on('error', (err) => { - clearTimeout(timeout); - const error = new Error(`Failed to spawn Claude Code CLI: ${err.message}`); - callbacks.onError?.(error); - reject(error); - }); - }); - } -} diff --git a/src/agent/persistent-runner.ts b/src/agent/persistent-runner.ts deleted file mode 100644 index 16ecfdb..0000000 --- a/src/agent/persistent-runner.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import { EventEmitter } from 'node:events'; -import { - BACKOFF_BASE_MS, - BACKOFF_MAX_MS, - DEFAULT_TIMEOUT_MS, - MAX_BUFFER_SIZE, - SESSION_ID_DISPLAY_LENGTH, -} from '../lib/constants.js'; -import { createLogger } from '../lib/logger.js'; -import type { AgentRunner, RunOptions, RunResult, StreamCallbacks } from './agent-runner.js'; -import { mergeTexts } from './agent-runner.js'; -import { buildPersistentSystemPrompt } from './base-runner.js'; - -const logger = createLogger('persistent-runner'); - -/** - * リクエストキューのアイテム - */ -interface QueueItem { - prompt: string; - options?: RunOptions; - callbacks?: StreamCallbacks; - resolve: (result: RunResult) => void; - reject: (error: Error) => void; -} - -/** - * Claude Code CLI を常駐プロセスとして実行するランナー - * - * --input-format=stream-json を使用して、1つのプロセスで複数のリクエストを処理 - */ -export class PersistentRunner extends EventEmitter implements AgentRunner { - private process: ChildProcess | null = null; - private processAlive = false; - private queue: QueueItem[] = []; - private currentItem: QueueItem | null = null; - private buffer = ''; - private sessionId = ''; - private fullText = ''; - private shuttingDown = false; - private cancelling = false; - - // サーキットブレーカー: 連続クラッシュ対策 - private crashCount = 0; - private lastCrashTime = 0; - private readonly maxCrashes: number; - private readonly crashWindowMs: number; - - private model?: string; - private timeoutMs: number; - private workdir?: string; - private systemPrompt: string; - private resumeSessionId?: string; // プロセス再起動時に --resume で復元するセッションID - - constructor(options?: { - model?: string; - timeoutMs?: number; - workdir?: string; - maxCrashes?: number; - crashWindowMs?: number; - }) { - super(); - this.model = options?.model; - this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; - this.workdir = options?.workdir; - this.maxCrashes = options?.maxCrashes ?? 3; - this.crashWindowMs = options?.crashWindowMs ?? 60000; - this.systemPrompt = buildPersistentSystemPrompt(this.workdir); - } - - /** - * 常駐プロセスを起動 - */ - private ensureProcess(): ChildProcess { - if (this.process && this.processAlive) { - return this.process; - } - - // サーキットブレーカーチェック - if (this.crashCount >= this.maxCrashes) { - const elapsed = Date.now() - this.lastCrashTime; - if (elapsed < this.crashWindowMs) { - throw new Error( - `Circuit breaker open: ${this.crashCount} crashes in ${elapsed}ms. Waiting for cooldown.` - ); - } - // クールダウン経過後はリセット - logger.info('Circuit breaker reset after cooldown'); - this.crashCount = 0; - } - - const args: string[] = [ - '-p', - '--input-format', - 'stream-json', - '--output-format', - 'stream-json', - '--verbose', - '--dangerously-skip-permissions', - ]; - - if (this.model) { - args.push('--model', this.model); - } - - // セッション復元: 保存済みセッションIDがあれば --resume で継続 - const resumeId = this.resumeSessionId || this.sessionId; - if (resumeId) { - args.push('--resume', resumeId); - logger.info(`Resuming session: ${resumeId.slice(0, SESSION_ID_DISPLAY_LENGTH)}...`); - } - - args.push('--append-system-prompt', this.systemPrompt); - - logger.info('Starting persistent process...'); - - this.process = spawn('claude', args, { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: this.workdir, - detached: process.platform !== 'win32', - }); - this.processAlive = true; - - this.process.stdout?.on('data', (data) => this.handleOutput(data.toString())); - this.process.stderr?.on('data', (data) => { - logger.debug('stderr:', data.toString()); - }); - - this.process.on('close', (code) => { - logger.info(`Process exited with code ${code}`); - const wasShuttingDown = this.shuttingDown; - this.process = null; - this.processAlive = false; - this.buffer = ''; // バッファをクリア - - // シャットダウン中またはキャンセル中なら正常終了 - if (wasShuttingDown) { - return; - } - if (this.cancelling) { - this.cancelling = false; - // キューに次のリクエストがあれば処理 - if (this.queue.length > 0) { - this.processNext(); - } - return; - } - - // クラッシュカウンタを更新 - this.crashCount++; - this.lastCrashTime = Date.now(); - logger.warn(`Crash count: ${this.crashCount}/${this.maxCrashes}`); - - // 現在処理中のリクエストがあればエラーで終了 - if (this.currentItem) { - this.currentItem.reject(new Error(`Process exited unexpectedly with code ${code}`)); - this.currentItem = null; - } - - // サーキットブレーカーがオープンでなければ指数バックオフ付きで再処理 - if (this.queue.length > 0 && this.crashCount < this.maxCrashes) { - const backoffMs = Math.min(BACKOFF_BASE_MS * 2 ** (this.crashCount - 1), BACKOFF_MAX_MS); - logger.info(`Restarting in ${backoffMs}ms (backoff, crash #${this.crashCount})...`); - setTimeout(() => this.processNext(), backoffMs); - } else if (this.crashCount >= this.maxCrashes) { - // サーキットブレーカーオープン: キューを全部エラーにする - logger.error('Circuit breaker OPEN. Rejecting all queued requests.'); - for (const item of this.queue) { - item.reject(new Error('Circuit breaker open: too many process crashes')); - } - this.queue = []; - } - }); - - this.process.on('error', (err) => { - logger.error('Process error:', err); - this.process = null; - this.processAlive = false; - - if (this.currentItem) { - this.currentItem.reject(err); - this.currentItem = null; - } - }); - - return this.process; - } - - /** - * stdout からの出力を処理 - */ - private handleOutput(data: string): void { - this.buffer += data; - - // バッファサイズ制限: 壊れたJSONが蓄積し続けるのを防止 - if (this.buffer.length > MAX_BUFFER_SIZE) { - logger.warn(`Buffer exceeded ${MAX_BUFFER_SIZE} bytes, truncating`); - this.buffer = this.buffer.slice(-MAX_BUFFER_SIZE); - } - - const lines = this.buffer.split('\n'); - this.buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const json = JSON.parse(line); - this.handleJsonMessage(json); - } catch { - // 予期しないCLI出力をログ(デバッグ用) - logger.debug('Failed to parse JSON line:', line.slice(0, 100)); - } - } - } - - /** - * JSON メッセージを処理 - */ - private handleJsonMessage(json: { - type: string; - session_id?: string; - message?: { content?: Array<{ type: string; text?: string; name?: string; input?: unknown }> }; - result?: string; - is_error?: boolean; - }): void { - if (json.type === 'system' && json.session_id) { - this.sessionId = json.session_id; - logger.info(`Session initialized: ${this.sessionId.slice(0, SESSION_ID_DISPLAY_LENGTH)}...`); - } - - if (json.type === 'assistant' && json.message?.content) { - for (const block of json.message.content) { - if (block.type === 'text' && block.text) { - this.fullText += block.text; - this.currentItem?.callbacks?.onText?.(block.text, this.fullText); - } - if (block.type === 'tool_use' && block.name) { - this.currentItem?.callbacks?.onProgress?.(block.name, block.input); - } - } - } - - if (json.type === 'result') { - if (json.session_id) { - this.sessionId = json.session_id; - } - - if (json.is_error) { - const error = new Error(json.result || 'Unknown error'); - this.currentItem?.callbacks?.onError?.(error); - this.currentItem?.reject(error); - } else { - // ストリーミング中の累積テキストと最終 result をマージ - // (ツール呼び出し前のテキストが result から消えるのを防ぐ) - if (json.result) { - this.fullText = mergeTexts(this.fullText, json.result); - } - - const result: RunResult = { - result: this.fullText, - sessionId: this.sessionId, - }; - - this.currentItem?.callbacks?.onComplete?.(result); - this.currentItem?.resolve(result); - } - - this.currentItem = null; - this.fullText = ''; - - // 次のリクエストを処理 - this.processNext(); - } - } - - /** - * キューから次のリクエストを処理 - */ - private processNext(): void { - if (this.currentItem || this.queue.length === 0) { - return; - } - - const nextItem = this.queue.shift(); - if (!nextItem) return; - this.currentItem = nextItem; - this.fullText = ''; - - const proc = this.ensureProcess(); - - // セッション継続のためのオプションを追加 - const message = { - type: 'user', - message: { - role: 'user', - content: this.currentItem.prompt, - }, - }; - - logger.debug(`Sending request (queue: ${this.queue.length} remaining)`); - proc.stdin?.write(`${JSON.stringify(message)}\n`); - - // タイムアウト設定: タイムアウト時はプロセスをkillして状態をクリーンに - const timeout = setTimeout(() => { - if (this.currentItem) { - logger.warn(`Request timed out after ${this.timeoutMs}ms. Killing process.`); - const error = new Error(`Request timed out after ${this.timeoutMs}ms`); - this.currentItem.callbacks?.onError?.(error); - this.currentItem.reject(error); - this.currentItem = null; - - // タイムアウト時はプロセスをkillして次のリクエスト用に再起動 - // これにより、古いリクエストの出力が新しいリクエストに混ざるのを防ぐ - if (this.process) { - this.process.kill(); - this.process = null; - this.processAlive = false; - this.buffer = ''; - } - - this.processNext(); - } - }, this.timeoutMs); - - // タイムアウトをクリアするためにresolve/rejectをラップ - const originalResolve = this.currentItem.resolve; - const originalReject = this.currentItem.reject; - - this.currentItem.resolve = (result) => { - clearTimeout(timeout); - originalResolve(result); - }; - - this.currentItem.reject = (error) => { - clearTimeout(timeout); - originalReject(error); - }; - } - - /** - * リクエストを実行(キューに追加) - */ - async run(prompt: string, options?: RunOptions): Promise { - return new Promise((resolve, reject) => { - this.queue.push({ prompt, options, resolve, reject }); - this.processNext(); - }); - } - - /** - * ストリーミング実行 - */ - async runStream( - prompt: string, - callbacks: StreamCallbacks, - options?: RunOptions - ): Promise { - return new Promise((resolve, reject) => { - this.queue.push({ prompt, options, callbacks, resolve, reject }); - this.processNext(); - }); - } - - /** - * 現在処理中のリクエストをキャンセル - * プロセス自体はkillして再起動(古い出力が混ざるのを防ぐ) - */ - cancel(): boolean { - if (!this.currentItem) { - return false; - } - - logger.info('Cancelling current request'); - const error = new Error('Request cancelled by user'); - this.currentItem.callbacks?.onError?.(error); - this.currentItem.reject(error); - this.currentItem = null; - this.fullText = ''; - - // プロセスをkillして状態をクリーンにする(タイムアウト時と同じ戦略) - // cancellingフラグでcloseイベントがクラッシュ扱いしないようにする - if (this.process) { - this.cancelling = true; - this.process.kill(); - this.process = null; - this.processAlive = false; - this.buffer = ''; - } else { - // プロセスがない場合はキューの次を直接処理 - this.processNext(); - } - - return true; - } - - /** - * 現在処理中のリクエスト+キュー内の全リクエストをキャンセル - */ - cancelAll(): number { - let cancelled = 0; - const error = new Error('Request cancelled by user'); - for (const item of this.queue) { - item.callbacks?.onError?.(error); - item.reject(error); - cancelled++; - } - this.queue = []; - if (this.cancel()) cancelled++; - return cancelled; - } - - /** - * プロセスを終了 - */ - shutdown(): void { - if (this.process) { - logger.info('Shutting down persistent process...'); - this.shuttingDown = true; - this.process.stdin?.end(); - this.process.kill(); - this.process = null; - this.processAlive = false; - this.buffer = ''; - - // キューに残っているリクエストをキャンセル - for (const item of this.queue) { - item.reject(new Error('Runner is shutting down')); - } - this.queue = []; - - if (this.currentItem) { - this.currentItem.reject(new Error('Runner is shutting down')); - this.currentItem = null; - } - } - } - - /** - * 現在のセッションID - */ - getSessionId(): string { - return this.sessionId; - } - - /** - * セッションIDを設定(プロセス再起動時の --resume 用) - */ - setSessionId(sessionId: string): void { - this.resumeSessionId = sessionId; - if (!this.sessionId) { - this.sessionId = sessionId; - } - } - - /** - * 現在リクエストを処理中かどうか - */ - isBusy(): boolean { - return this.currentItem !== null; - } - - /** - * キューの長さ - */ - getQueueLength(): number { - return this.queue.length; - } - - /** - * プロセスが生きているか - */ - isAlive(): boolean { - return this.processAlive; - } - - /** - * サーキットブレーカーの状態を取得 - */ - getCircuitBreakerStatus(): { open: boolean; crashCount: number; lastCrashTime: number } { - const open = - this.crashCount >= this.maxCrashes && Date.now() - this.lastCrashTime < this.crashWindowMs; - return { open, crashCount: this.crashCount, lastCrashTime: this.lastCrashTime }; - } - - /** - * サーキットブレーカーをリセット - */ - resetCircuitBreaker(): void { - this.crashCount = 0; - this.lastCrashTime = 0; - logger.info('Circuit breaker manually reset'); - } -} diff --git a/src/agent/runner-manager.ts b/src/agent/runner-manager.ts deleted file mode 100644 index 477e593..0000000 --- a/src/agent/runner-manager.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { AgentConfig } from '../lib/config.js'; -import { createLogger } from '../lib/logger.js'; -import type { - AgentRunner, - RunnerStatus, - RunOptions, - RunResult, - StreamCallbacks, -} from './agent-runner.js'; -import { PersistentRunner } from './persistent-runner.js'; - -const logger = createLogger('runner-manager'); - -/** - * プール内のランナー情報 - */ -interface PoolEntry { - runner: PersistentRunner; - lastUsed: number; - sessionId?: string; -} - -/** - * チャンネルごとに1つの PersistentRunner を管理するランナーマネージャー - * - * LRU eviction とアイドルタイムアウトでリソースを制御する。 - * 各ランナーは内部キューで順次処理を行う。 - */ -export class RunnerManager implements AgentRunner { - private pool = new Map(); - private maxProcesses: number; - private idleTimeoutMs: number; - private cleanupInterval: ReturnType | null = null; - private agentConfig: AgentConfig; - - /** デフォルトのチャンネルID(channelIdが未指定の場合に使用) */ - private static readonly DEFAULT_CHANNEL = '__default__'; - /** クリーンアップ実行間隔 */ - private static readonly CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5分 - - constructor( - agentConfig: AgentConfig, - options?: { - maxProcesses?: number; - idleTimeoutMs?: number; - } - ) { - this.agentConfig = agentConfig; - this.maxProcesses = options?.maxProcesses ?? 10; - this.idleTimeoutMs = options?.idleTimeoutMs ?? 30 * 60 * 1000; // 30分 - - // 定期クリーンアップ開始 - this.cleanupInterval = setInterval(() => this.cleanupIdle(), RunnerManager.CLEANUP_INTERVAL_MS); - - logger.info( - `Initialized (maxProcesses: ${this.maxProcesses}, idleTimeout: ${this.idleTimeoutMs / 1000}s)` - ); - } - - /** - * チャンネルに対応する PoolEntry を取得(なければ作成) - */ - private getOrCreateEntry(channelId: string): PoolEntry { - const entry = this.pool.get(channelId); - if (entry) { - entry.lastUsed = Date.now(); - return entry; - } - - // 上限チェック → LRU eviction - if (this.pool.size >= this.maxProcesses) { - this.evictLRU(); - } - - // 新しい PersistentRunner を作成 - const runner = new PersistentRunner(this.agentConfig); - const newEntry: PoolEntry = { runner, lastUsed: Date.now() }; - this.pool.set(channelId, newEntry); - - logger.debug( - `Created runner for channel ${channelId} (pool: ${this.pool.size}/${this.maxProcesses})` - ); - - return newEntry; - } - - /** - * 最も古い(LRU)かつ空いているランナーを evict する - */ - private evictLRU(): void { - let oldestChannel: string | null = null; - let oldestTime = Infinity; - - for (const [channelId, entry] of this.pool.entries()) { - if (!entry.runner.isBusy() && entry.lastUsed < oldestTime) { - oldestTime = entry.lastUsed; - oldestChannel = channelId; - } - } - - if (oldestChannel) { - const entry = this.pool.get(oldestChannel); - if (entry) { - logger.debug( - `Evicting LRU runner for channel ${oldestChannel} (idle ${Math.round((Date.now() - entry.lastUsed) / 1000)}s)` - ); - entry.runner.shutdown(); - this.pool.delete(oldestChannel); - } - } - } - - /** - * アイドル状態のランナーをクリーンアップ - */ - private cleanupIdle(): void { - const now = Date.now(); - let cleaned = 0; - - for (const [channelId, entry] of this.pool.entries()) { - if (!entry.runner.isBusy() && now - entry.lastUsed > this.idleTimeoutMs) { - logger.debug( - `Cleaning up idle runner for channel ${channelId} (idle ${Math.round((now - entry.lastUsed) / 1000)}s)` - ); - entry.runner.shutdown(); - this.pool.delete(channelId); - cleaned++; - } - } - - if (cleaned > 0) { - logger.debug( - `Cleaned up ${cleaned} idle runner(s) (pool: ${this.pool.size}/${this.maxProcesses})` - ); - } - } - - /** - * リクエストを実行 - */ - async run(prompt: string, options?: RunOptions): Promise { - const channelId = options?.channelId ?? RunnerManager.DEFAULT_CHANNEL; - const entry = this.getOrCreateEntry(channelId); - // 外部からsessionIdが渡された場合は優先、なければ内部保持のIDを使用 - const sessionId = options?.sessionId ?? entry.sessionId; - if (sessionId) { - entry.runner.setSessionId(sessionId); - } - const result = await entry.runner.run(prompt, options); - entry.sessionId = result.sessionId; - return result; - } - - /** - * ストリーミング実行 - */ - async runStream( - prompt: string, - callbacks: StreamCallbacks, - options?: RunOptions - ): Promise { - const channelId = options?.channelId ?? RunnerManager.DEFAULT_CHANNEL; - const entry = this.getOrCreateEntry(channelId); - const sessionId = options?.sessionId ?? entry.sessionId; - if (sessionId) { - entry.runner.setSessionId(sessionId); - } - const result = await entry.runner.runStream(prompt, callbacks, options); - entry.sessionId = result.sessionId; - return result; - } - - /** - * 指定チャンネルのリクエストをキャンセル - * channelId なしの場合は全チャンネルを試す - */ - cancel(channelId?: string): boolean { - if (channelId) { - const entry = this.pool.get(channelId); - return entry?.runner.cancel() ?? false; - } - - // channelId 未指定: 全ランナーを試す - for (const entry of this.pool.values()) { - if (entry.runner.cancel()) return true; - } - return false; - } - - /** - * 指定チャンネルの処理中+キュー内の全リクエストをキャンセル - */ - cancelAll(channelId?: string): number { - if (channelId) { - const entry = this.pool.get(channelId); - return entry?.runner.cancelAll() ?? 0; - } - - // channelId 未指定: 全ランナーをキャンセル - let total = 0; - for (const entry of this.pool.values()) { - total += entry.runner.cancelAll(); - } - return total; - } - - /** - * 指定チャンネルのランナーを完全に破棄(/new用) - */ - destroy(channelId: string): boolean { - const entry = this.pool.get(channelId); - if (entry) { - entry.runner.shutdown(); - this.pool.delete(channelId); - logger.debug( - `Destroyed runner for channel ${channelId} (pool: ${this.pool.size}/${this.maxProcesses})` - ); - return true; - } - return false; - } - - /** - * 全ランナーをシャットダウン - */ - shutdown(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - - for (const [channelId, entry] of this.pool.entries()) { - logger.info(`Shutting down runner for channel ${channelId}`); - entry.runner.shutdown(); - } - this.pool.clear(); - logger.info('All runners shut down'); - } - - /** - * 指定チャンネルのセッションIDを取得 - */ - getSessionId(channelId: string): string | undefined { - return this.pool.get(channelId)?.sessionId; - } - - /** - * 指定チャンネルのセッションをクリア(新規セッション開始用) - */ - deleteSession(channelId: string): void { - const entry = this.pool.get(channelId); - if (entry) { - entry.sessionId = undefined; - } - } - - /** - * プール状態の取得(デバッグ・ステータス表示用) - */ - getStatus(): RunnerStatus { - const now = Date.now(); - const channels: RunnerStatus['channels'] = []; - - for (const [channelId, entry] of this.pool.entries()) { - channels.push({ - channelId, - idleSeconds: Math.round((now - entry.lastUsed) / 1000), - alive: entry.runner.isAlive(), - }); - } - - return { - poolSize: this.pool.size, - maxProcesses: this.maxProcesses, - channels, - }; - } -} diff --git a/src/agent/sdk-runner.ts b/src/agent/sdk-runner.ts new file mode 100644 index 0000000..4d5a00b --- /dev/null +++ b/src/agent/sdk-runner.ts @@ -0,0 +1,226 @@ +import { + type McpSdkServerConfigWithInstance, + query, + type SDKMessage, +} from '@anthropic-ai/claude-agent-sdk'; +import { + CANCELLED_ERROR_MESSAGE, + DEFAULT_TIMEOUT_MS, + SESSION_ID_DISPLAY_LENGTH, +} from '../lib/constants.js'; +import { createLogger } from '../lib/logger.js'; +import type { RunContext } from '../mcp/context.js'; +import type { AgentRunner, RunOptions, RunResult, StreamCallbacks } from './agent-runner.js'; +import { mergeTexts } from './agent-runner.js'; +import { buildSdkSystemPrompt } from './system-prompt.js'; + +const logger = createLogger('sdk-runner'); + +export interface RunnerOptions { + model?: string; + timeoutMs?: number; + workdir?: string; +} + +/** + * Agent SDK based runner. + * + * Each runStream() creates a new query() with AbortController. + * Session continuity via resume option. + */ +export class SdkRunner implements AgentRunner { + private model?: string; + private timeoutMs: number; + private workdir?: string; + private mcpServer: McpSdkServerConfigWithInstance; + private runContext: RunContext; + private sessionId = ''; + private resumeSessionId?: string; + private abortController: AbortController | null = null; + private systemPromptAppend: string; + + constructor( + options: RunnerOptions, + mcpServer: McpSdkServerConfigWithInstance, + runContext: RunContext + ) { + this.model = options.model; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.workdir = options.workdir; + this.mcpServer = mcpServer; + this.runContext = runContext; + this.systemPromptAppend = buildSdkSystemPrompt(this.workdir); + } + + async run(prompt: string, options?: RunOptions): Promise { + return this.runStream(prompt, {}, options); + } + + async runStream( + prompt: string, + callbacks: StreamCallbacks, + options?: RunOptions + ): Promise { + this.abortController = new AbortController(); + + // Set context for MCP tools + if (options?.channelId) { + this.runContext.set({ channelId: options.channelId, guildId: options.guildId }); + } + + const resumeId = options?.sessionId || this.resumeSessionId || this.sessionId; + + try { + const q = query({ + prompt, + options: { + abortController: this.abortController, + cwd: this.workdir, + model: this.model, + systemPrompt: { + type: 'preset', + preset: 'claude_code', + append: this.systemPromptAppend, + }, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: ['project'], + includePartialMessages: true, + mcpServers: { thor: this.mcpServer }, + ...(resumeId ? { resume: resumeId } : {}), + }, + }); + + const timeout = setTimeout(() => { + logger.warn(`Request timed out after ${this.timeoutMs}ms`); + this.abortController?.abort(); + }, this.timeoutMs); + + let fullText = ''; + let lastResult: RunResult | null = null; + + try { + for await (const message of q) { + fullText = this.processMessage(message, callbacks, fullText); + + // Capture result if this was a result message + if (message.type === 'result' && 'subtype' in message && message.subtype === 'success') { + lastResult = { result: fullText, sessionId: this.sessionId }; + } + } + } finally { + clearTimeout(timeout); + } + + // Return the last captured result, or construct from accumulated text + return lastResult ?? { result: fullText, sessionId: this.sessionId }; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + const cancelError = new Error(CANCELLED_ERROR_MESSAGE); + callbacks.onError?.(cancelError); + throw cancelError; + } + const error = err instanceof Error ? err : new Error(String(err)); + callbacks.onError?.(error); + throw error; + } finally { + this.abortController = null; + this.runContext.clear(); + } + } + + /** + * Process a single SDK message, returning updated fullText. + */ + private processMessage( + message: SDKMessage, + callbacks: StreamCallbacks, + fullText: string + ): string { + // system/init — capture session_id + if (message.type === 'system' && 'subtype' in message && message.subtype === 'init') { + this.sessionId = message.session_id; + logger.info(`Session: ${this.sessionId.slice(0, SESSION_ID_DISPLAY_LENGTH)}...`); + } + + // assistant — extract text/tool_use blocks + if (message.type === 'assistant' && 'message' in message) { + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && 'text' in block) { + fullText += block.text; + callbacks.onText?.(block.text, fullText); + } + if (block.type === 'tool_use' && 'name' in block) { + callbacks.onProgress?.(block.name, block.input); + } + } + } + } + + // stream_event — partial text for live streaming + if (message.type === 'stream_event' && 'event' in message) { + const event = message.event; + if (event && typeof event === 'object' && 'type' in event) { + if (event.type === 'content_block_delta' && 'delta' in event) { + const delta = event.delta as { type: string; text?: string }; + if (delta.type === 'text_delta' && delta.text) { + fullText += delta.text; + callbacks.onText?.(delta.text, fullText); + } + } + } + } + + // result — final result + if (message.type === 'result') { + if ('session_id' in message) { + this.sessionId = message.session_id; + } + + if ('subtype' in message && message.subtype === 'success' && 'result' in message) { + const resultText = (message as { result: string }).result; + fullText = mergeTexts(fullText, resultText); + callbacks.onComplete?.({ result: fullText, sessionId: this.sessionId }); + } else { + const errors = 'errors' in message ? (message as { errors: string[] }).errors : []; + const errorMsg = errors.join('; ') || 'Unknown error'; + callbacks.onError?.(new Error(errorMsg)); + } + } + + return fullText; + } + + cancel(): boolean { + if (!this.abortController) return false; + logger.info('Cancelling current request'); + this.abortController.abort(); + return true; + } + + shutdown(): void { + logger.info('Shutting down SDK runner'); + this.abortController?.abort(); + } + + getSessionId(): string { + return this.sessionId; + } + + setSessionId(sessionId: string): void { + this.resumeSessionId = sessionId; + if (!this.sessionId) { + this.sessionId = sessionId; + } + } + + isBusy(): boolean { + return this.abortController !== null; + } + + isAlive(): boolean { + return true; + } +} diff --git a/src/agent/base-runner.ts b/src/agent/system-prompt.ts similarity index 50% rename from src/agent/base-runner.ts rename to src/agent/system-prompt.ts index 825fbe3..cf43740 100644 --- a/src/agent/base-runner.ts +++ b/src/agent/system-prompt.ts @@ -3,44 +3,11 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createLogger } from '../lib/logger.js'; -const logger = createLogger('base-runner'); +const logger = createLogger('system-prompt'); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -/** - * ランナー共通の設定 - */ -export interface BaseRunnerOptions { - model?: string; - timeoutMs?: number; - workdir?: string; -} - -/** - * チャットプラットフォーム連携用のシステムプロンプト(resumeあり) - */ -export const CHAT_SYSTEM_PROMPT_RESUME = `あなたはチャットプラットフォーム(Discord)経由で会話しています。 - -## セッション継続 -このセッションは --resume オプションで継続されています。過去の会話履歴は保持されているので、直前の会話内容を覚えています。「再起動したから覚えていない」とは言わないでください。 - -## セッション開始時 -AGENTS.md を読み、指示に従うこと(AGENTS.md 等の参照含む)。 -thor専用コマンド(Discord操作・ファイル送信・スケジューラー・チャンネル一覧・タイムアウト対策)は以下を参照。`; - -/** - * チャットプラットフォーム連携用のシステムプロンプト(常駐プロセス用) - */ -export const CHAT_SYSTEM_PROMPT_PERSISTENT = `あなたはチャットプラットフォーム(Discord)経由で会話しています。 - -## セッション継続 -このセッションは常駐プロセスで実行されています。セッション内の会話履歴は保持されます。 - -## セッション開始時 -AGENTS.md を読み、指示に従うこと(AGENTS.md 等の参照含む)。 -thor専用コマンド(Discord操作・ファイル送信・スケジューラー・チャンネル一覧・タイムアウト対策)は以下を参照。`; - /** * マークダウンファイルをセクション形式で読み込む共通ヘルパー */ @@ -92,17 +59,18 @@ export function loadThorCommands(): string { } /** - * 完全なシステムプロンプトを生成(resume型ランナー用) + * SDK用のシステムプロンプト追加部分を生成 + * claude_code プリセットに append する内容 */ -export function buildSystemPrompt(workdir?: string): string { - return CHAT_SYSTEM_PROMPT_RESUME + loadUserMd(workdir) + loadSoulMd(workdir) + loadThorCommands(); -} +const CHAT_SYSTEM_PROMPT_SDK = `あなたはチャットプラットフォーム(Discord)経由で会話しています。 -/** - * 完全なシステムプロンプトを生成(常駐プロセス用) - */ -export function buildPersistentSystemPrompt(workdir?: string): string { - return ( - CHAT_SYSTEM_PROMPT_PERSISTENT + loadUserMd(workdir) + loadSoulMd(workdir) + loadThorCommands() - ); +## セッション継続 +SDKのresumeオプションでセッションが継続されています。過去の会話履歴は保持されています。 + +## セッション開始時 +AGENTS.md を読み、指示に従うこと(AGENTS.md 等の参照含む)。 +thor MCP ツール(discord_send, discord_channels, discord_history, discord_delete, schedule_create, schedule_list, schedule_remove, schedule_toggle)が利用可能です。`; + +export function buildSdkSystemPrompt(workdir?: string): string { + return CHAT_SYSTEM_PROMPT_SDK + loadUserMd(workdir) + loadSoulMd(workdir) + loadThorCommands(); } diff --git a/src/brain/brain.ts b/src/brain/brain.ts new file mode 100644 index 0000000..55e99d8 --- /dev/null +++ b/src/brain/brain.ts @@ -0,0 +1,256 @@ +import type { AgentRunner, RunOptions, RunResult, StreamCallbacks } from '../agent/agent-runner.js'; +import { createLogger } from '../lib/logger.js'; + +const logger = createLogger('brain'); + +/** + * Task priority levels: lower number = higher priority + */ +export enum Priority { + USER = 0, + EVENT = 1, + HEARTBEAT = 2, +} + +/** + * A task submitted to the Brain + */ +export interface BrainTask { + prompt: string; + priority: Priority; + callbacks?: StreamCallbacks; + options?: RunOptions; + /** Callback when the task completes (used for autonomous results) */ + onResult?: (result: RunResult) => void; + /** Callback when the task errors */ + onError?: (error: Error) => void; +} + +/** + * Runner capabilities required by Brain + */ +export interface BrainRunner extends AgentRunner { + cancel(): boolean; + shutdown(): void; + isBusy(): boolean; + isAlive(): boolean; + getSessionId(): string; + setSessionId(sessionId: string): void; +} + +export interface BrainStatus { + busy: boolean; + queueLength: number; + currentPriority: Priority | null; + alive: boolean; + sessionId: string; +} + +type QueueEntry = BrainTask & { + resolve: (r: RunResult) => void; + reject: (e: Error) => void; +}; + +/** + * Brain: single consciousness for all modes (reactive, autonomous, event-driven) + * + * Wraps a single runner with a priority queue. + * USER messages preempt HEARTBEAT tasks (cancel-on-preempt). + */ +export class Brain implements AgentRunner { + private runner: BrainRunner; + private queue: QueueEntry[] = []; + private currentTask: QueueEntry | null = null; + private lastActivityTime = Date.now(); + + constructor(runner: BrainRunner) { + this.runner = runner; + logger.info('Brain initialized'); + } + + /** + * Submit a task to the brain with priority + */ + submit(task: BrainTask): Promise { + return new Promise((resolve, reject) => { + const entry = { ...task, resolve, reject }; + + // If a higher-priority task arrives while a lower-priority task is running, cancel current + if (this.currentTask && task.priority < this.currentTask.priority) { + logger.info( + `Preempting priority ${this.currentTask.priority} with priority ${task.priority}` + ); + this.runner.cancel(); + // The cancelled task's reject will be called by PersistentRunner + } + + // Insert into queue maintaining priority order (stable: same priority preserves FIFO) + const insertIdx = this.queue.findIndex((q) => q.priority > task.priority); + if (insertIdx === -1) { + this.queue.push(entry); + } else { + this.queue.splice(insertIdx, 0, entry); + } + + this.processNext(); + }); + } + + /** + * AgentRunner interface: run (defaults to USER priority) + */ + async run(prompt: string, options?: RunOptions): Promise { + return this.submit({ + prompt, + priority: Priority.USER, + options, + }); + } + + /** + * AgentRunner interface: runStream (defaults to USER priority) + */ + async runStream( + prompt: string, + callbacks: StreamCallbacks, + options?: RunOptions + ): Promise { + this.lastActivityTime = Date.now(); + return this.submit({ + prompt, + priority: Priority.USER, + callbacks, + options, + }); + } + + /** + * Cancel current request + */ + cancel(): boolean { + return this.runner.cancel(); + } + + /** + * Cancel all requests (current + queued) + */ + cancelAll(): number { + let cancelled = 0; + const error = new Error('Request cancelled by user'); + for (const item of this.queue) { + item.callbacks?.onError?.(error); + item.reject(error); + cancelled++; + } + this.queue = []; + if (this.runner.cancel()) cancelled++; + return cancelled; + } + + /** + * Shutdown the brain + */ + shutdown(): void { + logger.info('Shutting down brain...'); + const error = new Error('Brain is shutting down'); + for (const item of this.queue) { + item.callbacks?.onError?.(error); + item.reject(error); + } + this.queue = []; + this.currentTask = null; + this.runner.shutdown(); + } + + /** + * Whether the brain is currently processing a task + */ + isBusy(): boolean { + return this.runner.isBusy(); + } + + /** + * Time since last user activity in milliseconds + */ + getIdleTime(): number { + return Date.now() - this.lastActivityTime; + } + + /** + * Get the current session ID + */ + getSessionId(): string { + return this.runner.getSessionId(); + } + + /** + * Set the session ID for resume + */ + setSessionId(sessionId: string): void { + this.runner.setSessionId(sessionId); + } + + /** + * Get brain status + */ + getStatus(): BrainStatus { + return { + busy: this.runner.isBusy(), + queueLength: this.queue.length, + currentPriority: this.currentTask?.priority ?? null, + alive: this.runner.isAlive(), + sessionId: this.runner.getSessionId(), + }; + } + + /** + * Process the next task in the priority queue + */ + private processNext(): void { + if (this.currentTask || this.queue.length === 0) { + return; + } + + const task = this.queue.shift(); + if (!task) return; + + this.currentTask = task; + + if (task.priority === Priority.USER) { + this.lastActivityTime = Date.now(); + } + + logger.debug(`Processing task (priority: ${task.priority}, queue: ${this.queue.length})`); + + // Wrap callbacks to intercept completion + const wrappedCallbacks: StreamCallbacks = { + onText: task.callbacks?.onText, + onProgress: task.callbacks?.onProgress, + onComplete: (result) => { + task.callbacks?.onComplete?.(result); + task.onResult?.(result); + task.resolve(result); + this.currentTask = null; + this.processNext(); + }, + onError: (error) => { + task.callbacks?.onError?.(error); + task.onError?.(error); + task.reject(error); + this.currentTask = null; + this.processNext(); + }, + }; + + // Use sessionId from options if provided + if (task.options?.sessionId) { + this.runner.setSessionId(task.options.sessionId); + } + + this.runner.runStream(task.prompt, wrappedCallbacks, task.options).catch((error) => { + // runStream rejection is already handled by wrappedCallbacks.onError + // but we need a catch to prevent unhandled rejection + logger.debug(`Task rejected: ${error.message}`); + }); + } +} diff --git a/src/brain/heartbeat.ts b/src/brain/heartbeat.ts new file mode 100644 index 0000000..f10ec27 --- /dev/null +++ b/src/brain/heartbeat.ts @@ -0,0 +1,136 @@ +import { toErrorMessage } from '../lib/error-utils.js'; +import { createLogger } from '../lib/logger.js'; +import { type Brain, Priority } from './brain.js'; + +const logger = createLogger('heartbeat'); + +export interface HeartbeatOptions { + /** Minimum interval between heartbeats in ms */ + minIntervalMs: number; + /** Maximum interval between heartbeats in ms */ + maxIntervalMs: number; + /** Skip heartbeat if user was active within this time in ms */ + idleThresholdMs: number; + /** Channel ID to send autonomous messages to */ + channelId: string; +} + +const HEARTBEAT_PROMPT = + 'Read HEARTBEAT.md and follow the checklist. If there is nothing to do, respond with only HEARTBEAT_OK.'; + +/** Check if a result is a suppressed heartbeat-ok response */ +export function isHeartbeatOk(text: string): boolean { + return text.trim().toUpperCase().startsWith('HEARTBEAT_OK'); +} + +/** + * Heartbeat: periodic autonomous tick with jitter. + * + * Fires at random intervals between minIntervalMs and maxIntervalMs. + * If the Brain is busy or user was recently active, skip silently. + * Responses containing only "HEARTBEAT_OK" are suppressed (not sent to Discord). + */ +export class Heartbeat { + private timer: ReturnType | null = null; + private running = false; + private brain: Brain; + private options: HeartbeatOptions; + private onResult?: (result: string, channelId: string) => void; + + constructor(brain: Brain, options: HeartbeatOptions) { + this.brain = brain; + this.options = options; + } + + /** + * Register callback for heartbeat results that should be sent to Discord + */ + setResultHandler(handler: (result: string, channelId: string) => void): void { + this.onResult = handler; + } + + /** + * Start the heartbeat loop + */ + start(): void { + if (this.running) return; + this.running = true; + logger.info( + `Heartbeat started (interval: ${this.options.minIntervalMs / 1000}s-${this.options.maxIntervalMs / 1000}s)` + ); + this.scheduleNext(); + } + + /** + * Stop the heartbeat loop + */ + stop(): void { + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + logger.info('Heartbeat stopped'); + } + + /** + * Schedule the next heartbeat tick with random jitter + */ + private scheduleNext(): void { + if (!this.running) return; + + const { minIntervalMs, maxIntervalMs } = this.options; + const delay = minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs); + + logger.debug(`Next heartbeat in ${Math.round(delay / 1000)}s`); + + this.timer = setTimeout(() => { + this.tick(); + }, delay); + } + + /** + * Execute a heartbeat tick + */ + private async tick(): Promise { + if (!this.running) return; + + // Skip if brain is busy + if (this.brain.isBusy()) { + logger.debug('Skipping heartbeat: brain is busy'); + this.scheduleNext(); + return; + } + + // Skip if user was recently active + const idleTime = this.brain.getIdleTime(); + if (idleTime < this.options.idleThresholdMs) { + logger.debug(`Skipping heartbeat: user active ${Math.round(idleTime / 1000)}s ago`); + this.scheduleNext(); + return; + } + + logger.info('Heartbeat tick'); + + try { + const result = await this.brain.submit({ + prompt: HEARTBEAT_PROMPT, + priority: Priority.HEARTBEAT, + options: { channelId: this.options.channelId }, + }); + + if (isHeartbeatOk(result.result)) { + logger.debug('Heartbeat result: HEARTBEAT_OK (suppressed)'); + } else { + logger.info('Heartbeat produced output, forwarding to handler'); + this.onResult?.(result.result, this.options.channelId); + } + } catch (error) { + // Cancelled by higher-priority task or other error - that's fine + const message = toErrorMessage(error); + logger.debug(`Heartbeat task ended: ${message}`); + } finally { + this.scheduleNext(); + } + } +} diff --git a/src/brain/triggers.ts b/src/brain/triggers.ts new file mode 100644 index 0000000..eb551ce --- /dev/null +++ b/src/brain/triggers.ts @@ -0,0 +1,119 @@ +import cron from 'node-cron'; +import { TIMEZONE } from '../lib/constants.js'; +import { toErrorMessage } from '../lib/error-utils.js'; +import { createLogger } from '../lib/logger.js'; +import { type Brain, Priority } from './brain.js'; +import { isHeartbeatOk } from './heartbeat.js'; + +const logger = createLogger('triggers'); + +export interface TriggerConfig { + /** Channel ID to send trigger messages to */ + channelId: string; + /** Hour (0-23) for morning trigger */ + morningHour: number; + /** Hour (0-23) for evening trigger */ + eveningHour: number; + /** Day of week (0=Sun, 1=Mon, ..., 6=Sat) for weekly reflection */ + weeklyDay: number; +} + +/** + * TriggerManager: time-based event triggers using node-cron. + * + * Fires at specific times and submits skill prompts to the Brain + * with EVENT priority. + */ +export class TriggerManager { + private brain: Brain; + private config: TriggerConfig; + private tasks: cron.ScheduledTask[] = []; + private onResult?: (result: string, channelId: string) => void; + + constructor(brain: Brain, config: TriggerConfig) { + this.brain = brain; + this.config = config; + } + + /** + * Register callback for trigger results + */ + setResultHandler(handler: (result: string, channelId: string) => void): void { + this.onResult = handler; + } + + /** + * Start all triggers + */ + start(): void { + const { morningHour, eveningHour, weeklyDay, channelId } = this.config; + + // Morning trigger + const morningTask = cron.schedule( + `0 ${morningHour} * * *`, + () => { + this.fire('Use /morning to send a morning greeting.', channelId); + }, + { timezone: TIMEZONE } + ); + this.tasks.push(morningTask); + logger.info(`Morning trigger set at ${morningHour}:00 (${TIMEZONE})`); + + // Evening trigger + const eveningTask = cron.schedule( + `0 ${eveningHour} * * *`, + () => { + this.fire('Use /evening to send an evening review.', channelId); + }, + { timezone: TIMEZONE } + ); + this.tasks.push(eveningTask); + logger.info(`Evening trigger set at ${eveningHour}:00 (${TIMEZONE})`); + + // Weekly reflection trigger (offset by 5 minutes to avoid collision with evening trigger) + const weeklyTask = cron.schedule( + `5 ${eveningHour} * * ${weeklyDay}`, + () => { + this.fire('Use /reflect to write a weekly reflection.', channelId); + }, + { timezone: TIMEZONE } + ); + this.tasks.push(weeklyTask); + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + logger.info(`Weekly reflection trigger set for ${dayNames[weeklyDay]} at ${eveningHour}:00`); + } + + /** + * Stop all triggers + */ + stop(): void { + for (const task of this.tasks) { + task.stop(); + } + this.tasks = []; + logger.info('All triggers stopped'); + } + + /** + * Fire a trigger: submit prompt to Brain with EVENT priority + */ + private async fire(prompt: string, channelId: string): Promise { + logger.info(`Trigger firing: ${prompt}`); + + try { + const result = await this.brain.submit({ + prompt, + priority: Priority.EVENT, + options: { channelId }, + }); + + // Forward non-empty results (suppress heartbeat-ok responses) + if (result.result.trim() && !isHeartbeatOk(result.result)) { + this.onResult?.(result.result, channelId); + } + } catch (error) { + const message = toErrorMessage(error); + logger.warn(`Trigger task failed: ${message}`); + } + } +} diff --git a/src/discord/message-handler.ts b/src/discord/agent-response.ts similarity index 50% rename from src/discord/message-handler.ts rename to src/discord/agent-response.ts index 83fedca..8c7ce53 100644 --- a/src/discord/message-handler.ts +++ b/src/discord/agent-response.ts @@ -1,24 +1,20 @@ import type { Message } from 'discord.js'; -import type { AgentRunner } from '../agent/agent-runner.js'; +import type { Brain } from '../brain/brain.js'; import type { Config } from '../lib/config.js'; import { CANCELLED_ERROR_MESSAGE, - CIRCUIT_BREAKER_PREFIX, DISCORD_MAX_LENGTH, DISCORD_SAFE_LENGTH, STREAM_UPDATE_INTERVAL_MS, TYPING_INTERVAL_MS, } from '../lib/constants.js'; import { formatErrorDetail, toErrorMessage } from '../lib/error-utils.js'; -import { executeCommandsWithFeedback } from '../lib/feedback-loop.js'; import { extractFilePaths, stripFilePaths } from '../lib/file-utils.js'; import { createLogger } from '../lib/logger.js'; import { splitMessage } from '../lib/message-utils.js'; -import { parseAgentResponse } from '../lib/response-parser.js'; -import { handleSettingsFromResponse } from '../lib/system-commands.js'; -import type { Scheduler } from '../scheduler/scheduler.js'; -import { isSendableChannel } from './discord-types.js'; +import { handleSystemCommand } from '../lib/system-commands.js'; +import { isSendableChannel } from './channel-utils.js'; const logger = createLogger('thor'); @@ -27,28 +23,28 @@ const logger = createLogger('thor'); */ function formatProgressLine(toolName: string, toolInput: unknown): string { const input = (toolInput ?? {}) as Record; - const truncate = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s); + const truncate = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}...` : s); switch (toolName) { case 'Bash': - return `🔧 Bash: \`${truncate(String(input.command ?? ''), 80)}\``; + return `Bash: \`${truncate(String(input.command ?? ''), 80)}\``; case 'Read': - return `📖 Read: \`${input.file_path ?? ''}\``; + return `Read: \`${input.file_path ?? ''}\``; case 'Write': - return `✏️ Write: \`${input.file_path ?? ''}\``; + return `Write: \`${input.file_path ?? ''}\``; case 'Edit': - return `✏️ Edit: \`${input.file_path ?? ''}\``; + return `Edit: \`${input.file_path ?? ''}\``; case 'Grep': - return `🔍 Grep: \`${input.pattern ?? ''}\``; + return `Grep: \`${input.pattern ?? ''}\``; case 'Glob': - return `🔍 Glob: \`${input.pattern ?? ''}\``; + return `Glob: \`${input.pattern ?? ''}\``; case 'WebFetch': - return `🌐 Fetch: \`${truncate(String(input.url ?? ''), 80)}\``; + return `Fetch: \`${truncate(String(input.url ?? ''), 80)}\``; case 'WebSearch': - return `🔍 Search: \`${input.query ?? ''}\``; + return `Search: \`${input.query ?? ''}\``; case 'Agent': - return `🤖 Agent: ${truncate(String(input.description ?? ''), 60)}`; + return `Agent: ${truncate(String(input.description ?? ''), 60)}`; default: - return `🔧 ${toolName}`; + return toolName; } } @@ -76,10 +72,9 @@ async function sendResultToDiscord( ): Promise { const filePaths = workdir ? extractFilePaths(result, workdir) : []; const displayText = filePaths.length > 0 ? stripFilePaths(result) : result; - const cleanText = parseAgentResponse(displayText).displayText; - const chunks = splitMessage(cleanText, DISCORD_SAFE_LENGTH); - await (message as { edit: (content: string) => Promise }).edit(chunks[0] || '✅'); + const chunks = splitMessage(displayText, DISCORD_SAFE_LENGTH); + await (message as { edit: (content: string) => Promise }).edit(chunks[0] || '(done)'); if (chunks.length > 1 && channel && isSendableChannel(channel)) { for (let i = 1; i < chunks.length; i++) { @@ -104,7 +99,7 @@ async function sendResultToDiscord( */ export async function processPrompt( message: Message, - agentRunner: AgentRunner, + brain: Brain, prompt: string, channelId: string, config: Config @@ -115,7 +110,7 @@ export async function processPrompt( const channelName = 'name' in message.channel ? (message.channel as { name: string }).name : null; if (channelName) { - prompt = `[チャンネル: #${channelName} (ID: ${channelId})]\n${prompt}`; + prompt = `[Channel: #${channelName} (ID: ${channelId})]\n${prompt}`; } logger.info(`Processing message in channel ${channelId}`); @@ -135,11 +130,11 @@ export async function processPrompt( const buildStreamingContent = (fullText: string, progress: string) => { const base = fullText || progress; - if (!base) return '⏳'; + if (!base) return '...'; if (fullText && progress) { - return `${fullText}\n\n${progress} ▌`.slice(0, DISCORD_MAX_LENGTH); + return `${fullText}\n\n${progress}`.slice(0, DISCORD_MAX_LENGTH); } - return `${base} ▌`.slice(0, DISCORD_MAX_LENGTH); + return base.slice(0, DISCORD_MAX_LENGTH); }; const throttledEdit = (content: string) => { @@ -159,67 +154,51 @@ export async function processPrompt( } }; + /** Create the initial reply if not yet created */ + const ensureReply = (content: string) => { + if (replyMessage || replyPromise) return false; + typing.stop(); + const promise = message.reply(content); + replyPromise = promise; + promise + .then((msg) => { + replyMessage = msg; + lastUpdateTime = Date.now(); + }) + .catch((err) => { + logger.warn('Failed to create reply:', err.message); + }); + return true; + }; + try { - const streamResult = await agentRunner.runStream( + const streamResult = await brain.runStream( prompt, { onText: (_chunk, fullText) => { streamedText = fullText; - progressLine = ''; // tool_useは一段落したとみなしてクリア + progressLine = ''; const content = buildStreamingContent(fullText, progressLine); - - // 初回テキスト到着時にreplyメッセージを作成 - if (!replyMessage && !replyPromise) { - typing.stop(); - const promise = message.reply(content); - replyPromise = promise; - promise - .then((msg) => { - replyMessage = msg; - lastUpdateTime = Date.now(); - }) - .catch((err) => { - logger.warn('Failed to create reply:', err.message); - }); - return; - } - if (!replyMessage) return; // reply作成中 - + if (ensureReply(content)) return; + if (!replyMessage) return; throttledEdit(content); }, onProgress: (toolName, toolInput) => { progressLine = formatProgressLine(toolName, toolInput); logger.debug(`Tool: ${progressLine}`); - - // replyがまだなければ作成 - if (!replyMessage && !replyPromise) { - typing.stop(); - const content = buildStreamingContent('', progressLine); - const promise = message.reply(content); - replyPromise = promise; - promise - .then((msg) => { - replyMessage = msg; - lastUpdateTime = Date.now(); - }) - .catch((err) => { - logger.warn('Failed to create reply:', err.message); - }); - return; - } + const content = buildStreamingContent(streamedText, progressLine); + if (ensureReply(content)) return; if (!replyMessage) return; - - throttledEdit(buildStreamingContent(streamedText, progressLine)); + throttledEdit(content); }, }, - { channelId } + { channelId, guildId: message.guildId ?? undefined } ); result = streamResult.result; } finally { typing.stop(); } - // replyが作成中ならその完了を待つ(レースコンディション防止) if (!replyMessage && replyPromise) { try { replyMessage = await replyPromise; @@ -227,9 +206,8 @@ export async function processPrompt( logger.warn('Failed to await pending reply:', (err as Error).message); } } - // テキストが一度も来なかった場合(空応答)のフォールバック if (!replyMessage) { - replyMessage = await message.reply('✅'); + replyMessage = await message.reply('(done)'); } logger.info(`Response length: ${result.length}`); @@ -241,14 +219,13 @@ export async function processPrompt( config.agent.workdir ); - // AIの応答から SYSTEM_COMMAND: を検知して実行 - handleSettingsFromResponse(result); + handleSystemCommand(result); return result; } catch (error) { if (error instanceof Error && error.message === CANCELLED_ERROR_MESSAGE) { logger.info('Request cancelled by user'); - await replyMessage?.edit('🛑 停止しました').catch((e) => { + await replyMessage?.edit('Stopped').catch((e) => { logger.warn('Failed to edit cancel message:', e.message); }); return null; @@ -256,7 +233,7 @@ export async function processPrompt( logger.error('Error:', error); const errorMsg = toErrorMessage(error); - const timeoutLabel = `${Math.round((config.agent.timeoutMs ?? 300000) / 1000)}秒`; + const timeoutLabel = `${Math.round((config.agent.timeoutMs ?? 300000) / 1000)}s`; const errorDetail = formatErrorDetail(errorMsg, { timeoutLabel }); if (replyMessage) { @@ -268,33 +245,29 @@ export async function processPrompt( logger.warn('Failed to reply error message:', e.message); }); } - - // エラー後にエージェントへ自動フォローアップ(サーキットブレーカー時は除く) - if (!errorMsg.includes(CIRCUIT_BREAKER_PREFIX)) { - try { - logger.info('Sending error follow-up to agent'); - const hasSession = agentRunner.getSessionId?.(channelId); - if (hasSession) { - const followUpPrompt = - '先ほどの処理がエラー(タイムアウト等)で中断されました。途中まで行った作業内容と現在の状況を簡潔に報告してください。'; - const followUpResult = await agentRunner.run(followUpPrompt, { - channelId, - }); - if (followUpResult.result) { - const followUpText = followUpResult.result.slice(0, DISCORD_SAFE_LENGTH); - if (isSendableChannel(message.channel)) { - await message.channel.send(`📋 **エラー前の作業報告:**\n${followUpText}`); - } + try { + logger.info('Sending error follow-up to agent'); + const sessionId = brain.getSessionId(); + if (sessionId) { + const followUpPrompt = + 'The previous request was interrupted by an error (timeout etc). Please briefly report what work was done and the current state.'; + const followUpResult = await brain.run(followUpPrompt, { + channelId, + guildId: message.guildId ?? undefined, + }); + if (followUpResult.result) { + const followUpText = followUpResult.result.slice(0, DISCORD_SAFE_LENGTH); + if (isSendableChannel(message.channel)) { + await message.channel.send(`**Error report:**\n${followUpText}`); } } - } catch (followUpError) { - logger.error('Error follow-up failed:', followUpError); } + } catch (followUpError) { + logger.error('Error follow-up failed:', followUpError); } return null; } finally { - // 👀 リアクションを削除 await message.reactions.cache .find((r) => r.emoji.name === '👀') ?.users.remove(message.client.user?.id) @@ -303,53 +276,3 @@ export async function processPrompt( }); } } - -/** - * AI応答内の !discord / !schedule コマンドを処理し、フィードバック結果をエージェントに再注入する - */ -export async function handleResponseFeedback( - result: string, - message: Message, - agentRunner: AgentRunner, - channelId: string, - config: Config, - client: import('discord.js').Client, - scheduler: Scheduler -): Promise { - await executeCommandsWithFeedback(result, client, scheduler, { - sourceMessage: message, - runAgent: async (prompt) => { - const feedbackResult = await processPrompt(message, agentRunner, prompt, channelId, config); - return feedbackResult ?? ''; - }, - }); -} - -/** - * スキルコマンドを実行する(/skill と 個別スキルコマンドの共通処理) - */ -export async function executeSkillCommand( - interaction: import('discord.js').ChatInputCommandInteraction, - agentRunner: AgentRunner, - channelId: string, - skillName: string, - args: string -): Promise { - await interaction.deferReply(); - - try { - const prompt = `スキル「${skillName}」を実行してください。${args ? `引数: ${args}` : ''}`; - const { result } = await agentRunner.run(prompt, { - channelId, - }); - - const chunks = splitMessage(result, DISCORD_SAFE_LENGTH); - await interaction.editReply(chunks[0] || '✅'); - for (let i = 1; i < chunks.length; i++) { - await interaction.followUp(chunks[i]); - } - } catch (error) { - logger.error('Error:', error); - await interaction.editReply('エラーが発生しました'); - } -} diff --git a/src/discord/discord-types.ts b/src/discord/channel-utils.ts similarity index 100% rename from src/discord/discord-types.ts rename to src/discord/channel-utils.ts diff --git a/src/discord/discord-client.ts b/src/discord/discord-client.ts index 10ec357..2ac6188 100644 --- a/src/discord/discord-client.ts +++ b/src/discord/discord-client.ts @@ -1,32 +1,32 @@ import { Client, Events, GatewayIntentBits, type Message, REST, Routes } from 'discord.js'; -import type { AgentRunner } from '../agent/agent-runner.js'; +import type { Brain } from '../brain/brain.js'; import type { Config } from '../lib/config.js'; import { MAX_QUEUE_PER_CHANNEL } from '../lib/constants.js'; import { buildPromptWithAttachments, downloadFile } from '../lib/file-utils.js'; import { createLogger } from '../lib/logger.js'; -import { loadSkills, type Skill } from '../lib/skills.js'; -import { handleScheduleMessage } from '../scheduler/schedule-handler.js'; import type { Scheduler } from '../scheduler/scheduler.js'; -import { handleDiscordCommand } from './discord-commands.js'; +import { processPrompt } from './agent-response.js'; import { annotateChannelMentions, fetchChannelMessages, fetchDiscordLinkContent, fetchReplyContent, } from './message-enrichment.js'; -import { handleResponseFeedback, processPrompt } from './message-handler.js'; -import { - buildSlashCommands, - formatChannelStatus, - handleAutocomplete, - handleSlashCommand, -} from './slash-commands.js'; +import { buildSlashCommands, formatChannelStatus, handleSlashCommand } from './slash-commands.js'; const logger = createLogger('discord'); +/** Strip Discord mentions and normalize whitespace */ +function stripMentions(content: string): string { + return content + .replace(/<@[!&]?\d+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + interface DiscordClientDeps { config: Config; - agentRunner: AgentRunner; + getBrain: () => Brain; scheduler: Scheduler; } @@ -34,8 +34,7 @@ interface DiscordClientDeps { * Discord クライアントをセットアップして返す */ export function setupDiscordClient(deps: DiscordClientDeps): Client { - const { config, agentRunner, scheduler } = deps; - const workdir = config.agent.workdir; + const { config, getBrain, scheduler } = deps; const client = new Client({ intents: [ @@ -45,13 +44,10 @@ export function setupDiscordClient(deps: DiscordClientDeps): Client { ], }); - let skills: Skill[] = loadSkills(workdir); - logger.info(`Loaded ${skills.length} skills from ${workdir}`); - const processingChannels = new Map(); // スラッシュコマンド定義 - const commands = buildSlashCommands(skills); + const commands = buildSlashCommands(); // ClientReady イベント client.once(Events.ClientReady, async (c) => { @@ -71,27 +67,16 @@ export function setupDiscordClient(deps: DiscordClientDeps): Client { // InteractionCreate イベント client.on(Events.InteractionCreate, async (interaction) => { - if (interaction.isAutocomplete()) { - await handleAutocomplete(interaction, skills); - return; - } - if (!interaction.isChatInputCommand()) return; if (!config.discord.allowedUsers?.includes(interaction.user.id)) return; const channelId = interaction.channelId; await handleSlashCommand(interaction, channelId, { - agentRunner, + brain: getBrain(), scheduler, config, - skills, processingChannels, - workdir, - reloadSkills: () => { - skills = loadSkills(workdir); - return skills; - }, }); }); @@ -112,11 +97,7 @@ export function setupDiscordClient(deps: DiscordClientDeps): Client { if (!isMentioned && !isDM && !isAutoReplyChannel) return; - const normalizedMessage = message.content - .replace(/<@[!&]?\d+>/g, '') - .replace(/\s+/g, ' ') - .trim() - .toLowerCase(); + const normalizedMessage = stripMentions(message.content).toLowerCase(); const isControlCommand = ['!stop', 'stop', '/stop', '!status', 'status', '/status'].includes( normalizedMessage ); @@ -124,9 +105,7 @@ export function setupDiscordClient(deps: DiscordClientDeps): Client { // キュー上限チェック(メンション・制御コマンドは除く) const currentCount = processingChannels.get(message.channel.id) ?? 0; if (!isMentioned && currentCount >= MAX_QUEUE_PER_CHANNEL && !isControlCommand) { - await message.reply( - `📥 キューが一杯です(${currentCount}件処理中)。完了までお待ちください。` - ); + await message.reply(`Queue full (${currentCount} tasks processing). Please wait.`); return; } @@ -135,9 +114,8 @@ export function setupDiscordClient(deps: DiscordClientDeps): Client { return; } - await handleMessage(message, client, { - agentRunner, - scheduler, + await routeMessage(message, client, { + getBrain, config, processingChannels, }); @@ -149,59 +127,33 @@ export function setupDiscordClient(deps: DiscordClientDeps): Client { // ─── Message handler ─── interface MessageDeps { - agentRunner: AgentRunner; - scheduler: Scheduler; + getBrain: () => Brain; config: Config; processingChannels: Map; } -async function handleMessage(message: Message, client: Client, deps: MessageDeps): Promise { - const { agentRunner, scheduler, config, processingChannels } = deps; +async function routeMessage(message: Message, client: Client, deps: MessageDeps): Promise { + const { getBrain, config, processingChannels } = deps; + const brain = getBrain(); - let prompt = message.content - .replace(/<@[!&]?\d+>/g, '') - .replace(/\s+/g, ' ') - .trim(); + let prompt = stripMentions(message.content); const normalizedPrompt = prompt.toLowerCase(); // 停止コマンド if (['!stop', 'stop', '/stop'].includes(normalizedPrompt)) { - const cancelledCount = agentRunner.cancelAll?.(message.channel.id) ?? 0; + const cancelledCount = brain.cancelAll(); processingChannels.delete(message.channel.id); if (cancelledCount > 0) { - await message.reply( - `🛑 タスクを停止しました${cancelledCount > 1 ? `(${cancelledCount}件キャンセル)` : ''}` - ); + await message.reply(`Stopped${cancelledCount > 1 ? ` (${cancelledCount} cancelled)` : ''}`); } else { - await message.reply('実行中のタスクはありません'); + await message.reply('No tasks running'); } return; } // 状態確認コマンド if (['!status', 'status', '/status'].includes(normalizedPrompt)) { - await message.reply(formatChannelStatus(message.channel.id, processingChannels, agentRunner)); - return; - } - - // !discord コマンドの処理 - if (prompt.startsWith('!discord')) { - const result = await handleDiscordCommand(prompt, client, message); - if (result.handled) { - if (result.feedback && result.response) { - prompt = `ユーザーが「${prompt}」を実行しました。以下がその結果です。この情報を踏まえてユーザーに返答してください。\n\n${result.response}`; - } else { - if (result.response) { - await message.reply(result.response); - } - return; - } - } - } - - // !schedule コマンドの処理 - if (prompt.startsWith('!schedule')) { - await handleScheduleMessage(message, prompt, scheduler, undefined); + await message.reply(formatChannelStatus(message.channel.id, processingChannels, brain)); return; } @@ -220,16 +172,15 @@ async function handleMessage(message: Message, client: Client, deps: MessageDeps // チャンネルメンションから最新メッセージを取得 prompt = await fetchChannelMessages(prompt, client); - // 添付ファイルをダウンロード + // 添付ファイルを並列ダウンロード const attachmentPaths: string[] = []; if (message.attachments.size > 0) { - for (const [, attachment] of message.attachments) { - try { - const filePath = await downloadFile(attachment.url, attachment.name || 'file'); - attachmentPaths.push(filePath); - } catch (err) { - logger.error(`Failed to download attachment: ${attachment.name}`, err); - } + const results = await Promise.allSettled( + [...message.attachments.values()].map((a) => downloadFile(a.url, a.name || 'file')) + ); + for (const r of results) { + if (r.status === 'fulfilled') attachmentPaths.push(r.value); + else logger.error('Failed to download attachment:', r.reason); } } @@ -237,23 +188,15 @@ async function handleMessage(message: Message, client: Client, deps: MessageDeps if (!prompt && attachmentPaths.length === 0) return; // 添付ファイル情報をプロンプトに追加 - prompt = buildPromptWithAttachments(prompt || '添付ファイルを確認してください', attachmentPaths); + prompt = buildPromptWithAttachments( + prompt || 'Please check the attached file(s)', + attachmentPaths + ); const channelId = message.channel.id; processingChannels.set(channelId, (processingChannels.get(channelId) ?? 0) + 1); try { - const result = await processPrompt(message, agentRunner, prompt, channelId, config); - if (result) { - await handleResponseFeedback( - result, - message, - agentRunner, - channelId, - config, - client, - scheduler - ); - } + await processPrompt(message, brain, prompt, channelId, config); } finally { const remaining = (processingChannels.get(channelId) ?? 1) - 1; if (remaining <= 0) processingChannels.delete(channelId); @@ -261,5 +204,4 @@ async function handleMessage(message: Message, client: Client, deps: MessageDeps } } -// Re-export registerSchedulerHandlers from scheduler-bridge -export { registerSchedulerHandlers } from '../scheduler/scheduler-bridge.js'; +export { registerSchedulerHandlers } from '../scheduler/scheduler-discord.js'; diff --git a/src/discord/discord-commands.ts b/src/discord/discord-commands.ts deleted file mode 100644 index f430fcf..0000000 --- a/src/discord/discord-commands.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { Client, Message } from 'discord.js'; -import { - ERROR_TRUNCATE_LENGTH, - HISTORY_DEFAULT_COUNT, - HISTORY_MAX_COUNT, - TIMEZONE, -} from '../lib/constants.js'; -import { createLogger } from '../lib/logger.js'; -import { chunkDiscordMessage } from '../lib/message-utils.js'; -import { parseAgentResponse } from '../lib/response-parser.js'; -import { executeScheduleFromResponse } from '../scheduler/schedule-handler.js'; -import type { Scheduler } from '../scheduler/scheduler.js'; -import { isSendableChannel } from './discord-types.js'; - -const logger = createLogger('discord'); - -/** - * Discordコマンドを処理する関数 - * feedback: true の場合、response をDiscordに送信せずエージェントに再注入する - */ -export async function handleDiscordCommand( - text: string, - client: Client, - sourceMessage?: Message, - fallbackChannelId?: string -): Promise<{ handled: boolean; response?: string; feedback?: boolean }> { - // !discord send <#channelId> message (複数行対応) - const sendMatch = text.match(/^!discord\s+send\s+<#(\d+)>\s+(.+)$/s); - if (sendMatch) { - const [, channelId, content] = sendMatch; - try { - const channel = await client.channels.fetch(channelId); - if (isSendableChannel(channel)) { - // ギルド検証: 送信先チャンネルがソースと同じギルドに属するか確認 - const sourceGuildId = - sourceMessage?.guildId ?? - (fallbackChannelId - ? ((await client.channels.fetch(fallbackChannelId)) as { guildId?: string })?.guildId - : undefined); - const targetGuildId = - 'guildId' in channel ? (channel as { guildId?: string }).guildId : undefined; - if (sourceGuildId && targetGuildId && sourceGuildId !== targetGuildId) { - logger.warn(`Cross-guild send blocked: source=${sourceGuildId}, target=${targetGuildId}`); - return { handled: true, response: '❌ 異なるサーバーのチャンネルには送信できません' }; - } - - const chunks = chunkDiscordMessage(content); - for (const chunk of chunks) { - await channel.send({ - content: chunk, - allowedMentions: { parse: [] }, - }); - } - const channelName = 'name' in channel ? channel.name : 'unknown'; - logger.info(`Sent message to #${channelName} (${chunks.length} chunk(s))`); - return { handled: true, response: `✅ #${channelName} にメッセージを送信しました` }; - } - } catch (err) { - logger.error(`Failed to send message to channel: ${channelId}`, err); - return { handled: true, response: '❌ チャンネルへの送信に失敗しました' }; - } - } - - // !discord channels - if (text.match(/^!discord\s+channels$/)) { - if (!sourceMessage) { - return { - handled: true, - response: '⚠️ channels コマンドはスケジューラーからは使用できません', - }; - } - try { - const guild = sourceMessage.guild; - if (guild) { - const channels = guild.channels.cache - .filter((c) => c.type === 0) - .map((c) => `- #${c.name} (<#${c.id}>)`) - .join('\n'); - return { handled: true, response: `📺 チャンネル一覧:\n${channels}` }; - } - } catch (err) { - logger.error('Failed to list channels', err); - return { handled: true, response: '❌ チャンネル一覧の取得に失敗しました' }; - } - } - - // !discord history [件数] [offset:N] [チャンネルID] - const historyMatch = text.match( - /^!discord\s+history(?:\s+(\d+))?(?:\s+offset:(\d+))?(?:\s+<#(\d+)>)?$/ - ); - if (historyMatch) { - const count = Math.min( - parseInt(historyMatch[1] || String(HISTORY_DEFAULT_COUNT), 10), - HISTORY_MAX_COUNT - ); - const offset = parseInt(historyMatch[2] || '0', 10); - const targetChannelId = historyMatch[3]; - try { - let targetChannel: Awaited> | null = null; - if (targetChannelId) { - targetChannel = await client.channels.fetch(targetChannelId); - } else if (sourceMessage) { - targetChannel = sourceMessage.channel; - } else if (fallbackChannelId) { - targetChannel = await client.channels.fetch(fallbackChannelId); - } - - if (targetChannel && 'messages' in targetChannel) { - let beforeId: string | undefined; - - if (offset > 0) { - const skipMessages = await targetChannel.messages.fetch({ limit: offset }); - if (skipMessages.size > 0) { - beforeId = skipMessages.lastKey(); - } - } - - const fetchOptions: { limit: number; before?: string } = { limit: count }; - if (beforeId) { - fetchOptions.before = beforeId; - } - const messages = await targetChannel.messages.fetch(fetchOptions); - const channelName = 'name' in targetChannel ? targetChannel.name : 'unknown'; - - const rangeStart = offset; - const rangeEnd = offset + messages.size; - const messageList = messages - .reverse() - .map((m) => { - const time = m.createdAt.toLocaleString('ja-JP', { timeZone: TIMEZONE }); - const content = (m.content || '(添付ファイルのみ)') - .slice(0, ERROR_TRUNCATE_LENGTH) - .replace(/<#(\d+)>/g, '#$1'); - const attachments = - m.attachments.size > 0 - ? `\n${m.attachments.map((a) => ` 📎 ${a.name} ${a.url}`).join('\n')}` - : ''; - return `[${time}] (ID:${m.id}) ${m.author.tag}: ${content}${attachments}`; - }) - .join('\n'); - - const offsetLabel = - offset > 0 ? `${rangeStart}〜${rangeEnd}件目` : `最新${messages.size}件`; - logger.debug( - `Fetched ${messages.size} history messages from #${channelName} (offset: ${offset})` - ); - return { - handled: true, - feedback: true, - response: `📺 #${channelName} のチャンネル履歴(${offsetLabel}):\n${messageList}`, - }; - } - - if (!sourceMessage && !targetChannelId && !fallbackChannelId) { - return { - handled: true, - feedback: true, - response: - '⚠️ history コマンドはチャンネルIDを指定してください(例: !discord history 20 <#123>)', - }; - } - return { handled: true, feedback: true, response: '❌ チャンネルが見つかりません' }; - } catch (err) { - logger.error('Failed to fetch history', err); - return { handled: true, feedback: true, response: '❌ 履歴の取得に失敗しました' }; - } - } - - // !discord delete - const deleteMatch = text.match(/^!discord\s+delete\s+(.+)$/); - if (deleteMatch) { - const arg = deleteMatch[1].trim(); - - try { - let messageId: string; - let targetChannelId: string | undefined; - - const linkMatch = arg.match(/discord\.com\/channels\/\d+\/(\d+)\/(\d+)/); - if (linkMatch) { - targetChannelId = linkMatch[1]; - messageId = linkMatch[2]; - } else if (/^\d+$/.test(arg)) { - messageId = arg; - } else { - return { - handled: true, - feedback: true, - response: '❌ 無効な形式です。メッセージIDまたはリンクを指定してください', - }; - } - - let channel: Awaited> | null = null; - if (targetChannelId) { - channel = await client.channels.fetch(targetChannelId); - } else if (sourceMessage) { - channel = sourceMessage.channel; - } else if (fallbackChannelId) { - channel = await client.channels.fetch(fallbackChannelId); - } - - if (channel && 'messages' in channel) { - const msg = await channel.messages.fetch(messageId); - if (msg.author.id !== client.user?.id) { - return { - handled: true, - feedback: true, - response: '❌ 自分のメッセージのみ削除できます', - }; - } - await msg.delete(); - const deletedChannelId = targetChannelId || sourceMessage?.channel.id || fallbackChannelId; - logger.info(`Deleted message ${messageId} in channel ${deletedChannelId}`); - return { handled: true, feedback: true, response: '🗑️ メッセージを削除しました' }; - } - return { - handled: true, - feedback: true, - response: '❌ このチャンネルではメッセージを削除できません', - }; - } catch (err) { - logger.error('Failed to delete message:', err); - return { handled: true, feedback: true, response: '❌ メッセージの削除に失敗しました' }; - } - } - - return { handled: false }; -} - -/** - * AIの応答から !discord / !schedule コマンドを検知して実行 - * parseAgentResponse で統一的にコマンドを抽出し、各コマンドを実行する。 - * feedback: true のコマンド結果はDiscordに送信せずフィードバック配列に収集して返す - */ -export async function handleDiscordCommandsInResponse( - text: string, - client: Client, - scheduler: Scheduler, - schedulerConfig: { enabled: boolean; startupEnabled: boolean } | undefined, - sourceMessage?: Message, - fallbackChannelId?: string -): Promise { - const { commands } = parseAgentResponse(text); - const feedbackResults: string[] = []; - - for (const cmd of commands) { - // !discord コマンド - if (cmd.startsWith('!discord ')) { - logger.debug(`Processing discord command from response: ${cmd.slice(0, 50)}...`); - const result = await handleDiscordCommand(cmd, client, sourceMessage, fallbackChannelId); - if (result.handled && result.response) { - if (result.feedback) { - feedbackResults.push(result.response); - } else if (sourceMessage && isSendableChannel(sourceMessage.channel)) { - await sourceMessage.channel.send(result.response); - } - } - continue; - } - - // !schedule コマンド - if (cmd === '!schedule' || cmd.startsWith('!schedule ')) { - if (sourceMessage) { - logger.debug(`Processing schedule command from response: ${cmd.slice(0, 50)}...`); - await executeScheduleFromResponse(cmd, sourceMessage, scheduler, schedulerConfig); - } - } - } - - return feedbackResults; -} diff --git a/src/discord/discord-send.ts b/src/discord/discord-send.ts deleted file mode 100644 index b8dbbe5..0000000 --- a/src/discord/discord-send.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { DISCORD_MAX_LENGTH, DISCORD_SAFE_LENGTH } from '../lib/constants.js'; -import { splitScheduleContent } from '../lib/message-utils.js'; -import { SCHEDULE_SEPARATOR } from '../scheduler/scheduler.js'; - -type Replyable = { reply: (content: string) => Promise }; -type Sendable = { send: (content: string) => Promise }; -type Interactionable = { - reply: (content: string) => Promise; - followUp: (content: string) => Promise; -}; - -/** - * message.reply() でチャンク分割して送信 - */ -export async function sendChunkedReply(target: Replyable, content: string): Promise { - const clean = content.replaceAll(SCHEDULE_SEPARATOR, ''); - if (clean.length <= DISCORD_MAX_LENGTH) { - await target.reply(clean); - return; - } - const chunks = splitScheduleContent(content, DISCORD_SAFE_LENGTH); - for (const chunk of chunks) { - await target.reply(chunk); - } -} - -/** - * channel.send() でチャンク分割して送信 - */ -export async function sendChunkedMessage(target: Sendable, content: string): Promise { - const clean = content.replaceAll(SCHEDULE_SEPARATOR, ''); - if (clean.length <= DISCORD_MAX_LENGTH) { - await target.send(clean); - return; - } - const chunks = splitScheduleContent(content, DISCORD_SAFE_LENGTH); - for (const chunk of chunks) { - await target.send(chunk); - } -} - -/** - * 送信モードを選んでチャンク分割送信 - * - 'reply': message.reply() - * - 'send': channel.send() - * - 'interaction': interaction.reply() + followUp() - */ -export async function sendScheduleContent( - target: Replyable & Partial & Partial, - content: string, - mode: 'reply' | 'send' | 'interaction' -): Promise { - const clean = content.replaceAll(SCHEDULE_SEPARATOR, ''); - - if (mode === 'send') { - const sendTarget = target as Sendable; - if (clean.length <= DISCORD_MAX_LENGTH) { - await sendTarget.send(clean); - return; - } - const chunks = splitScheduleContent(content, DISCORD_SAFE_LENGTH); - for (const chunk of chunks) { - await sendTarget.send(chunk); - } - return; - } - - if (mode === 'interaction') { - const interactionTarget = target as Interactionable; - if (clean.length <= DISCORD_MAX_LENGTH) { - await interactionTarget.reply(clean); - return; - } - const chunks = splitScheduleContent(content, DISCORD_SAFE_LENGTH); - await interactionTarget.reply(chunks[0]); - for (let i = 1; i < chunks.length; i++) { - await interactionTarget.followUp(chunks[i]); - } - return; - } - - // 'reply' mode (default) - await sendChunkedReply(target, content); -} diff --git a/src/discord/schedule-send.ts b/src/discord/schedule-send.ts new file mode 100644 index 0000000..51e45c8 --- /dev/null +++ b/src/discord/schedule-send.ts @@ -0,0 +1,24 @@ +import { DISCORD_MAX_LENGTH, DISCORD_SAFE_LENGTH } from '../lib/constants.js'; +import { splitScheduleContent } from '../lib/message-utils.js'; +import { SCHEDULE_SEPARATOR } from '../scheduler/scheduler.js'; + +type Interactionable = { + reply: (content: string) => Promise; + followUp: (content: string) => Promise; +}; + +/** + * interaction.reply() + followUp() でチャンク分割送信 + */ +export async function sendScheduleContent(target: Interactionable, content: string): Promise { + const clean = content.replaceAll(SCHEDULE_SEPARATOR, ''); + if (clean.length <= DISCORD_MAX_LENGTH) { + await target.reply(clean); + return; + } + const chunks = splitScheduleContent(content, DISCORD_SAFE_LENGTH); + await target.reply(chunks[0]); + for (let i = 1; i < chunks.length; i++) { + await target.followUp(chunks[i]); + } +} diff --git a/src/discord/slash-commands.ts b/src/discord/slash-commands.ts index b2b16e4..2358ffa 100644 --- a/src/discord/slash-commands.ts +++ b/src/discord/slash-commands.ts @@ -1,23 +1,9 @@ -import type { AutocompleteInteraction, ChatInputCommandInteraction } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js'; -import type { AgentRunner } from '../agent/agent-runner.js'; +import type { Brain } from '../brain/brain.js'; import type { Config } from '../lib/config.js'; -import { - AUTOCOMPLETE_MAX_RESULTS, - DISCORD_SAFE_LENGTH, - ERROR_TRUNCATE_LENGTH, - SLASH_COMMAND_DESCRIPTION_MAX, -} from '../lib/constants.js'; -import { createLogger } from '../lib/logger.js'; -import { splitMessage } from '../lib/message-utils.js'; -import { formatSettings, loadSettings } from '../lib/settings.js'; -import { formatSkillList, type Skill } from '../lib/skills.js'; import { handleScheduleCommand } from '../scheduler/schedule-handler.js'; import type { Scheduler } from '../scheduler/scheduler.js'; -import { isSendableChannel } from './discord-types.js'; -import { executeSkillCommand } from './message-handler.js'; - -const logger = createLogger('discord'); /** * チャンネルの実行状態をフォーマット @@ -25,225 +11,64 @@ const logger = createLogger('discord'); export function formatChannelStatus( channelId: string, processingChannels: Map, - agentRunner: AgentRunner + brain: Brain ): string { const count = processingChannels.get(channelId) ?? 0; - const sessionId = agentRunner.getSessionId?.(channelId); + const status = brain.getStatus(); - const lines = ['📊 **現在の実行状態**']; - lines.push(`- チャンネル: <#${channelId}>`); - lines.push(`- 実行ロック: ${count > 0 ? `🔒 ${count}件処理中` : '🔓 待機中'}`); - lines.push(`- セッション: ${sessionId ? `✅ ${sessionId.slice(0, 12)}...` : '❌ なし'}`); - - const status = agentRunner.getStatus?.(); - if (status) { - const channelStatus = status.channels.find((c) => c.channelId === channelId); - lines.push(`- Runner pool: ${status.poolSize}/${status.maxProcesses}`); - if (channelStatus) { - lines.push( - `- チャンネルランナー: ${channelStatus.alive ? '✅ alive' : '⚠️ dead'} (idle ${channelStatus.idleSeconds}s)` - ); - } else { - lines.push('- チャンネルランナー: なし'); - } - } + const lines = ['**Current Status**']; + lines.push(`- Channel: <#${channelId}>`); + lines.push(`- Processing: ${count > 0 ? `${count} tasks` : 'idle'}`); + lines.push(`- Brain: ${status.busy ? 'busy' : 'idle'} (queue: ${status.queueLength})`); + lines.push(`- Session: ${status.sessionId ? `${status.sessionId.slice(0, 12)}...` : 'none'}`); return lines.join('\n'); } -/** - * autocomplete ハンドラー - */ -export async function handleAutocomplete( - interaction: AutocompleteInteraction, - skills: Skill[] -): Promise { - const focusedValue = interaction.options.getFocused().toLowerCase(); - - const filtered = skills - .filter( - (skill) => - skill.name.toLowerCase().includes(focusedValue) || - skill.description.toLowerCase().includes(focusedValue) - ) - .slice(0, AUTOCOMPLETE_MAX_RESULTS) - .map((skill) => ({ - name: `${skill.name} - ${skill.description.slice(0, 50)}`, - value: skill.name, - })); - - await interaction.respond(filtered); -} - -/** - * /personalize コマンドハンドラー - */ -export async function handlePersonalize( - interaction: ChatInputCommandInteraction, - agentRunner: AgentRunner, - channelId: string -): Promise { - const target = interaction.options.getString('target') || 'both'; - - agentRunner.deleteSession?.(channelId); - agentRunner.destroy?.(channelId); - await interaction.deferReply(); - - const targetDesc = - target === 'soul' - ? 'SOUL.md(ボットの人格・性格)' - : target === 'user' - ? 'USER.md(ユーザー情報)' - : 'SOUL.md(ボットの人格・性格)と USER.md(ユーザー情報)'; - - const prompt = `ユーザーが /personalize コマンドを実行しました。${targetDesc}の作成・編集を対話的にサポートしてください。 - -## あなたのタスク - -1. まずワークスペースの既存ファイルを確認してください: - - SOUL.md: ボットの人格・口調・価値観を定義するファイル - - USER.md: ユーザーの情報・好み・コンテキストを定義するファイル - -2. ユーザーに質問しながら、ファイルの内容を一緒に考えてください: -${ - target === 'user' - ? '' - : ` - SOUL.md: どんな口調がいい?(フレンドリー/丁寧/カジュアル等)、性格は?、特別なルールは? -` -}${ - target === 'soul' - ? '' - : ` - USER.md: ユーザーの名前、興味・関心、よく使う技術、好みのコミュニケーションスタイル等 -` -} -3. ユーザーの回答を元にファイルを作成・更新してください。 - -## 重要なルール -- 一度に全部聞かず、2-3個ずつ質問してください -- 既存ファイルがあれば内容を見せて、変更したい部分を聞いてください -- 最終的にファイルを書き出す前に内容を確認してもらってください -- Discordで会話しているので、返答は簡潔にしてください`; - - try { - const { result } = await agentRunner.run(prompt, { - channelId, - }); - - const chunks = splitMessage(result, DISCORD_SAFE_LENGTH); - await interaction.editReply(chunks[0] || '✅'); - if (chunks.length > 1 && isSendableChannel(interaction.channel)) { - for (let i = 1; i < chunks.length; i++) { - await interaction.channel.send(chunks[i]); - } - } - } catch (error) { - logger.error('Personalize error:', error); - const errorMsg = error instanceof Error ? error.message : String(error); - await interaction.editReply(`❌ エラー: ${errorMsg.slice(0, ERROR_TRUNCATE_LENGTH)}`); - } -} - /** * スラッシュコマンド定義を生成 */ -export function buildSlashCommands(skills: Skill[]): ReturnType[] { - const commands: ReturnType[] = [ - new SlashCommandBuilder().setName('new').setDescription('新しいセッションを開始').toJSON(), - new SlashCommandBuilder().setName('stop').setDescription('実行中のタスクを停止').toJSON(), - new SlashCommandBuilder().setName('status').setDescription('実行状態を確認').toJSON(), - new SlashCommandBuilder() - .setName('settings') - .setDescription('設定を表示・変更') - .addStringOption((option) => - option - .setName('key') - .setDescription('設定キー') - .setRequired(false) - .addChoices({ name: 'autoRestart', value: 'autoRestart' }) - ) - .addStringOption((option) => - option.setName('value').setDescription('設定値').setRequired(false) - ) - .toJSON(), - new SlashCommandBuilder().setName('restart').setDescription('プロセスを再起動').toJSON(), +export function buildSlashCommands(): ReturnType[] { + return [ + new SlashCommandBuilder().setName('stop').setDescription('Stop current task').toJSON(), + new SlashCommandBuilder().setName('status').setDescription('Show current status').toJSON(), new SlashCommandBuilder() .setName('schedule') - .setDescription('スケジュール管理') + .setDescription('Manage schedules') .addSubcommand((sub) => sub .setName('add') - .setDescription('スケジュール追加') + .setDescription('Add schedule') .addStringOption((opt) => - opt.setName('input').setDescription('スケジュール入力').setRequired(true) + opt.setName('input').setDescription('Schedule input').setRequired(true) ) ) - .addSubcommand((sub) => sub.setName('list').setDescription('スケジュール一覧')) + .addSubcommand((sub) => sub.setName('list').setDescription('List schedules')) .addSubcommand((sub) => sub .setName('remove') - .setDescription('スケジュール削除') + .setDescription('Remove schedule') .addStringOption((opt) => - opt.setName('id').setDescription('スケジュールID').setRequired(true) + opt.setName('id').setDescription('Schedule ID').setRequired(true) ) ) .addSubcommand((sub) => sub .setName('toggle') - .setDescription('スケジュールの有効/無効切替') + .setDescription('Toggle schedule') .addStringOption((opt) => - opt.setName('id').setDescription('スケジュールID').setRequired(true) - ) - ) - .toJSON(), - new SlashCommandBuilder() - .setName('personalize') - .setDescription('SOUL.md / USER.md を対話的に作成・編集') - .addStringOption((option) => - option - .setName('target') - .setDescription('編集対象') - .setRequired(false) - .addChoices( - { name: 'both (SOUL.md + USER.md)', value: 'both' }, - { name: 'soul (ボットの人格)', value: 'soul' }, - { name: 'user (ユーザー情報)', value: 'user' } + opt.setName('id').setDescription('Schedule ID').setRequired(true) ) ) .toJSON(), - new SlashCommandBuilder().setName('skills').setDescription('利用可能なスキル一覧').toJSON(), - new SlashCommandBuilder() - .setName('skill') - .setDescription('スキルを実行') - .addStringOption((option) => - option.setName('name').setDescription('スキル名').setRequired(true).setAutocomplete(true) - ) - .addStringOption((option) => option.setName('args').setDescription('引数').setRequired(false)) - .toJSON(), ]; - - for (const skill of skills) { - commands.push( - new SlashCommandBuilder() - .setName(skill.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')) - .setDescription(skill.description.slice(0, SLASH_COMMAND_DESCRIPTION_MAX)) - .addStringOption((option) => - option.setName('args').setDescription('引数').setRequired(false) - ) - .toJSON() - ); - } - - return commands; } export interface SlashCommandDeps { - agentRunner: AgentRunner; + brain: Brain; scheduler: Scheduler; config: Config; - skills: Skill[]; processingChannels: Map; - workdir: string; - reloadSkills: () => Skill[]; } /** @@ -254,58 +79,23 @@ export async function handleSlashCommand( channelId: string, deps: SlashCommandDeps ): Promise { - const { agentRunner, scheduler, skills, reloadSkills } = deps; - - if (interaction.commandName === 'new') { - agentRunner.deleteSession?.(channelId); - agentRunner.destroy?.(channelId); - await interaction.reply('🆕 新しいセッションを開始しました'); - return; - } + const { brain, scheduler } = deps; if (interaction.commandName === 'stop') { - const cancelledCount = agentRunner.cancelAll?.(channelId) ?? 0; + const cancelledCount = brain.cancelAll(); deps.processingChannels.delete(channelId); if (cancelledCount > 0) { await interaction.reply( - `🛑 タスクを停止しました${cancelledCount > 1 ? `(${cancelledCount}件キャンセル)` : ''}` + `Stopped${cancelledCount > 1 ? ` (${cancelledCount} cancelled)` : ''}` ); } else { - await interaction.reply('実行中のタスクはありません'); + await interaction.reply('No tasks running'); } return; } if (interaction.commandName === 'status') { - await interaction.reply(formatChannelStatus(channelId, deps.processingChannels, agentRunner)); - return; - } - - if (interaction.commandName === 'settings') { - const key = interaction.options.getString('key'); - const value = interaction.options.getString('value'); - if (key && value !== null) { - const settings = loadSettings(); - if (key === 'autoRestart') { - settings.autoRestart = value === 'true'; - const { saveSettings } = await import('../lib/settings.js'); - saveSettings(settings); - } - await interaction.reply(`⚙️ \`${key}\` = \`${value}\``); - } else { - await interaction.reply(formatSettings(loadSettings())); - } - return; - } - - if (interaction.commandName === 'restart') { - const settings = loadSettings(); - if (!settings.autoRestart) { - await interaction.reply('⚠️ 自動再起動が無効です。先に有効にしてください。'); - return; - } - await interaction.reply('🔄 再起動します...'); - setTimeout(() => process.exit(0), 1000); + await interaction.reply(formatChannelStatus(channelId, deps.processingChannels, brain)); return; } @@ -313,31 +103,4 @@ export async function handleSlashCommand( await handleScheduleCommand(interaction, scheduler, undefined); return; } - - if (interaction.commandName === 'personalize') { - await handlePersonalize(interaction, agentRunner, channelId); - return; - } - - if (interaction.commandName === 'skills') { - const reloaded = reloadSkills(); - await interaction.reply(formatSkillList(reloaded)); - return; - } - - if (interaction.commandName === 'skill') { - const skillName = interaction.options.getString('name', true); - const args = interaction.options.getString('args') || ''; - await executeSkillCommand(interaction, agentRunner, channelId, skillName, args); - return; - } - - // 個別スキルコマンド - const matchedSkill = skills.find( - (s) => s.name.toLowerCase().replace(/[^a-z0-9-]/g, '-') === interaction.commandName - ); - if (matchedSkill) { - const args = interaction.options.getString('args') || ''; - await executeSkillCommand(interaction, agentRunner, channelId, matchedSkill.name, args); - } } diff --git a/src/index.ts b/src/index.ts index 852a4aa..70f83d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,17 @@ import { join } from 'node:path'; -import { createAgentRunner } from './agent/agent-runner.js'; +import { SdkRunner } from './agent/sdk-runner.js'; +import { Brain } from './brain/brain.js'; +import { Heartbeat } from './brain/heartbeat.js'; +import { TriggerManager } from './brain/triggers.js'; +import { isSendableChannel } from './discord/channel-utils.js'; import { registerSchedulerHandlers, setupDiscordClient } from './discord/discord-client.js'; - import { loadConfig } from './lib/config.js'; +import { DISCORD_SAFE_LENGTH } from './lib/constants.js'; import { createLogger } from './lib/logger.js'; -import { initSessions } from './lib/sessions.js'; +import { splitMessage } from './lib/message-utils.js'; import { initSettings, loadSettings } from './lib/settings.js'; +import { RunContext } from './mcp/context.js'; +import { createThorMcpServer } from './mcp/server.js'; import { Scheduler } from './scheduler/scheduler.js'; const logger = createLogger('thor'); @@ -21,46 +27,113 @@ async function main() { process.exit(1); } if (discordAllowed.length > 1) { - logger.error('Only one user is allowed'); - logger.error('利用規約遵守のため、複数ユーザーの設定は禁止です'); + logger.error('Only one user is allowed (compliance requirement)'); process.exit(1); } - // エージェントランナーを作成 - const agentRunner = createAgentRunner(config.agent); - logger.info('Using Claude Code as agent backend'); - // 設定を初期化 const workdir = config.agent.workdir; initSettings(workdir); const initialSettings = loadSettings(); logger.info(`Settings loaded: autoRestart=${initialSettings.autoRestart}`); - // スケジューラを初期化(ワークスペースの .thor を使用) + // スケジューラを初期化 const dataDir = join(workdir, '.thor'); const scheduler = new Scheduler(dataDir); - // セッション永続化を初期化 - initSessions(dataDir); + // Brain は遅延初期化(Discord client が必要) + let brain: Brain | null = null; + const getBrain = (): Brain => { + if (!brain) throw new Error('Brain not yet initialized'); + return brain; + }; - // Discord クライアントをセットアップ - const client = setupDiscordClient({ config, agentRunner, scheduler }); + // Discord クライアントをセットアップ(getBrain で遅延アクセス) + const client = setupDiscordClient({ config, getBrain, scheduler }); // Discordボットを起動 await client.login(config.discord.token); logger.info('Discord bot started'); + // Brain を作成(Discord client + Scheduler が必要) + const runContext = new RunContext(); + const mcpServer = createThorMcpServer(client, scheduler, runContext); + const runner = new SdkRunner( + { model: config.agent.model, timeoutMs: config.agent.timeoutMs, workdir }, + mcpServer, + runContext + ); + brain = new Brain(runner); + logger.info('Brain initialized'); + // スケジューラにDiscord連携関数を登録 - registerSchedulerHandlers(scheduler, client, agentRunner, config); + registerSchedulerHandlers(scheduler, client, getBrain, config); // スケジューラの全ジョブを開始 scheduler.startAll(); + // 自律結果のハンドラ(MCP tools は query 中に実行済みなのでテキスト送信のみ) + const sendResultToChannel = async (result: string, channelId: string) => { + try { + const channel = await client.channels.fetch(channelId); + if (isSendableChannel(channel)) { + const chunks = splitMessage(result, DISCORD_SAFE_LENGTH); + for (const chunk of chunks) { + await channel.send(chunk); + } + } + } catch (err) { + logger.error('Failed to send result to channel:', err); + } + }; + + // Heartbeat 作成・開始 + let heartbeat: Heartbeat | null = null; + if (config.heartbeat.enabled && config.heartbeat.channelId) { + heartbeat = new Heartbeat(getBrain(), { + minIntervalMs: config.heartbeat.minIntervalMs, + maxIntervalMs: config.heartbeat.maxIntervalMs, + idleThresholdMs: config.heartbeat.idleThresholdMs, + channelId: config.heartbeat.channelId, + }); + heartbeat.setResultHandler((result, channelId) => { + sendResultToChannel(result, channelId).catch((err) => { + logger.error('Failed to send heartbeat result:', err); + }); + }); + heartbeat.start(); + logger.info('Heartbeat enabled'); + } else { + logger.info('Heartbeat disabled'); + } + + // TriggerManager 作成・開始 + let triggerManager: TriggerManager | null = null; + if (config.trigger.enabled && config.trigger.channelId) { + triggerManager = new TriggerManager(getBrain(), { + channelId: config.trigger.channelId, + morningHour: config.trigger.morningHour, + eveningHour: config.trigger.eveningHour, + weeklyDay: config.trigger.weeklyDay, + }); + triggerManager.setResultHandler((result, channelId) => { + sendResultToChannel(result, channelId).catch((err) => { + logger.error('Failed to send trigger result:', err); + }); + }); + triggerManager.start(); + logger.info('Triggers enabled'); + } else { + logger.info('Triggers disabled'); + } + // グレースフルシャットダウン process.on('SIGINT', async () => { logger.info('Shutting down...'); + heartbeat?.stop(); + triggerManager?.stop(); scheduler.stopAll(); - agentRunner.shutdown?.(); + brain?.shutdown(); client.destroy(); process.exit(0); }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 67a92a2..d31be73 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,7 +1,15 @@ import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { z } from 'zod'; -import { DEFAULT_TIMEOUT_MS } from './constants.js'; +import { + DEFAULT_TIMEOUT_MS, + HEARTBEAT_IDLE_THRESHOLD_MS, + HEARTBEAT_MAX_INTERVAL_MS, + HEARTBEAT_MIN_INTERVAL_MS, + TRIGGER_EVENING_HOUR, + TRIGGER_MORNING_HOUR, + TRIGGER_WEEKLY_DAY, +} from './constants.js'; const AgentConfigSchema = z.object({ model: z.string().optional(), @@ -9,6 +17,22 @@ const AgentConfigSchema = z.object({ workdir: z.string(), }); +const HeartbeatConfigSchema = z.object({ + enabled: z.boolean().default(false), + minIntervalMs: z.number().int().positive().default(HEARTBEAT_MIN_INTERVAL_MS), + maxIntervalMs: z.number().int().positive().default(HEARTBEAT_MAX_INTERVAL_MS), + idleThresholdMs: z.number().int().positive().default(HEARTBEAT_IDLE_THRESHOLD_MS), + channelId: z.string().default(''), +}); + +const TriggerConfigSchema = z.object({ + enabled: z.boolean().default(false), + morningHour: z.number().int().min(0).max(23).default(TRIGGER_MORNING_HOUR), + eveningHour: z.number().int().min(0).max(23).default(TRIGGER_EVENING_HOUR), + weeklyDay: z.number().int().min(0).max(6).default(TRIGGER_WEEKLY_DAY), + channelId: z.string().default(''), +}); + const ConfigSchema = z.object({ discord: z.object({ token: z.string().min(1, 'DISCORD_TOKEN is required'), @@ -16,9 +40,13 @@ const ConfigSchema = z.object({ autoReplyChannels: z.array(z.string()).default([]), }), agent: AgentConfigSchema, + heartbeat: HeartbeatConfigSchema, + trigger: TriggerConfigSchema, }); export type AgentConfig = z.infer; +export type HeartbeatConfig = z.infer; +export type TriggerConfig = z.infer; export type Config = z.infer; /** Resolve a path: expand leading `~` to home directory, then resolve to absolute */ @@ -59,6 +87,23 @@ export function loadConfig(): Config { timeoutMs: parseIntEnv(process.env.TIMEOUT_MS, DEFAULT_TIMEOUT_MS), workdir: process.env.WORKSPACE_PATH ? resolvePath(process.env.WORKSPACE_PATH) : './workspace', }, + heartbeat: { + enabled: process.env.HEARTBEAT_ENABLED === 'true', + minIntervalMs: parseIntEnv(process.env.HEARTBEAT_MIN_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS), + maxIntervalMs: parseIntEnv(process.env.HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MAX_INTERVAL_MS), + idleThresholdMs: parseIntEnv( + process.env.HEARTBEAT_IDLE_THRESHOLD_MS, + HEARTBEAT_IDLE_THRESHOLD_MS + ), + channelId: process.env.HEARTBEAT_CHANNEL_ID || '', + }, + trigger: { + enabled: process.env.TRIGGER_ENABLED === 'true', + morningHour: parseIntEnv(process.env.TRIGGER_MORNING_HOUR, TRIGGER_MORNING_HOUR), + eveningHour: parseIntEnv(process.env.TRIGGER_EVENING_HOUR, TRIGGER_EVENING_HOUR), + weeklyDay: parseIntEnv(process.env.TRIGGER_WEEKLY_DAY, TRIGGER_WEEKLY_DAY), + channelId: process.env.TRIGGER_CHANNEL_ID || '', + }, }; return ConfigSchema.parse(raw); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index bfbe039..2cfc40b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,8 +4,7 @@ // Discord export const DISCORD_MAX_LENGTH = 2000; -export const DISCORD_SPLIT_MARGIN = 100; // 分割時のマージン -export const DISCORD_SAFE_LENGTH = DISCORD_MAX_LENGTH - DISCORD_SPLIT_MARGIN; // 1900 +export const DISCORD_SAFE_LENGTH = DISCORD_MAX_LENGTH - 100; // ストリーミング export const STREAM_UPDATE_INTERVAL_MS = 1000; @@ -22,23 +21,9 @@ export const DEFAULT_TIMEOUT_MS = 300000; // 5分 // タイムゾーン export const TIMEZONE = process.env.TIMEZONE || Intl.DateTimeFormat().resolvedOptions().timeZone; -// PersistentRunner バッファ -export const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB - -// PersistentRunner 指数バックオフ -export const BACKOFF_BASE_MS = 1000; -export const BACKOFF_MAX_MS = 30000; - // 表示用トランケーション export const ERROR_TRUNCATE_LENGTH = 200; export const SESSION_ID_DISPLAY_LENGTH = 8; -export const COMMAND_LOG_TRUNCATE_LENGTH = 80; - -// Discord オートコンプリート -export const AUTOCOMPLETE_MAX_RESULTS = 25; - -// Discord スラッシュコマンド -export const SLASH_COMMAND_DESCRIPTION_MAX = 100; // ファイルサイズ上限 export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB @@ -49,4 +34,13 @@ export const HISTORY_MAX_COUNT = 100; // エラーメッセージ判定 export const CANCELLED_ERROR_MESSAGE = 'Request cancelled by user'; -export const CIRCUIT_BREAKER_PREFIX = 'Circuit breaker'; + +// Heartbeat defaults +export const HEARTBEAT_MIN_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes +export const HEARTBEAT_MAX_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours +export const HEARTBEAT_IDLE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes + +// Trigger defaults +export const TRIGGER_MORNING_HOUR = 8; +export const TRIGGER_EVENING_HOUR = 22; +export const TRIGGER_WEEKLY_DAY = 0; // Sunday diff --git a/src/lib/error-utils.ts b/src/lib/error-utils.ts index b8d0be5..059e056 100644 --- a/src/lib/error-utils.ts +++ b/src/lib/error-utils.ts @@ -15,9 +15,6 @@ export function formatErrorDetail(errorMsg: string, options?: { timeoutLabel?: s if (errorMsg.includes('Process exited unexpectedly')) { return `💥 AIプロセスが予期せず終了しました: ${errorMsg}`; } - if (errorMsg.includes('Circuit breaker')) { - return '🔌 AIプロセスが連続でクラッシュしたため一時停止中です。しばらくしてから再試行してください'; - } return `❌ エラーが発生しました: ${errorMsg.slice(0, ERROR_TRUNCATE_LENGTH)}`; } diff --git a/src/lib/feedback-loop.ts b/src/lib/feedback-loop.ts deleted file mode 100644 index 9f6fc47..0000000 --- a/src/lib/feedback-loop.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Client, Message } from 'discord.js'; -import { handleDiscordCommandsInResponse } from '../discord/discord-commands.js'; -import type { Scheduler } from '../scheduler/scheduler.js'; -import { createLogger } from './logger.js'; - -const logger = createLogger('feedback'); - -interface FeedbackOptions { - /** Discordメッセージコンテキスト(インタラクティブ応答時) */ - sourceMessage?: Message; - /** スケジューラ実行時のフォールバックチャンネルID */ - fallbackChannelId?: string; - /** フィードバックをエージェントに再注入する関数 */ - runAgent: (prompt: string) => Promise; -} - -/** - * AI応答内のコマンドを実行し、フィードバック結果をエージェントに再注入する - * - * handleResponseFeedback(メッセージ経由)とスケジューラハンドラの - * 重複フィードバックループを統一する。 - */ -export async function executeCommandsWithFeedback( - result: string, - client: Client, - scheduler: Scheduler, - options: FeedbackOptions -): Promise { - const feedbackResults = await handleDiscordCommandsInResponse( - result, - client, - scheduler, - undefined, - options.sourceMessage, - options.fallbackChannelId - ); - - if (feedbackResults.length === 0) { - return; - } - - const feedbackPrompt = `あなたが実行したコマンドの結果が返ってきました。この情報を踏まえて、元の会話の文脈に沿ってユーザーに返答してください。\n\n${feedbackResults.join('\n\n')}`; - logger.info(`Re-injecting ${feedbackResults.length} feedback result(s) to agent`); - - const reInjectedResult = await options.runAgent(feedbackPrompt); - - // 再注入後の応答にもコマンドがあれば処理(ただし再帰は1回のみ) - await handleDiscordCommandsInResponse( - reInjectedResult, - client, - scheduler, - undefined, - options.sourceMessage, - options.fallbackChannelId - ); -} diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts index cdb6b4f..a399bf6 100644 --- a/src/lib/file-utils.ts +++ b/src/lib/file-utils.ts @@ -13,10 +13,8 @@ const DOWNLOAD_DIR = path.join( 'attachments' ); -// ダウンロードディレクトリを作成 -if (!fs.existsSync(DOWNLOAD_DIR)) { - fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); -} +// ダウンロードディレクトリを作成(recursive: true は既存でも安全) +fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); const ALLOWED_DOWNLOAD_HOSTS = ['cdn.discordapp.com', 'media.discordapp.net']; diff --git a/src/lib/message-utils.ts b/src/lib/message-utils.ts index 2a4f8e0..c958040 100644 --- a/src/lib/message-utils.ts +++ b/src/lib/message-utils.ts @@ -1,5 +1,4 @@ import { SCHEDULE_SEPARATOR } from '../scheduler/scheduler.js'; -import { DISCORD_MAX_LENGTH } from './constants.js'; /** メッセージを指定文字数で分割(カスタムセパレータ対応、デフォルトは行単位) */ export function splitMessage(text: string, maxLength: number, separator = '\n'): string[] { @@ -17,7 +16,19 @@ export function splitMessage(text: string, maxLength: number, separator = '\n'): for (const line of lines) { if (current.length + line.length + 1 > maxLength) { if (current) chunks.push(current.trim()); - current = line; + // 単一行がmaxLengthを超える場合はハードスプリット + if (line.length > maxLength) { + for (let j = 0; j < line.length; j += maxLength) { + const slice = line.slice(j, j + maxLength); + if (j + maxLength < line.length) { + chunks.push(slice); + } else { + current = slice; + } + } + } else { + current = line; + } } else { current += (current ? '\n' : '') + line; } @@ -39,36 +50,3 @@ export function splitScheduleContent(content: string, maxLength: number): string const chunks = splitMessage(content, maxLength, sep); return chunks.map((c) => c.replaceAll(SCHEDULE_SEPARATOR, '')); } - -/** - * Discord の 2000 文字制限に合わせてメッセージを分割する - */ -export function chunkDiscordMessage(message: string, limit = DISCORD_MAX_LENGTH): string[] { - if (message.length <= limit) return [message]; - - const chunks: string[] = []; - let buf = ''; - - for (const line of message.split('\n')) { - if (line.length > limit) { - // 1行が limit 超え → バッファをフラッシュしてハードスプリット - if (buf) { - chunks.push(buf); - buf = ''; - } - for (let j = 0; j < line.length; j += limit) { - chunks.push(line.slice(j, j + limit)); - } - continue; - } - const candidate = buf ? `${buf}\n${line}` : line; - if (candidate.length > limit) { - chunks.push(buf); - buf = line; - } else { - buf = candidate; - } - } - if (buf) chunks.push(buf); - return chunks; -} diff --git a/src/lib/response-parser.ts b/src/lib/response-parser.ts deleted file mode 100644 index 9e355d6..0000000 --- a/src/lib/response-parser.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * AI応答テキストを解析し、コマンドと表示テキストを分離する統一パーサー - * - * 以下のコマンドを検出する: - * - !discord send <#channelId> メッセージ(複数行対応) - * - !discord channels / history / delete - * - !schedule [引数] - * - SYSTEM_COMMAND:xxx - * - * コードブロック (```) 内のコマンドは無視する。 - */ - -export interface ParsedResponse { - /** コマンドを除いた表示用テキスト */ - displayText: string; - /** 検出されたコマンド文字列の配列 */ - commands: string[]; -} - -export function parseAgentResponse(text: string): ParsedResponse { - if (!text) { - return { displayText: '', commands: [] }; - } - - const lines = text.split('\n'); - const commands: string[] = []; - const displayLines: string[] = []; - let inCodeBlock = false; - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - const trimmed = line.trim(); - - // コードブロックのトグル - if (trimmed.startsWith('```')) { - inCodeBlock = !inCodeBlock; - displayLines.push(line); - i++; - continue; - } - - // コードブロック内はそのまま表示テキストへ - if (inCodeBlock) { - displayLines.push(line); - i++; - continue; - } - - // SYSTEM_COMMAND: 行 - if (trimmed.startsWith('SYSTEM_COMMAND:')) { - commands.push(trimmed); - i++; - continue; - } - - // !discord send の複数行対応 - const sendMatch = trimmed.match(/^!discord\s+send\s+<#(\d+)>\s*(.*)/); - if (sendMatch) { - const channelRef = `<#${sendMatch[1]}>`; - const firstLineContent = sendMatch[2] ?? ''; - - // 後続行を吸収(次のコマンド行まで) - const bodyLines: string[] = []; - if (firstLineContent.trim()) { - bodyLines.push(firstLineContent); - } - let inBodyCodeBlock = false; - i++; - while (i < lines.length) { - const bodyLine = lines[i]; - if (bodyLine.trim().startsWith('```')) { - inBodyCodeBlock = !inBodyCodeBlock; - } - if (!inBodyCodeBlock && isCommandLine(bodyLine.trim())) { - break; - } - bodyLines.push(bodyLine); - i++; - } - - const fullMessage = bodyLines.join('\n').trim(); - if (fullMessage) { - commands.push(`!discord send ${channelRef} ${fullMessage}`); - } - // send の場合、後続行も吸収済みなので continue(i は次のコマンド行を指す) - continue; - } - - // !discord channels / history / delete - if (trimmed.startsWith('!discord ')) { - commands.push(trimmed); - i++; - continue; - } - - // !schedule - if (trimmed === '!schedule' || trimmed.startsWith('!schedule ')) { - commands.push(trimmed); - i++; - continue; - } - - // 通常テキスト - displayLines.push(line); - i++; - } - - return { - displayText: displayLines.join('\n').trim(), - commands, - }; -} - -/** コマンド行かどうかを判定するヘルパー */ -function isCommandLine(trimmed: string): boolean { - return ( - trimmed.startsWith('!discord ') || - trimmed === '!schedule' || - trimmed.startsWith('!schedule ') || - trimmed.startsWith('SYSTEM_COMMAND:') - ); -} diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts deleted file mode 100644 index 0da86e6..0000000 --- a/src/lib/sessions.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { z } from 'zod'; -import { createLogger } from './logger.js'; - -const logger = createLogger('sessions'); - -/** - * セッション管理(チャンネルID → Claude CodeセッションID) - * ファイルに永続化してプロセス再起動後も継続可能にする - */ - -type SessionMap = Map; - -let sessionsPath: string | null = null; -let sessions: SessionMap = new Map(); - -/** - * sessions.json のパスを初期化 - * @param dataDir .thor ディレクトリ - */ -export function initSessions(dataDir: string): void { - sessionsPath = join(dataDir, 'sessions.json'); - loadSessionsFromFile(); -} - -/** - * sessions.json のパスを取得 - */ -export function getSessionsPath(): string { - if (!sessionsPath) { - throw new Error('Sessions not initialized. Call initSessions(dataDir) first.'); - } - return sessionsPath; -} - -/** - * ファイルからセッションを読み込む - */ -const SessionsSchema = z.record(z.string(), z.string()); - -function loadSessionsFromFile(): void { - const path = getSessionsPath(); - try { - if (existsSync(path)) { - const raw = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(raw); - const result = SessionsSchema.safeParse(parsed); - if (result.success) { - sessions = new Map(Object.entries(result.data)); - } else { - logger.error('Invalid sessions data, resetting:', result.error.message); - sessions = new Map(); - } - logger.info(`Loaded ${sessions.size} sessions from ${path}`); - } - } catch (err) { - logger.error('Failed to load sessions:', err); - sessions = new Map(); - } -} - -/** - * セッションをファイルに保存 - */ -function saveSessionsToFile(): void { - const path = getSessionsPath(); - try { - mkdirSync(dirname(path), { recursive: true }); - const obj = Object.fromEntries(sessions); - writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, 'utf-8'); - } catch (err) { - logger.error('Failed to save sessions:', err); - } -} - -/** - * セッションIDを取得 - */ -export function getSession(channelId: string): string | undefined { - return sessions.get(channelId); -} - -/** - * セッションIDを設定(自動保存) - */ -export function setSession(channelId: string, sessionId: string): void { - sessions.set(channelId, sessionId); - saveSessionsToFile(); -} - -/** - * セッションを削除(自動保存) - */ -export function deleteSession(channelId: string): boolean { - const deleted = sessions.delete(channelId); - if (deleted) { - saveSessionsToFile(); - } - return deleted; -} - -/** - * 全セッションをクリア(テスト用) - */ -export function clearSessions(): void { - sessions.clear(); - sessionsPath = null; -} - -/** - * セッション数を取得 - */ -export function getSessionCount(): number { - return sessions.size; -} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 88175a9..a80dd3a 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -70,14 +70,6 @@ export function saveSettings(settings: Partial): Settings { return { ...merged }; } -/** - * 設定をフォーマットして表示用文字列を返す - */ -export function formatSettings(settings: Settings): string { - const lines = ['⚙️ **現在の設定**', `- 自動再起動: ${settings.autoRestart ? '✅ ON' : '❌ OFF'}`]; - return lines.join('\n'); -} - /** * キャッシュをクリア(テスト用) */ diff --git a/src/lib/skills.ts b/src/lib/skills.ts deleted file mode 100644 index 6f86e72..0000000 --- a/src/lib/skills.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; -import { DISCORD_SAFE_LENGTH } from './constants.js'; -import { createLogger } from './logger.js'; - -const logger = createLogger('skills'); - -export interface Skill { - name: string; - description: string; - path: string; -} - -/** - * ワークスペースのスキルディレクトリからスキル一覧を読み込む - * .claude/skills/, .codex/skills/, skills/ を探し、重複は除外 - */ -export function loadSkills(workdir: string): Skill[] { - const skillMap = new Map(); - - // 複数のスキルディレクトリを探す(優先順位順) - const skillsDirs = [ - join(workdir, '.claude', 'skills'), // Claude Code形式 - join(workdir, 'skills'), // 標準形式 - ]; - - for (const skillsDir of skillsDirs) { - const loaded = loadSkillsFromDir(skillsDir); - for (const skill of loaded) { - // 同名スキルは最初に見つかったものを優先(重複排除) - if (!skillMap.has(skill.name)) { - skillMap.set(skill.name, skill); - } - } - } - - return Array.from(skillMap.values()); -} - -/** - * 指定ディレクトリからスキルを読み込む - */ -function loadSkillsFromDir(skillsDir: string): Skill[] { - const skills: Skill[] = []; - - if (!existsSync(skillsDir)) { - return skills; - } - - try { - const entries = readdirSync(skillsDir); - - for (const entry of entries) { - const entryPath = join(skillsDir, entry); - const stat = statSync(entryPath); - - if (stat.isDirectory()) { - // skills/skill-name/SKILL.md 形式 - const skillFile = join(entryPath, 'SKILL.md'); - if (existsSync(skillFile)) { - const skill = parseSkillFile(skillFile, entry); - if (skill) { - skills.push(skill); - } - } - } else if (entry.endsWith('.md') && entry !== 'README.md') { - // skills/skill-name.md 形式 - const skillName = basename(entry, '.md'); - const skill = parseSkillFile(entryPath, skillName); - if (skill) { - skills.push(skill); - } - } - } - } catch (err) { - logger.error('Failed to load skills:', err); - } - - return skills; -} - -/** - * SKILL.mdファイルをパースしてスキル情報を抽出 - */ -function parseSkillFile(filePath: string, defaultName: string): Skill | null { - try { - const content = readFileSync(filePath, 'utf-8'); - - // フロントマターからdescriptionを抽出 - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); - let description = ''; - let name = defaultName; - - if (frontmatterMatch) { - const frontmatter = frontmatterMatch[1]; - const descMatch = frontmatter.match(/description:\s*["']?([^"'\n]+)["']?/); - const nameMatch = frontmatter.match(/name:\s*["']?([^"'\n]+)["']?/); - - if (descMatch) { - description = descMatch[1].trim(); - } - if (nameMatch) { - name = nameMatch[1].trim(); - } - } - - // フロントマターがない場合、最初の見出しや段落から説明を取得 - if (!description) { - const lines = content - .split('\n') - .filter((l) => l.trim() && !l.startsWith('#') && !l.startsWith('---')); - if (lines.length > 0) { - description = lines[0].slice(0, 100); - } - } - - return { - name, - description: description || '(説明なし)', - path: filePath, - }; - } catch (err) { - logger.warn(`Failed to parse skill file: ${filePath}`, err); - return null; - } -} - -/** - * スキル一覧をフォーマット(Discord 2000文字制限対応) - */ -export function formatSkillList(skills: Skill[]): string { - if (skills.length === 0) { - return '📚 利用可能なスキルはありません\n\n`skills/` ディレクトリにSKILL.mdを追加してください。'; - } - - const lines = [`📚 **利用可能なスキル** (${skills.length}件)`, '']; - for (const skill of skills) { - // 説明を50文字に切り詰め - const shortDesc = - skill.description.length > 50 ? `${skill.description.slice(0, 50)}...` : skill.description; - lines.push(`• **${skill.name}**: ${shortDesc}`); - } - lines.push('', '使い方: `/skill <スキル名>`'); - - const result = lines.join('\n'); - // Discord文字数制限対応 - if (result.length > DISCORD_SAFE_LENGTH) { - const shortLines = [`📚 **利用可能なスキル** (${skills.length}件)`, '']; - for (const skill of skills) { - shortLines.push(`• **${skill.name}**`); - } - shortLines.push('', '使い方: `/skill <スキル名>`'); - return shortLines.join('\n'); - } - return result; -} diff --git a/src/lib/system-commands.ts b/src/lib/system-commands.ts index 614d464..8fe44b0 100644 --- a/src/lib/system-commands.ts +++ b/src/lib/system-commands.ts @@ -7,7 +7,7 @@ const logger = createLogger('system-commands'); * AIの応答から SYSTEM_COMMAND: を検知して実行 * 形式: SYSTEM_COMMAND:restart */ -export function handleSettingsFromResponse(text: string): void { +export function handleSystemCommand(text: string): void { const commands = text.match(/^SYSTEM_COMMAND:(.+)$/gm); if (!commands) return; diff --git a/src/mcp/context.ts b/src/mcp/context.ts new file mode 100644 index 0000000..24a72ba --- /dev/null +++ b/src/mcp/context.ts @@ -0,0 +1,29 @@ +/** Helper to build MCP tool text response */ +export function mcpText(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +/** + * Shared request context — set before each query() call. + * No race condition because Brain serializes execution. + */ +export interface RequestContext { + channelId: string; + guildId?: string; +} + +export class RunContext { + private current: RequestContext = { channelId: '' }; + + set(ctx: RequestContext): void { + this.current = { ...ctx }; + } + + get(): RequestContext { + return this.current; + } + + clear(): void { + this.current = { channelId: '' }; + } +} diff --git a/src/mcp/discord-tools.ts b/src/mcp/discord-tools.ts new file mode 100644 index 0000000..956a10c --- /dev/null +++ b/src/mcp/discord-tools.ts @@ -0,0 +1,202 @@ +import { tool } from '@anthropic-ai/claude-agent-sdk'; +import { ChannelType, type Client } from 'discord.js'; +import { z } from 'zod/v4'; +import { isSendableChannel } from '../discord/channel-utils.js'; +import { + DISCORD_MAX_LENGTH, + ERROR_TRUNCATE_LENGTH, + HISTORY_DEFAULT_COUNT, + HISTORY_MAX_COUNT, + TIMEZONE, +} from '../lib/constants.js'; +import { toErrorMessage } from '../lib/error-utils.js'; +import { createLogger } from '../lib/logger.js'; +import { splitMessage } from '../lib/message-utils.js'; +import { mcpText, type RunContext } from './context.js'; + +const logger = createLogger('mcp-discord'); + +export function createDiscordTools(client: Client, runContext: RunContext) { + const discordSend = tool( + 'discord_send', + 'Send a message to a Discord channel. The channel_id must be in the same guild as the current channel.', + { + channel_id: z.string().describe('Target channel ID'), + message: z.string().describe('Message content'), + }, + async (args) => { + try { + const channel = await client.channels.fetch(args.channel_id); + if (!isSendableChannel(channel)) { + return mcpText('Error: Channel is not sendable'); + } + + // Guild validation + const ctx = runContext.get(); + if (ctx.guildId) { + const targetGuildId = 'guildId' in channel ? (channel.guildId as string) : undefined; + if (targetGuildId && ctx.guildId !== targetGuildId) { + return mcpText('Error: Cannot send to a channel in a different guild'); + } + } + + const chunks = splitMessage(args.message, DISCORD_MAX_LENGTH); + for (const chunk of chunks) { + await channel.send({ content: chunk, allowedMentions: { parse: [] } }); + } + + const channelName = 'name' in channel ? channel.name : 'unknown'; + logger.info(`Sent message to #${channelName} (${chunks.length} chunk(s))`); + return mcpText(`Sent message to #${channelName}`); + } catch (err) { + logger.error('Failed to send message:', err); + return mcpText(`Error: Failed to send message — ${toErrorMessage(err)}`); + } + } + ); + + const discordChannels = tool( + 'discord_channels', + 'List all text channels in the current guild.', + {}, + async () => { + try { + const ctx = runContext.get(); + if (!ctx.guildId) { + return mcpText('Error: No guild context (DM?)'); + } + + const guild = client.guilds.cache.get(ctx.guildId); + if (!guild) { + return mcpText('Error: Guild not found'); + } + + const channels = guild.channels.cache + .filter((c) => c.type === ChannelType.GuildText) + .map((c) => `- #${c.name} (ID: ${c.id})`) + .join('\n'); + return mcpText(`Channels:\n${channels}`); + } catch (err) { + logger.error('Failed to list channels:', err); + return mcpText('Error: Failed to list channels'); + } + } + ); + + const discordHistory = tool( + 'discord_history', + 'Fetch recent messages from a Discord channel. Returns message history as text.', + { + count: z.number().optional().describe('Number of messages to fetch (default 10, max 100)'), + offset: z.number().optional().describe('Number of messages to skip from latest'), + channel_id: z.string().optional().describe('Channel ID (defaults to current channel)'), + }, + async (args) => { + try { + const count = Math.min(args.count ?? HISTORY_DEFAULT_COUNT, HISTORY_MAX_COUNT); + const offset = args.offset ?? 0; + const ctx = runContext.get(); + const targetChannelId = args.channel_id ?? ctx.channelId; + + if (!targetChannelId) { + return mcpText('Error: No channel specified'); + } + + const targetChannel = await client.channels.fetch(targetChannelId); + if (!targetChannel || !('messages' in targetChannel)) { + return mcpText('Error: Channel not found or has no messages'); + } + + let beforeId: string | undefined; + if (offset > 0) { + const skipMessages = await targetChannel.messages.fetch({ limit: offset }); + if (skipMessages.size > 0) { + beforeId = skipMessages.lastKey(); + } + } + + const fetchOptions: { limit: number; before?: string } = { limit: count }; + if (beforeId) fetchOptions.before = beforeId; + const messages = await targetChannel.messages.fetch(fetchOptions); + const channelName = 'name' in targetChannel ? targetChannel.name : 'unknown'; + + const rangeStart = offset; + const rangeEnd = offset + messages.size; + const messageList = messages + .reverse() + .map((m) => { + const time = m.createdAt.toLocaleString('ja-JP', { timeZone: TIMEZONE }); + const content = (m.content || '(attachment only)') + .slice(0, ERROR_TRUNCATE_LENGTH) + .replace(/<#(\d+)>/g, '#$1'); + const attachments = + m.attachments.size > 0 + ? `\n${m.attachments.map((a) => ` 📎 ${a.name} ${a.url}`).join('\n')}` + : ''; + return `[${time}] (ID:${m.id}) ${m.author.tag}: ${content}${attachments}`; + }) + .join('\n'); + + const offsetLabel = offset > 0 ? `${rangeStart}–${rangeEnd}` : `latest ${messages.size}`; + return mcpText(`#${channelName} history (${offsetLabel}):\n${messageList}`); + } catch (err) { + logger.error('Failed to fetch history:', err); + return mcpText('Error: Failed to fetch history'); + } + } + ); + + const discordDelete = tool( + 'discord_delete', + 'Delete a bot message by message ID or Discord message link. Only bot messages can be deleted.', + { + message_id_or_link: z.string().describe('Message ID or Discord message link'), + channel_id: z + .string() + .optional() + .describe('Channel ID (required if using a plain message ID, not a link)'), + }, + async (args) => { + try { + let messageId: string; + let targetChannelId: string | undefined; + + const linkMatch = args.message_id_or_link.match( + /discord\.com\/channels\/\d+\/(\d+)\/(\d+)/ + ); + if (linkMatch) { + targetChannelId = linkMatch[1]; + messageId = linkMatch[2]; + } else if (/^\d+$/.test(args.message_id_or_link)) { + messageId = args.message_id_or_link; + targetChannelId = args.channel_id ?? runContext.get().channelId; + } else { + return mcpText('Error: Invalid format. Provide a message ID or Discord link.'); + } + + if (!targetChannelId) { + return mcpText('Error: No channel specified'); + } + + const channel = await client.channels.fetch(targetChannelId); + if (!channel || !('messages' in channel)) { + return mcpText('Error: Channel not found'); + } + + const msg = await channel.messages.fetch(messageId); + if (msg.author.id !== client.user?.id) { + return mcpText('Error: Can only delete own (bot) messages'); + } + + await msg.delete(); + logger.info(`Deleted message ${messageId} in channel ${targetChannelId}`); + return mcpText('Message deleted'); + } catch (err) { + logger.error('Failed to delete message:', err); + return mcpText('Error: Failed to delete message'); + } + } + ); + + return [discordSend, discordChannels, discordHistory, discordDelete]; +} diff --git a/src/mcp/schedule-tools.ts b/src/mcp/schedule-tools.ts new file mode 100644 index 0000000..d79f586 --- /dev/null +++ b/src/mcp/schedule-tools.ts @@ -0,0 +1,103 @@ +import { tool } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod/v4'; +import { toErrorMessage } from '../lib/error-utils.js'; +import { createLogger } from '../lib/logger.js'; +import { getTypeLabel } from '../scheduler/schedule-handler.js'; +import { formatScheduleList, parseScheduleInput, type Scheduler } from '../scheduler/scheduler.js'; +import { mcpText, type RunContext } from './context.js'; + +const logger = createLogger('mcp-schedule'); + +export function createScheduleTools(scheduler: Scheduler, runContext: RunContext) { + const scheduleCreate = tool( + 'schedule_create', + 'Create a new schedule. Supports: "in 30 minutes message", "15:00 message", "every day 9:00 message", "cron 0 9 * * * message", etc. Prefix with channel ID to target another channel.', + { input: z.string().describe('Schedule input string') }, + async (args) => { + try { + const parsed = parseScheduleInput(args.input); + if (!parsed) { + return mcpText( + 'Error: Could not parse schedule input. Supported formats: "in 30 minutes msg", "15:00 msg", "every day 9:00 msg", "cron 0 9 * * * msg"' + ); + } + + const ctx = runContext.get(); + const targetChannel = parsed.targetChannelId || ctx.channelId; + const schedule = scheduler.add({ + ...parsed, + channelId: targetChannel, + platform: 'discord', + }); + + const channelInfoLabel = parsed.targetChannelId + ? ` → channel ${parsed.targetChannelId}` + : ''; + const typeLabel = getTypeLabel(schedule.type, { + expression: schedule.expression, + runAt: schedule.runAt, + channelInfo: channelInfoLabel, + }); + + logger.info(`Schedule created: ${schedule.id}`); + return mcpText( + `Schedule created.\n${typeLabel}\nMessage: ${schedule.message}\nID: ${schedule.id}` + ); + } catch (err) { + logger.error('Failed to create schedule:', err); + return mcpText(`Error: ${toErrorMessage(err)}`); + } + } + ); + + const scheduleList = tool('schedule_list', 'List all schedules.', {}, async () => { + try { + const schedules = scheduler.list(); + return mcpText(formatScheduleList(schedules)); + } catch (err) { + logger.error('Failed to list schedules:', err); + return mcpText(`Error: ${toErrorMessage(err)}`); + } + }); + + const scheduleRemove = tool( + 'schedule_remove', + 'Remove a schedule by ID.', + { id: z.string().describe('Schedule ID to remove') }, + async (args) => { + try { + const removed = scheduler.remove(args.id); + if (removed) { + logger.info(`Schedule removed: ${args.id}`); + return mcpText(`Schedule ${args.id} removed`); + } + return mcpText(`Error: Schedule ${args.id} not found`); + } catch (err) { + logger.error('Failed to remove schedule:', err); + return mcpText(`Error: ${toErrorMessage(err)}`); + } + } + ); + + const scheduleToggle = tool( + 'schedule_toggle', + 'Enable or disable a schedule by ID.', + { id: z.string().describe('Schedule ID to toggle') }, + async (args) => { + try { + const schedule = scheduler.toggle(args.id); + if (schedule) { + const status = schedule.enabled ? 'enabled' : 'disabled'; + logger.info(`Schedule ${args.id} toggled to ${status}`); + return mcpText(`Schedule ${args.id} is now ${status}`); + } + return mcpText(`Error: Schedule ${args.id} not found`); + } catch (err) { + logger.error('Failed to toggle schedule:', err); + return mcpText(`Error: ${toErrorMessage(err)}`); + } + } + ); + + return [scheduleCreate, scheduleList, scheduleRemove, scheduleToggle]; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..ea20e0e --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,19 @@ +import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import type { Client } from 'discord.js'; +import type { Scheduler } from '../scheduler/scheduler.js'; +import type { RunContext } from './context.js'; +import { createDiscordTools } from './discord-tools.js'; +import { createScheduleTools } from './schedule-tools.js'; + +/** + * Create a combined MCP server with all thor tools. + */ +export function createThorMcpServer(client: Client, scheduler: Scheduler, runContext: RunContext) { + return createSdkMcpServer({ + name: 'thor', + tools: [ + ...createDiscordTools(client, runContext), + ...createScheduleTools(scheduler, runContext), + ], + }); +} diff --git a/src/scheduler/schedule-handler.ts b/src/scheduler/schedule-handler.ts index 2bdd9f6..ad1d861 100644 --- a/src/scheduler/schedule-handler.ts +++ b/src/scheduler/schedule-handler.ts @@ -1,22 +1,13 @@ -import type { ChatInputCommandInteraction, Message } from 'discord.js'; -import { - sendChunkedMessage, - sendChunkedReply, - sendScheduleContent, -} from '../discord/discord-send.js'; -import { isSendableChannel } from '../discord/discord-types.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import { sendScheduleContent } from '../discord/schedule-send.js'; import { TIMEZONE } from '../lib/constants.js'; -import { createLogger } from '../lib/logger.js'; import { formatScheduleList, - type Platform, parseScheduleInput, type Scheduler, type ScheduleType, } from './scheduler.js'; -const logger = createLogger('schedule-handler'); - /** スケジュールタイプに応じたラベルを生成 */ export function getTypeLabel( type: ScheduleType, @@ -65,7 +56,7 @@ export async function handleScheduleCommand( const schedule = scheduler.add({ ...parsed, channelId: targetChannel, - platform: 'discord' as Platform, + platform: 'discord', }); const channelInfoLabel = parsed.targetChannelId ? ` → <#${parsed.targetChannelId}>` : ''; @@ -90,7 +81,7 @@ export async function handleScheduleCommand( case 'list': { const schedules = scheduler.list(); const content = formatScheduleList(schedules, schedulerConfig); - await sendScheduleContent(interaction, content, 'interaction'); + await sendScheduleContent(interaction, content); return; } @@ -116,260 +107,3 @@ export async function handleScheduleCommand( } } } - -export async function handleScheduleMessage( - message: Message, - prompt: string, - scheduler: Scheduler, - schedulerConfig?: { enabled: boolean; startupEnabled: boolean } -): Promise { - const args = prompt.replace(/^!schedule\s*/, '').trim(); - const channelId = message.channel.id; - - // !schedule (引数なし) or !schedule list → 一覧(全件表示) - if (!args || args === 'list') { - const schedules = scheduler.list(); - const content = formatScheduleList(schedules, schedulerConfig); - await sendChunkedReply(message, content); - return; - } - - // !schedule remove [番号2] [番号3] ... - if (args.startsWith('remove ') || args.startsWith('delete ') || args.startsWith('rm ')) { - const parts = args.split(/\s+/).slice(1).filter(Boolean); - if (parts.length === 0) { - await message.reply('使い方: `!schedule remove [番号2] ...`'); - return; - } - - const schedules = scheduler.list(); - const deletedIds: string[] = []; - const errors: string[] = []; - - const targets = parts - .map((p) => { - const num = parseInt(p, 10); - if (!Number.isNaN(num) && num > 0 && !p.startsWith('sch_')) { - if (num > schedules.length) { - errors.push(`番号 ${num} は範囲外`); - return null; - } - return { index: num, id: schedules[num - 1].id }; - } - return { index: 0, id: p }; - }) - .filter((t): t is { index: number; id: string } => t !== null) - .sort((a, b) => b.index - a.index); - - for (const target of targets) { - if (scheduler.remove(target.id)) { - deletedIds.push(target.id); - } else { - errors.push(`ID ${target.id} が見つからない`); - } - } - - const remaining = scheduler.list(); - let response = ''; - if (deletedIds.length > 0) { - response += `✅ ${deletedIds.length}件削除しました\n\n`; - } - if (errors.length > 0) { - response += `⚠️ エラー: ${errors.join(', ')}\n\n`; - } - response += formatScheduleList(remaining, schedulerConfig); - await sendChunkedReply(message, response); - return; - } - - // !schedule toggle - if (args.startsWith('toggle ')) { - const idOrIndex = args.split(/\s+/)[1]; - if (!idOrIndex) { - await message.reply('使い方: `!schedule toggle `'); - return; - } - - let targetId = idOrIndex; - const indexNum = parseInt(idOrIndex, 10); - if (!Number.isNaN(indexNum) && indexNum > 0 && !idOrIndex.startsWith('sch_')) { - const schedules = scheduler.list(channelId); - if (indexNum > schedules.length) { - await message.reply(`❌ 番号 ${indexNum} は範囲外です(1〜${schedules.length})`); - return; - } - targetId = schedules[indexNum - 1].id; - } - - const schedule = scheduler.toggle(targetId); - if (schedule) { - const status = schedule.enabled ? '✅ 有効化' : '⏸️ 無効化'; - const all = scheduler.list(channelId); - const listContent = formatScheduleList(all, schedulerConfig); - await sendChunkedReply(message, `${status}しました: ${targetId}\n\n${listContent}`); - } else { - await message.reply(`❌ ID \`${targetId}\` が見つかりません`); - } - return; - } - - // !schedule add or !schedule (addなしでも追加) - const input = args.startsWith('add ') ? args.replace(/^add\s+/, '') : args; - const parsed = parseScheduleInput(input); - if (!parsed) { - await message.reply( - '❌ 入力を解析できませんでした\n\n' + - '**対応フォーマット:**\n' + - '• `!schedule 30分後 メッセージ`\n' + - '• `!schedule 15:00 メッセージ`\n' + - '• `!schedule 毎日 9:00 メッセージ`\n' + - '• `!schedule 毎週月曜 10:00 メッセージ`\n' + - '• `!schedule cron 0 9 * * * メッセージ`\n' + - '• `!schedule list` / `!schedule remove `' - ); - return; - } - - try { - const targetChannel = parsed.targetChannelId || channelId; - const schedule = scheduler.add({ - ...parsed, - channelId: targetChannel, - platform: 'discord' as Platform, - }); - - const channelInfoLabel = parsed.targetChannelId ? ` → <#${parsed.targetChannelId}>` : ''; - const typeLabel = getTypeLabel(schedule.type, { - expression: schedule.expression, - runAt: schedule.runAt, - channelInfo: channelInfoLabel, - }); - - await message.reply( - `✅ スケジュールを追加しました\n\n${typeLabel}\n📝 ${schedule.message}\n🆔 \`${schedule.id}\`` - ); - } catch (error) { - await message.reply(`❌ ${error instanceof Error ? error.message : 'エラーが発生しました'}`); - } -} - -/** - * AI応答内の !schedule コマンドを実行 - */ -export async function executeScheduleFromResponse( - text: string, - sourceMessage: Message, - scheduler: Scheduler, - schedulerConfig?: { enabled: boolean; startupEnabled: boolean } -): Promise { - const args = text.replace(/^!schedule\s*/, '').trim(); - const channelId = sourceMessage.channel.id; - const channel = sourceMessage.channel; - - // list コマンド(全件表示) - if (!args || args === 'list') { - const schedules = scheduler.list(); - const content = formatScheduleList(schedules, schedulerConfig); - if (isSendableChannel(channel)) { - await sendChunkedMessage(channel, content); - } - return; - } - - // remove コマンド(複数対応) - if (args.startsWith('remove ') || args.startsWith('delete ') || args.startsWith('rm ')) { - const parts = args.split(/\s+/).slice(1).filter(Boolean); - if (parts.length === 0) return; - - const schedules = scheduler.list(); - const deletedIds: string[] = []; - - const targets = parts - .map((p) => { - const num = parseInt(p, 10); - if (!Number.isNaN(num) && num > 0 && !p.startsWith('sch_')) { - if (num > schedules.length) return null; - return { index: num, id: schedules[num - 1].id }; - } - return { index: 0, id: p }; - }) - .filter((t): t is { index: number; id: string } => t !== null) - .sort((a, b) => b.index - a.index); - - for (const target of targets) { - if (scheduler.remove(target.id)) { - deletedIds.push(target.id); - } - } - - if (isSendableChannel(channel) && deletedIds.length > 0) { - const remaining = scheduler.list(); - const content = `✅ ${deletedIds.length}件削除しました\n\n${formatScheduleList(remaining, schedulerConfig)}`; - await sendChunkedMessage(channel, content); - } - return; - } - - // toggle コマンド - if (args.startsWith('toggle ')) { - const idOrIndex = args.split(/\s+/)[1]; - if (!idOrIndex) return; - - let targetId = idOrIndex; - const indexNum = parseInt(idOrIndex, 10); - if (!Number.isNaN(indexNum) && indexNum > 0 && !idOrIndex.startsWith('sch_')) { - const schedules = scheduler.list(channelId); - if (indexNum > schedules.length) { - if (isSendableChannel(channel)) { - await channel.send(`❌ 番号 ${indexNum} は範囲外です(1〜${schedules.length})`); - } - return; - } - targetId = schedules[indexNum - 1].id; - } - - const schedule = scheduler.toggle(targetId); - if (isSendableChannel(channel)) { - if (schedule) { - const status = schedule.enabled ? '✅ 有効化' : '⏸️ 無効化'; - const all = scheduler.list(channelId); - const listContent = formatScheduleList(all, schedulerConfig); - await sendChunkedMessage(channel, `${status}しました: ${targetId}\n\n${listContent}`); - } else { - await channel.send(`❌ ID \`${targetId}\` が見つかりません`); - } - } - return; - } - - const input = args.startsWith('add ') ? args.replace(/^add\s+/, '') : args; - const parsed = parseScheduleInput(input); - if (!parsed) { - logger.debug(`Failed to parse schedule input: ${input}`); - return; - } - - try { - const targetChannel = parsed.targetChannelId || channelId; - const schedule = scheduler.add({ - ...parsed, - channelId: targetChannel, - platform: 'discord' as Platform, - }); - - const channelInfoLabel = parsed.targetChannelId ? ` → <#${parsed.targetChannelId}>` : ''; - const typeLabel = getTypeLabel(schedule.type, { - expression: schedule.expression, - runAt: schedule.runAt, - channelInfo: channelInfoLabel, - }); - - if (isSendableChannel(channel)) { - await channel.send( - `✅ スケジュールを追加しました\n\n${typeLabel}\n📝 ${schedule.message}\n🆔 \`${schedule.id}\`` - ); - } - } catch (error) { - logger.error('Failed to add schedule from response:', error); - } -} diff --git a/src/scheduler/schedule-parser.ts b/src/scheduler/schedule-parser.ts index 4a6a13a..abb402c 100644 --- a/src/scheduler/schedule-parser.ts +++ b/src/scheduler/schedule-parser.ts @@ -1,5 +1,37 @@ +import { TIMEZONE } from '../lib/constants.js'; import type { ScheduleType } from './scheduler.js'; +const localTimeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: TIMEZONE, + hour: 'numeric', + minute: 'numeric', + hour12: false, +}); + +/** TIMEZONE での現在時刻の時・分を取得する */ +function getLocalTime(date: Date): { hours: number; minutes: number } { + const parts = localTimeFormatter.formatToParts(date); + const hours = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10); + const minutes = parseInt(parts.find((p) => p.type === 'minute')?.value ?? '0', 10); + return { hours, minutes }; +} + +/** TIMEZONE でのローカル日時から Date を生成する */ +function localToDate(dateStr: string, hour: number, min: number): Date { + // 一旦 TIMEZONE のオフセットを動的に計算 + const refDate = new Date(`${dateStr}T12:00:00Z`); + const utcStr = refDate.toLocaleString('en-US', { timeZone: 'UTC', hour12: false }); + const localStr = refDate.toLocaleString('en-US', { timeZone: TIMEZONE, hour12: false }); + const utcMs = new Date(utcStr).getTime(); + const localMs = new Date(localStr).getTime(); + const offsetMs = localMs - utcMs; + // ローカル時刻を UTC に変換 + const localTarget = new Date( + `${dateStr}T${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}:00Z` + ); + return new Date(localTarget.getTime() - offsetMs); +} + /** * 自然言語風の入力をパースしてスケジュールパラメータに変換 * @@ -119,14 +151,10 @@ export function parseScheduleInput(input: string): { const hour = parseInt(timeMatch[1], 10); const min = parseInt(timeMatch[2], 10); const now = new Date(); - // Asia/Tokyo で設定 - const jstOffset = 9 * 60; // JST = UTC+9 - const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes(); - const jstMinutes = utcMinutes + jstOffset; + const local = getLocalTime(now); const targetMinutes = hour * 60 + min; - // JSTベースで今日か明日かを判定 - const currentJstMinutes = jstMinutes % (24 * 60); - let diffMinutes = targetMinutes - currentJstMinutes; + const currentMinutes = local.hours * 60 + local.minutes; + let diffMinutes = targetMinutes - currentMinutes; if (diffMinutes <= 0) { diffMinutes += 24 * 60; // 明日 } @@ -144,10 +172,7 @@ export function parseScheduleInput(input: string): { const dateStr = dateTimeMatch[1]; const hour = parseInt(dateTimeMatch[2], 10); const min = parseInt(dateTimeMatch[3], 10); - // JST として解釈 - const runAt = new Date( - `${dateStr}T${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}:00+09:00` - ); + const runAt = localToDate(dateStr, hour, min); return { type: 'once', runAt: runAt.toISOString(), diff --git a/src/scheduler/scheduler-bridge.ts b/src/scheduler/scheduler-bridge.ts deleted file mode 100644 index 575837d..0000000 --- a/src/scheduler/scheduler-bridge.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Client } from 'discord.js'; -import type { AgentRunner } from '../agent/agent-runner.js'; -import { handleDiscordCommand } from '../discord/discord-commands.js'; -import { isSendableChannel } from '../discord/discord-types.js'; - -import type { Config } from '../lib/config.js'; -import { COMMAND_LOG_TRUNCATE_LENGTH, DISCORD_SAFE_LENGTH } from '../lib/constants.js'; -import { formatErrorDetail, toErrorMessage } from '../lib/error-utils.js'; -import { executeCommandsWithFeedback } from '../lib/feedback-loop.js'; -import { extractFilePaths, stripFilePaths } from '../lib/file-utils.js'; -import { createLogger } from '../lib/logger.js'; -import { splitMessage } from '../lib/message-utils.js'; -import { parseAgentResponse } from '../lib/response-parser.js'; -import type { Scheduler } from './scheduler.js'; - -const logger = createLogger('scheduler'); - -/** - * スケジューラにDiscord連携関数を登録 - */ -export function registerSchedulerHandlers( - scheduler: Scheduler, - client: Client, - agentRunner: AgentRunner, - config: Config -): void { - // メッセージ送信関数 - scheduler.registerSender('discord', async (channelId, msg) => { - const channel = await client.channels.fetch(channelId); - if (isSendableChannel(channel)) { - await channel.send(msg); - } - }); - - // エージェント実行関数 - scheduler.registerAgentRunner('discord', async (prompt, channelId) => { - const channel = await client.channels.fetch(channelId); - if (!isSendableChannel(channel)) { - throw new Error(`Channel not found: ${channelId}`); - } - - // プロンプト内の !discord send コマンドを先に直接実行 - const parsed = parseAgentResponse(prompt); - for (const cmd of parsed.commands) { - logger.info( - `Executing discord command from prompt: ${cmd.slice(0, COMMAND_LOG_TRUNCATE_LENGTH)}...` - ); - await handleDiscordCommand(cmd, client, undefined, channelId); - } - - // !discord send 以外のテキストが残っていればAIに渡す - const remainingPrompt = parsed.displayText; - if (!remainingPrompt) { - logger.info('Prompt contained only discord commands, skipping agent'); - return parsed.commands.map((c) => `✅ ${c.slice(0, 50)}`).join('\n'); - } - - try { - const { result } = await agentRunner.run(remainingPrompt, { - channelId, - }); - - // AI応答内の !discord / !schedule コマンドを処理し、フィードバックを再注入 - await executeCommandsWithFeedback(result, client, scheduler, { - fallbackChannelId: channelId, - runAgent: async (prompt) => { - const run = await agentRunner.run(prompt, { channelId }); - return run.result; - }, - }); - - // ファイルパス抽出(ワークスペース内のみ許可) - const filePaths = extractFilePaths(result, config.agent.workdir); - const displayText = filePaths.length > 0 ? stripFilePaths(result) : result; - - // 2000文字超の応答は分割送信 - const textChunks = splitMessage(displayText, DISCORD_SAFE_LENGTH); - for (const chunk of textChunks) { - await channel.send(chunk); - } - - if (filePaths.length > 0) { - await channel.send({ - files: filePaths.map((fp) => ({ attachment: fp })), - }); - } - - return result; - } catch (error) { - if (error instanceof Error && error.message === 'Request cancelled by user') { - await channel.send('🛑 タスクを停止しました'); - } else { - await channel.send(formatErrorDetail(toErrorMessage(error))); - } - throw error; - } - }); -} diff --git a/src/scheduler/scheduler-discord.ts b/src/scheduler/scheduler-discord.ts new file mode 100644 index 0000000..1310c26 --- /dev/null +++ b/src/scheduler/scheduler-discord.ts @@ -0,0 +1,66 @@ +import type { Client } from 'discord.js'; +import type { Brain } from '../brain/brain.js'; +import { isSendableChannel } from '../discord/channel-utils.js'; + +import type { Config } from '../lib/config.js'; +import { CANCELLED_ERROR_MESSAGE, DISCORD_SAFE_LENGTH } from '../lib/constants.js'; +import { formatErrorDetail, toErrorMessage } from '../lib/error-utils.js'; +import { extractFilePaths, stripFilePaths } from '../lib/file-utils.js'; +import { splitMessage } from '../lib/message-utils.js'; +import type { Scheduler } from './scheduler.js'; + +/** + * スケジューラにDiscord連携関数を登録 + */ +export function registerSchedulerHandlers( + scheduler: Scheduler, + client: Client, + getBrain: () => Brain, + config: Config +): void { + // メッセージ送信関数 + scheduler.registerSender('discord', async (channelId, msg) => { + const channel = await client.channels.fetch(channelId); + if (isSendableChannel(channel)) { + await channel.send(msg); + } + }); + + // エージェント実行関数 + scheduler.registerAgentRunner('discord', async (prompt, channelId) => { + const channel = await client.channels.fetch(channelId); + if (!isSendableChannel(channel)) { + throw new Error(`Channel not found: ${channelId}`); + } + + try { + const brain = getBrain(); + const { result } = await brain.run(prompt, { channelId }); + + // ファイルパス抽出 + const filePaths = extractFilePaths(result, config.agent.workdir); + const displayText = filePaths.length > 0 ? stripFilePaths(result) : result; + + // 2000文字超の応答は分割送信 + const textChunks = splitMessage(displayText, DISCORD_SAFE_LENGTH); + for (const chunk of textChunks) { + await channel.send(chunk); + } + + if (filePaths.length > 0) { + await channel.send({ + files: filePaths.map((fp) => ({ attachment: fp })), + }); + } + + return result; + } catch (error) { + if (error instanceof Error && error.message === CANCELLED_ERROR_MESSAGE) { + await channel.send('Task stopped'); + } else { + await channel.send(formatErrorDetail(toErrorMessage(error))); + } + throw error; + } + }); +} diff --git a/tests/agent-runner.test.ts b/tests/agent-runner.test.ts index 61c18c1..7a35511 100644 --- a/tests/agent-runner.test.ts +++ b/tests/agent-runner.test.ts @@ -1,38 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { createAgentRunner, mergeTexts } from '../src/agent/agent-runner.js'; +import { mergeTexts } from '../src/agent/agent-runner.js'; describe('agent-runner', () => { - describe('createAgentRunner', () => { - it('should create a runner instance', () => { - const runner = createAgentRunner({ workdir: './workspace', timeoutMs: 300000 }); - expect(runner).toBeDefined(); - expect(runner.run).toBeDefined(); - expect(runner.runStream).toBeDefined(); - }); - - it('should have cancel and cancelAll methods', () => { - const runner = createAgentRunner({ workdir: './workspace', timeoutMs: 300000 }); - expect(runner.cancel).toBeDefined(); - expect(runner.cancelAll).toBeDefined(); - }); - - it('should have destroy and shutdown methods', () => { - const runner = createAgentRunner({ workdir: './workspace', timeoutMs: 300000 }); - expect(runner.destroy).toBeDefined(); - expect(runner.shutdown).toBeDefined(); - }); - - it('should have getStatus method', () => { - const runner = createAgentRunner({ workdir: './workspace', timeoutMs: 300000 }); - expect(runner.getStatus).toBeDefined(); - const status = runner.getStatus?.(); - expect(status).toHaveProperty('poolSize'); - expect(status).toHaveProperty('maxProcesses'); - expect(status).toHaveProperty('channels'); - runner.shutdown?.(); - }); - }); - describe('mergeTexts', () => { it('should return streamed when result is empty', () => { expect(mergeTexts('hello world', '')).toBe('hello world'); @@ -43,34 +12,23 @@ describe('agent-runner', () => { }); it('should return streamed when result is a suffix of streamed', () => { - // result が streamed の末尾と一致 → 重複なので streamed をそのまま返す - const streamed = '!discord send <#123> hello\nfinal answer'; + const streamed = 'first part\nfinal answer'; const result = 'final answer'; expect(mergeTexts(streamed, result)).toBe(streamed); }); it('should return result when streamed is a suffix of result', () => { const streamed = 'final answer'; - const result = '!discord send <#123> hello\nfinal answer'; + const result = 'first part\nfinal answer'; expect(mergeTexts(streamed, result)).toBe(result); }); it('should concatenate when no overlap', () => { - // ツール呼び出し前のテキストが streamed にあり、result には最後のテキストだけ - const streamed = '!discord send <#123> hello\n調べてみるね...'; - const result = '調査結果はこちら'; + const streamed = 'first part'; + const result = 'second part'; expect(mergeTexts(streamed, result)).toBe(`${streamed}\n${result}`); }); - it('should handle discord commands in streamed text that are missing from result', () => { - // これが問題2の核心ケース - const streamed = '!discord send <#work_01> 作業開始します\nツール実行中...\n結果報告'; - const result = '結果報告'; - // result は streamed の末尾 → streamed をそのまま返す → !discord send が保持される - expect(mergeTexts(streamed, result)).toBe(streamed); - expect(mergeTexts(streamed, result)).toContain('!discord send'); - }); - it('should handle identical texts', () => { const text = 'same text'; expect(mergeTexts(text, text)).toBe(text); diff --git a/tests/base-runner.test.ts b/tests/base-runner.test.ts deleted file mode 100644 index ecead70..0000000 --- a/tests/base-runner.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { - buildPersistentSystemPrompt, - buildSystemPrompt, - CHAT_SYSTEM_PROMPT_PERSISTENT, - CHAT_SYSTEM_PROMPT_RESUME, - loadSoulMd, - loadUserMd, -} from '../src/agent/base-runner.js'; - -describe('loadUserMd', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'thor-test-')); - }); - - it('returns empty string when workdir is undefined', () => { - expect(loadUserMd()).toBe(''); - }); - - it('returns empty string when USER.md does not exist', () => { - expect(loadUserMd(tempDir)).toBe(''); - }); - - it('returns formatted content when USER.md exists', () => { - const content = '# User Info\nName: Alice'; - writeFileSync(join(tempDir, 'USER.md'), content); - - const result = loadUserMd(tempDir); - expect(result).toBe(`\n\n## USER.md\n\n${content}`); - }); - - it('returns empty string on read failure', () => { - const userPath = join(tempDir, 'USER.md'); - writeFileSync(userPath, 'content'); - chmodSync(userPath, 0o000); - - const result = loadUserMd(tempDir); - expect(result).toBe(''); - - chmodSync(userPath, 0o644); - }); -}); - -describe('loadSoulMd', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'thor-test-')); - }); - - it('returns empty string when workdir is undefined', () => { - expect(loadSoulMd()).toBe(''); - }); - - it('returns empty string when SOUL.md does not exist', () => { - expect(loadSoulMd(tempDir)).toBe(''); - }); - - it('returns formatted content when SOUL.md exists', () => { - const content = '# My Soul\nBe helpful and kind.'; - writeFileSync(join(tempDir, 'SOUL.md'), content); - - const result = loadSoulMd(tempDir); - expect(result).toBe(`\n\n## SOUL.md\n\n${content}`); - }); - - it('returns empty string on read failure', () => { - const soulPath = join(tempDir, 'SOUL.md'); - writeFileSync(soulPath, 'content'); - chmodSync(soulPath, 0o000); - - const result = loadSoulMd(tempDir); - expect(result).toBe(''); - - chmodSync(soulPath, 0o644); - }); -}); - -describe('buildSystemPrompt', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'thor-test-')); - }); - - it('includes SOUL.md content when workdir has SOUL.md', () => { - const soul = 'Be creative and bold.'; - writeFileSync(join(tempDir, 'SOUL.md'), soul); - - const prompt = buildSystemPrompt(tempDir); - expect(prompt).toContain('## SOUL.md'); - expect(prompt).toContain(soul); - expect(prompt).toContain(CHAT_SYSTEM_PROMPT_RESUME); - }); - - it('includes USER.md content when workdir has USER.md', () => { - const user = 'Name: Alice'; - writeFileSync(join(tempDir, 'USER.md'), user); - - const prompt = buildSystemPrompt(tempDir); - expect(prompt).toContain('## USER.md'); - expect(prompt).toContain(user); - }); - - it('places USER.md before SOUL.md', () => { - writeFileSync(join(tempDir, 'USER.md'), 'user info'); - writeFileSync(join(tempDir, 'SOUL.md'), 'soul info'); - - const prompt = buildSystemPrompt(tempDir); - const userIndex = prompt.indexOf('## USER.md'); - const soulIndex = prompt.indexOf('## SOUL.md'); - expect(userIndex).toBeLessThan(soulIndex); - }); - - it('works without workdir (backward compatible)', () => { - const prompt = buildSystemPrompt(); - expect(prompt).toContain(CHAT_SYSTEM_PROMPT_RESUME); - expect(prompt).not.toContain('## SOUL.md'); - expect(prompt).not.toContain('## USER.md'); - }); -}); - -describe('buildPersistentSystemPrompt', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'thor-test-')); - }); - - it('includes SOUL.md content when workdir has SOUL.md', () => { - const soul = 'Be creative and bold.'; - writeFileSync(join(tempDir, 'SOUL.md'), soul); - - const prompt = buildPersistentSystemPrompt(tempDir); - expect(prompt).toContain('## SOUL.md'); - expect(prompt).toContain(soul); - expect(prompt).toContain(CHAT_SYSTEM_PROMPT_PERSISTENT); - }); - - it('includes USER.md content when workdir has USER.md', () => { - const user = 'Name: Alice'; - writeFileSync(join(tempDir, 'USER.md'), user); - - const prompt = buildPersistentSystemPrompt(tempDir); - expect(prompt).toContain('## USER.md'); - expect(prompt).toContain(user); - }); - - it('works without workdir (backward compatible)', () => { - const prompt = buildPersistentSystemPrompt(); - expect(prompt).toContain(CHAT_SYSTEM_PROMPT_PERSISTENT); - expect(prompt).not.toContain('## SOUL.md'); - expect(prompt).not.toContain('## USER.md'); - }); -}); diff --git a/tests/brain.test.ts b/tests/brain.test.ts new file mode 100644 index 0000000..c3c11bd --- /dev/null +++ b/tests/brain.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BrainRunner } from '../src/brain/brain.js'; +import { Brain, Priority } from '../src/brain/brain.js'; + +function createMockRunner(): BrainRunner { + return { + run: vi.fn().mockResolvedValue({ result: 'done', sessionId: 'sess-1' }), + runStream: vi + .fn() + .mockImplementation( + ( + _prompt: string, + callbacks: { onComplete?: (result: { result: string; sessionId: string }) => void } + ) => { + return new Promise((resolve) => { + setTimeout(() => { + const result = { result: 'streamed', sessionId: 'sess-1' }; + callbacks.onComplete?.(result); + resolve(result); + }, 10); + }); + } + ), + cancel: vi.fn().mockReturnValue(true), + shutdown: vi.fn(), + isBusy: vi.fn().mockReturnValue(false), + isAlive: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('sess-1'), + setSessionId: vi.fn(), + }; +} + +describe('Brain', () => { + let brain: Brain; + let runner: BrainRunner; + + beforeEach(() => { + runner = createMockRunner(); + brain = new Brain(runner); + }); + + afterEach(() => { + brain.shutdown(); + }); + + it('should create a Brain instance', () => { + expect(brain).toBeDefined(); + expect(brain.run).toBeDefined(); + expect(brain.runStream).toBeDefined(); + }); + + it('should have getStatus method', () => { + const status = brain.getStatus(); + expect(status).toHaveProperty('busy'); + expect(status).toHaveProperty('queueLength'); + expect(status).toHaveProperty('currentPriority'); + expect(status).toHaveProperty('alive'); + expect(status).toHaveProperty('sessionId'); + }); + + it('should submit tasks with USER priority via run()', async () => { + const result = await brain.run('hello'); + expect(result).toBeDefined(); + expect(result.sessionId).toBe('sess-1'); + }); + + it('should submit tasks with USER priority via runStream()', async () => { + const result = await brain.runStream('hello', {}); + expect(result).toBeDefined(); + }); + + it('should cancel current task', () => { + const cancelled = brain.cancel(); + expect(cancelled).toBe(true); + }); + + it('should cancel all tasks', () => { + const count = brain.cancelAll(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + it('should track idle time', () => { + const idleTime = brain.getIdleTime(); + expect(idleTime).toBeGreaterThanOrEqual(0); + }); + + it('should report busy state', () => { + expect(brain.isBusy()).toBe(false); + }); + + it('should get session ID', () => { + expect(brain.getSessionId()).toBe('sess-1'); + }); + + it('should accept tasks via submit with priority', async () => { + const result = await brain.submit({ + prompt: 'heartbeat check', + priority: Priority.HEARTBEAT, + }); + expect(result).toBeDefined(); + }); +}); + +describe('Priority', () => { + it('should have correct priority ordering', () => { + expect(Priority.USER).toBeLessThan(Priority.EVENT); + expect(Priority.EVENT).toBeLessThan(Priority.HEARTBEAT); + }); +}); diff --git a/tests/claude-code.test.ts b/tests/claude-code.test.ts deleted file mode 100644 index db4a24e..0000000 --- a/tests/claude-code.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ClaudeCodeRunner } from '../src/agent/claude-code.js'; - -// child_process をモック -vi.mock('child_process', () => { - const EventEmitter = require('node:events'); - - class MockProcess extends EventEmitter { - stdin = { write: vi.fn(), end: vi.fn() }; - stdout = new EventEmitter(); - stderr = new EventEmitter(); - killed = false; - - kill() { - this.killed = true; - this.emit('close', 0); - } - } - - let mockProcess: MockProcess; - - return { - spawn: vi.fn(() => { - mockProcess = new MockProcess(); - return mockProcess; - }), - getMockProcess: () => mockProcess, - }; -}); - -// fs をモック(loadProjectContext でファイル読み込みを防止) -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - existsSync: vi.fn(() => false), - readFileSync: vi.fn(() => ''), - }; -}); - -describe('ClaudeCodeRunner args', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - /** - * spawn に渡された引数を取得するヘルパー - */ - async function getSpawnArgs( - runner: ClaudeCodeRunner, - prompt: string, - options?: { sessionId?: string } - ) { - const { spawn, getMockProcess } = await import('node:child_process'); - - const runPromise = runner.run(prompt, options); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const spawnMock = spawn as ReturnType; - const callArgs = spawnMock.mock.calls[0]; - const command = callArgs[0] as string; - const args = callArgs[1] as string[]; - const spawnOptions = callArgs[2] as { cwd?: string }; - - // プロセスを終了させてクリーンアップ - const mockProcess = (getMockProcess as () => ReturnType)(); - mockProcess.stdout.emit( - 'data', - JSON.stringify({ - type: 'result', - subtype: 'success', - is_error: false, - result: 'ok', - session_id: 'test-session', - total_cost_usd: 0, - duration_ms: 100, - }) - ); - mockProcess.emit('close', 0); - - await runPromise.catch(() => {}); - - return { command, args, spawnOptions }; - } - - it('should include basic args', async () => { - const runner = new ClaudeCodeRunner({}); - const { command, args } = await getSpawnArgs(runner, 'hello'); - - expect(command).toBe('claude'); - expect(args).toContain('-p'); - expect(args).toContain('--output-format'); - expect(args[args.indexOf('--output-format') + 1]).toBe('json'); - }); - - it('should always include --dangerously-skip-permissions', async () => { - const runner = new ClaudeCodeRunner({}); - const { args } = await getSpawnArgs(runner, 'hello'); - - expect(args).toContain('--dangerously-skip-permissions'); - }); - - it('should include --resume with sessionId', async () => { - const runner = new ClaudeCodeRunner({}); - const { args } = await getSpawnArgs(runner, 'hello', { sessionId: 'abc-123' }); - - const resumeIndex = args.indexOf('--resume'); - expect(resumeIndex).toBeGreaterThan(-1); - expect(args[resumeIndex + 1]).toBe('abc-123'); - }); - - it('should include --model when model is set', async () => { - const runner = new ClaudeCodeRunner({ model: 'claude-sonnet-4-5-20250929' }); - const { args } = await getSpawnArgs(runner, 'hello'); - - const modelIndex = args.indexOf('--model'); - expect(modelIndex).toBeGreaterThan(-1); - expect(args[modelIndex + 1]).toBe('claude-sonnet-4-5-20250929'); - }); - - it('should include --append-system-prompt', async () => { - const runner = new ClaudeCodeRunner({}); - const { args } = await getSpawnArgs(runner, 'hello'); - - expect(args).toContain('--append-system-prompt'); - }); - - it('should place prompt as the last argument', async () => { - const runner = new ClaudeCodeRunner({}); - const { args } = await getSpawnArgs(runner, 'test prompt'); - - const lastArg = args[args.length - 1]; - expect(lastArg).toBe('test prompt'); - }); - - it('should use workdir as cwd in spawn options', async () => { - const runner = new ClaudeCodeRunner({ workdir: '/tmp/test' }); - const { spawnOptions } = await getSpawnArgs(runner, 'hello'); - - expect(spawnOptions.cwd).toBe('/tmp/test'); - }); - - it('should have correct arg order with all options', async () => { - const runner = new ClaudeCodeRunner({ - model: 'claude-sonnet-4-5-20250929', - }); - const { args } = await getSpawnArgs(runner, 'do stuff', { sessionId: 'sess-456' }); - - // -p と --output-format json は最初 - expect(args[0]).toBe('-p'); - expect(args[1]).toBe('--output-format'); - expect(args[2]).toBe('json'); - - // prompt は最後 - expect(args[args.length - 1]).toBe('do stuff'); - - // 各オプションが含まれている - expect(args).toContain('--dangerously-skip-permissions'); - expect(args).toContain('--resume'); - expect(args).toContain('--model'); - expect(args).toContain('--append-system-prompt'); - }); -}); diff --git a/tests/context.test.ts b/tests/context.test.ts new file mode 100644 index 0000000..47a592b --- /dev/null +++ b/tests/context.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { RunContext } from '../src/mcp/context.js'; + +describe('RunContext', () => { + it('should start with empty channelId', () => { + const ctx = new RunContext(); + expect(ctx.get().channelId).toBe(''); + expect(ctx.get().guildId).toBeUndefined(); + }); + + it('should set and get context', () => { + const ctx = new RunContext(); + ctx.set({ channelId: 'ch-1', guildId: 'guild-1' }); + expect(ctx.get().channelId).toBe('ch-1'); + expect(ctx.get().guildId).toBe('guild-1'); + }); + + it('should clear context', () => { + const ctx = new RunContext(); + ctx.set({ channelId: 'ch-1', guildId: 'guild-1' }); + ctx.clear(); + expect(ctx.get().channelId).toBe(''); + expect(ctx.get().guildId).toBeUndefined(); + }); + + it('should not share reference with set input', () => { + const ctx = new RunContext(); + const input = { channelId: 'ch-1', guildId: 'guild-1' }; + ctx.set(input); + input.channelId = 'modified'; + expect(ctx.get().channelId).toBe('ch-1'); + }); +}); diff --git a/tests/discord-commands.test.ts b/tests/discord-commands.test.ts deleted file mode 100644 index fa5e6ca..0000000 --- a/tests/discord-commands.test.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseAgentResponse } from '../src/lib/response-parser.js'; - -/** - * annotateChannelMentions のテスト用に関数を再実装 - * (元の関数は startDiscord 内のローカル関数のため) - */ -function annotateChannelMentions(text: string): string { - return text.replace(/<#(\d+)>/g, (match, id) => `${match} [チャンネルID: ${id}]`); -} - -/** parseAgentResponse のラッパー: displayText を返す */ -function stripCommandsFromDisplay(text: string): string { - return parseAgentResponse(text).displayText; -} - -/** - * コードブロック判定のテスト用 - */ -function isInCodeBlock(lines: string[], targetIndex: number): boolean { - let inCodeBlock = false; - for (let i = 0; i <= targetIndex; i++) { - if (lines[i].trim().startsWith('```')) { - inCodeBlock = !inCodeBlock; - } - } - return inCodeBlock; -} - -/** parseAgentResponse のラッパー: !discord コマンドのみ抽出 */ -function extractDiscordCommands(text: string): string[] { - return parseAgentResponse(text).commands.filter((c) => c.startsWith('!discord ')); -} - -/** - * Discord の 2000 文字制限に合わせてメッセージを分割する - */ -function chunkDiscordMessage(message: string, limit = 2000): string[] { - if (message.length <= limit) return [message]; - - const chunks: string[] = []; - let buf = ''; - - for (const line of message.split('\n')) { - if (line.length > limit) { - if (buf) { - chunks.push(buf); - buf = ''; - } - for (let j = 0; j < line.length; j += limit) { - chunks.push(line.slice(j, j + limit)); - } - continue; - } - const candidate = buf ? `${buf}\n${line}` : line; - if (candidate.length > limit) { - chunks.push(buf); - buf = line; - } else { - buf = candidate; - } - } - if (buf) chunks.push(buf); - return chunks; -} - -/** parseAgentResponse のラッパー: コマンドと残テキストを返す */ -function extractDiscordSendFromPrompt(text: string): { - commands: string[]; - remaining: string; -} { - const parsed = parseAgentResponse(text); - return { commands: parsed.commands, remaining: parsed.displayText }; -} - -describe('Discord Commands', () => { - describe('annotateChannelMentions', () => { - it('should add channel ID annotation', () => { - const input = '<#1234567890> に投稿して'; - const result = annotateChannelMentions(input); - expect(result).toBe('<#1234567890> [チャンネルID: 1234567890] に投稿して'); - }); - - it('should handle multiple channel mentions', () => { - const input = '<#111> と <#222> に送って'; - const result = annotateChannelMentions(input); - expect(result).toBe('<#111> [チャンネルID: 111] と <#222> [チャンネルID: 222] に送って'); - }); - - it('should not modify text without channel mentions', () => { - const input = '普通のテキスト'; - const result = annotateChannelMentions(input); - expect(result).toBe('普通のテキスト'); - }); - - it('should handle empty string', () => { - const result = annotateChannelMentions(''); - expect(result).toBe(''); - }); - }); - - describe('isInCodeBlock', () => { - it('should detect code block', () => { - const lines = ['text', '```', 'code', '```', 'text']; - expect(isInCodeBlock(lines, 0)).toBe(false); - expect(isInCodeBlock(lines, 2)).toBe(true); - expect(isInCodeBlock(lines, 4)).toBe(false); - }); - - it('should handle nested code blocks', () => { - const lines = ['```', 'code1', '```', 'text', '```', 'code2', '```']; - expect(isInCodeBlock(lines, 1)).toBe(true); - expect(isInCodeBlock(lines, 3)).toBe(false); - expect(isInCodeBlock(lines, 5)).toBe(true); - }); - }); - - describe('extractDiscordCommands', () => { - it('should extract discord commands', () => { - const text = `!discord send <#123> hello -!discord channels`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord send <#123> hello', '!discord channels']); - }); - - it('should skip commands inside code blocks', () => { - const text = `コマンド例: -\`\`\` -!discord send <#123> hello -\`\`\` -実際のコマンド: -!discord channels`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord channels']); - }); - - it('should handle multiple code blocks', () => { - const text = `\`\`\` -!discord send <#111> skip1 -\`\`\` -!discord send <#222> include -!discord channels`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord send <#222> include', '!discord channels']); - }); - - it('should return empty array when no commands', () => { - const text = '普通のテキスト\n改行あり'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual([]); - }); - - it('should handle inline code (not block)', () => { - const text = '`!discord send` はコマンドです\n!discord channels'; - const commands = extractDiscordCommands(text); - // インラインコードは無視されないが、行頭でないのでマッチしない - expect(commands).toEqual(['!discord channels']); - }); - - it('should collect multiline message when send has no inline content', () => { - const text = `!discord send <#123> -今日のニュース -【記事1】タイトル -→ 要点 -URL`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual([ - '!discord send <#123> 今日のニュース\n【記事1】タイトル\n→ 要点\nURL', - ]); - }); - - it('should stop multiline collection at next !discord command', () => { - const text = `!discord send <#123> -1行目 -2行目 -!discord channels`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord send <#123> 1行目\n2行目', '!discord channels']); - }); - - it('should stop multiline collection at !schedule command', () => { - const text = `!discord send <#123> -メッセージ本文 -!schedule list`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord send <#123> メッセージ本文']); - }); - - it('should keep single-line send as-is (backward compat)', () => { - const text = '!discord send <#123> 単一行メッセージ'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord send <#123> 単一行メッセージ']); - }); - - it('should handle multiline with code blocks in body', () => { - const text = `!discord send <#123> -本文開始 -\`\`\` -!discord send <#999> これはコードブロック内 -\`\`\` -本文続き -!discord channels`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual([ - '!discord send <#123> 本文開始\n```\n!discord send <#999> これはコードブロック内\n```\n本文続き', - '!discord channels', - ]); - }); - - it('should handle multiple multiline sends', () => { - const text = `!discord send <#111> -メッセージ1 -!discord send <#222> -メッセージ2`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual([ - '!discord send <#111> メッセージ1', - '!discord send <#222> メッセージ2', - ]); - }); - - it('should absorb continuation lines even when first line has content', () => { - const text = `!discord send <#123> 【ニュース1】テストタイトル1 -→ テスト要点1 -https://example.com/1 - -【ニュース2】テストタイトル2 -→ テスト要点2 -https://example.com/2`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual([ - '!discord send <#123> 【ニュース1】テストタイトル1\n→ テスト要点1\nhttps://example.com/1\n\n【ニュース2】テストタイトル2\n→ テスト要点2\nhttps://example.com/2', - ]); - }); - - it('should skip empty multiline send', () => { - const text = `!discord send <#123> -!discord channels`; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord channels']); - }); - }); - - describe('chunkDiscordMessage', () => { - it('should return single chunk for short messages', () => { - const chunks = chunkDiscordMessage('short message', 2000); - expect(chunks).toEqual(['short message']); - }); - - it('should split long messages at newline boundaries', () => { - const line = 'A'.repeat(800); - const message = `${line}\n${line}\n${line}`; - const chunks = chunkDiscordMessage(message, 2000); - expect(chunks.length).toBe(2); - expect(chunks[0]).toBe(`${line}\n${line}`); - expect(chunks[1]).toBe(line); - for (const c of chunks) { - expect(c.length).toBeLessThanOrEqual(2000); - } - }); - - it('should hard-split lines exceeding limit', () => { - const longLine = 'X'.repeat(5000); - const chunks = chunkDiscordMessage(longLine, 2000); - expect(chunks.length).toBe(3); - expect(chunks[0].length).toBe(2000); - expect(chunks[1].length).toBe(2000); - expect(chunks[2].length).toBe(1000); - }); - - it('should handle empty message', () => { - const chunks = chunkDiscordMessage('', 2000); - expect(chunks).toEqual(['']); - }); - }); - - describe('stripCommandsFromDisplay', () => { - it('should remove !discord commands outside code blocks', () => { - // !discord send は続く行も吸収するため、テキスト後も消える - const text = `テキスト前\n!discord send <#123> メッセージ\nテキスト後`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前'); - }); - - it('should keep !discord commands inside code blocks', () => { - const text = `テキスト前\n\`\`\`\n!discord send <#123> メッセージ\n\`\`\`\nテキスト後`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前\n```\n!discord send <#123> メッセージ\n```\nテキスト後'); - }); - - it('should remove SYSTEM_COMMAND lines', () => { - const text = `テキスト\nSYSTEM_COMMAND:setting=value\n続き`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト\n続き'); - }); - - it('should remove !schedule commands outside code blocks', () => { - const text = `テキスト\n!schedule 5分後 テスト\n続き`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト\n続き'); - }); - - it('should keep !schedule commands inside code blocks', () => { - const text = `例:\n\`\`\`\n!schedule 5分後 テスト\n\`\`\`\n以上`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('例:\n```\n!schedule 5分後 テスト\n```\n以上'); - }); - - it('should remove multiline !discord send and continuation lines', () => { - // 続く行は次のコマンド行まで吸収されるため、後文も消える - const text = `前文\n!discord send <#123>\n1行目\n2行目\n後文`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('前文'); - }); - - it('should remove single-line send with continuation lines', () => { - // 続く行は次のコマンド行まで吸収されるため、後文も消える - const text = `前文\n!discord send <#123> 【ニュース1】タイトル\n→ 要点\nURL\n後文`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('前文'); - }); - - it('should stop removal at next command', () => { - const text = `!discord send <#123>\nメッセージ\n!discord channels\n後文`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('後文'); - }); - - it('should handle mixed code blocks and commands', () => { - // コードブロック内は残し、外のコマンドは続く行も吸収して除去 - const text = `説明:\n\`\`\`\n!discord send <#123> 例文\n\`\`\`\n実行:\n!discord send <#456> 本文\n以上`; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('説明:\n```\n!discord send <#123> 例文\n```\n実行:'); - }); - - it('should handle empty text', () => { - const result = stripCommandsFromDisplay(''); - expect(result).toBe(''); - }); - }); - - describe('extractDiscordSendFromPrompt', () => { - it('should extract single-line send command', () => { - const result = extractDiscordSendFromPrompt('!discord send <#123> テストメッセージ'); - expect(result.commands).toEqual(['!discord send <#123> テストメッセージ']); - expect(result.remaining.trim()).toBe(''); - }); - - it('should extract multiline send command', () => { - const result = extractDiscordSendFromPrompt('!discord send <#123>\n1行目\n2行目'); - expect(result.commands).toEqual(['!discord send <#123> 1行目\n2行目']); - expect(result.remaining.trim()).toBe(''); - }); - - it('should separate command from remaining text', () => { - const text = '前文テキスト\n!discord send <#123> メッセージ'; - const result = extractDiscordSendFromPrompt(text); - expect(result.commands).toEqual(['!discord send <#123> メッセージ']); - expect(result.remaining.trim()).toBe('前文テキスト'); - }); - - it('should handle multiple send commands', () => { - const text = '!discord send <#111> msg1\n!discord send <#222> msg2'; - const result = extractDiscordSendFromPrompt(text); - expect(result.commands).toEqual(['!discord send <#111> msg1', '!discord send <#222> msg2']); - expect(result.remaining.trim()).toBe(''); - }); - - it('should skip commands inside code blocks', () => { - const text = '```\n!discord send <#123> コード内\n```\n!discord send <#456> コード外'; - const result = extractDiscordSendFromPrompt(text); - expect(result.commands).toEqual(['!discord send <#456> コード外']); - expect(result.remaining).toContain('```'); - }); - - it('should return empty commands for text without commands', () => { - const text = '普通のテキスト\n改行あり'; - const result = extractDiscordSendFromPrompt(text); - expect(result.commands).toEqual([]); - expect(result.remaining).toBe('普通のテキスト\n改行あり'); - }); - - it('should handle send with continuation and remaining text', () => { - const text = '指示テキスト\n!discord send <#123>\nメッセージ本文\n追加行\n他の指示'; - const result = extractDiscordSendFromPrompt(text); - // 後続行は全て吸収される(次のコマンド行まで) - expect(result.commands).toEqual(['!discord send <#123> メッセージ本文\n追加行\n他の指示']); - expect(result.remaining.trim()).toBe('指示テキスト'); - }); - - it('should handle empty multiline send', () => { - const text = '!discord send <#123>\n!discord send <#456> msg'; - const result = extractDiscordSendFromPrompt(text); - expect(result.commands).toEqual(['!discord send <#456> msg']); - }); - - it('should handle empty string', () => { - const result = extractDiscordSendFromPrompt(''); - expect(result.commands).toEqual([]); - expect(result.remaining).toBe(''); - }); - }); - - describe('extractDiscordCommands - history command', () => { - it('should extract !discord history command', () => { - const text = '!discord history 20'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord history 20']); - }); - - it('should extract !discord history without count', () => { - const text = '!discord history'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord history']); - }); - - it('should extract !discord history with channel', () => { - const text = '!discord history 10 <#123456>'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord history 10 <#123456>']); - }); - - it('should skip !discord history inside code blocks', () => { - const text = '```\n!discord history 20\n```\n!discord history 10'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord history 10']); - }); - - it('should extract history alongside other commands', () => { - const text = '!discord history 20\n!discord send <#123> test\n!discord channels'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual([ - '!discord history 20', - '!discord send <#123> test', - '!discord channels', - ]); - }); - }); - - describe('stripCommandsFromDisplay - history command', () => { - it('should remove !discord history from display', () => { - const text = 'テキスト前\n!discord history 20\nテキスト後'; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前\nテキスト後'); - }); - - it('should keep !discord history inside code blocks', () => { - const text = '例:\n```\n!discord history 20\n```\n以上'; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('例:\n```\n!discord history 20\n```\n以上'); - }); - - it('should remove !discord history with channel from display', () => { - const text = 'テキスト前\n!discord history 10 <#123>\nテキスト後'; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前\nテキスト後'); - }); - - it('should remove !discord history with offset from display', () => { - const text = 'テキスト前\n!discord history 30 offset:30\nテキスト後'; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前\nテキスト後'); - }); - - it('should remove !discord history with offset and channel from display', () => { - const text = 'テキスト前\n!discord history 30 offset:60 <#123>\nテキスト後'; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前\nテキスト後'); - }); - }); - - describe('extractDiscordCommands - history with offset', () => { - it('should extract !discord history with offset', () => { - const text = '!discord history 30 offset:30'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord history 30 offset:30']); - }); - - it('should extract !discord history with offset and channel', () => { - const text = '!discord history 30 offset:60 <#123456>'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord history 30 offset:60 <#123456>']); - }); - }); - - describe('extractDiscordCommands - delete', () => { - it('should extract !discord delete with message ID', () => { - const text = '!discord delete 123456789012345678'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord delete 123456789012345678']); - }); - - it('should extract !discord delete with message link', () => { - const text = '!discord delete https://discord.com/channels/111/222/333'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual(['!discord delete https://discord.com/channels/111/222/333']); - }); - - it('should not extract !discord delete inside code blocks', () => { - const text = '```\n!discord delete 123456789012345678\n```'; - const commands = extractDiscordCommands(text); - expect(commands).toEqual([]); - }); - }); - - describe('stripCommandsFromDisplay - delete', () => { - it('should remove !discord delete with args from display', () => { - const text = 'テキスト前\n!discord delete 123456789012345678\nテキスト後'; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前\nテキスト後'); - }); - - it('should remove !discord delete with message link from display', () => { - const text = - 'テキスト前\n!discord delete https://discord.com/channels/111/222/333\nテキスト後'; - const result = stripCommandsFromDisplay(text); - expect(result).toBe('テキスト前\nテキスト後'); - }); - }); -}); diff --git a/tests/discord-reply.test.ts b/tests/discord-reply.test.ts index 5dcb20b..9ca0486 100644 --- a/tests/discord-reply.test.ts +++ b/tests/discord-reply.test.ts @@ -1,94 +1,22 @@ import { describe, expect, it } from 'vitest'; - -/** - * 返信元メッセージのフォーマット関数(テスト用に再実装) - */ -function formatReplyContent(authorTag: string, content: string, attachmentNames: string[]): string { - const attachmentInfo = - attachmentNames.length > 0 ? `\n[添付: ${attachmentNames.join(', ')}]` : ''; - - return `\n---\n💬 返信元 (${authorTag}):\n${content}${attachmentInfo}\n---\n`; -} - -/** - * 返信元があるかどうかを判定 - */ -function hasReplyReference(reference: { messageId?: string } | null): boolean { - return Boolean(reference?.messageId); -} +import { sanitizeChannelMentions } from '../src/discord/message-enrichment.js'; describe('Discord Reply Feature', () => { - describe('hasReplyReference', () => { - it('should return true when messageId exists', () => { - const reference = { messageId: '1234567890' }; - expect(hasReplyReference(reference)).toBe(true); + describe('sanitizeChannelMentions', () => { + it('should replace channel mentions with bare IDs', () => { + expect(sanitizeChannelMentions('<#1234567890>')).toBe('#1234567890'); }); - it('should return false when reference is null', () => { - expect(hasReplyReference(null)).toBe(false); + it('should handle multiple mentions', () => { + expect(sanitizeChannelMentions('<#111> and <#222>')).toBe('#111 and #222'); }); - it('should return false when messageId is undefined', () => { - const reference = {}; - expect(hasReplyReference(reference)).toBe(false); + it('should not modify text without mentions', () => { + expect(sanitizeChannelMentions('plain text')).toBe('plain text'); }); - it('should return false when messageId is empty string', () => { - const reference = { messageId: '' }; - expect(hasReplyReference(reference)).toBe(false); - }); - }); - - describe('formatReplyContent', () => { - it('should format reply content with author and text', () => { - const result = formatReplyContent('user#1234', 'Hello world', []); - expect(result).toBe('\n---\n💬 返信元 (user#1234):\nHello world\n---\n'); - }); - - it('should include attachment info when present', () => { - const result = formatReplyContent('user#1234', 'Check this', ['image.png', 'doc.pdf']); - expect(result).toBe( - '\n---\n💬 返信元 (user#1234):\nCheck this\n[添付: image.png, doc.pdf]\n---\n' - ); - }); - - it('should handle empty content', () => { - const result = formatReplyContent('user#1234', '', ['file.txt']); - expect(result).toBe('\n---\n💬 返信元 (user#1234):\n\n[添付: file.txt]\n---\n'); - }); - - it('should handle attachment-only message placeholder', () => { - const result = formatReplyContent('user#1234', '(添付ファイルのみ)', ['image.png']); - expect(result).toBe( - '\n---\n💬 返信元 (user#1234):\n(添付ファイルのみ)\n[添付: image.png]\n---\n' - ); - }); - - it('should handle multiline content', () => { - const result = formatReplyContent('user#1234', 'Line 1\nLine 2\nLine 3', []); - expect(result).toBe('\n---\n💬 返信元 (user#1234):\nLine 1\nLine 2\nLine 3\n---\n'); - }); - }); - - describe('Reply content prepending', () => { - it('should prepend reply content to prompt', () => { - const replyContent = formatReplyContent('user#1234', 'Original message', []); - const prompt = 'My response'; - const combined = replyContent + prompt; - - expect(combined).toContain('💬 返信元 (user#1234)'); - expect(combined).toContain('Original message'); - expect(combined).toContain('My response'); - expect(combined.indexOf('Original message')).toBeLessThan(combined.indexOf('My response')); - }); - - it('should work with empty prompt', () => { - const replyContent = formatReplyContent('user#1234', 'Original', []); - const prompt = ''; - const combined = replyContent + prompt; - - expect(combined).toContain('Original'); - expect(combined.endsWith('---\n')).toBe(true); + it('should handle empty string', () => { + expect(sanitizeChannelMentions('')).toBe(''); }); }); }); diff --git a/tests/discord-send.test.ts b/tests/discord-send.test.ts deleted file mode 100644 index 900da3b..0000000 --- a/tests/discord-send.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - sendChunkedMessage, - sendChunkedReply, - sendScheduleContent, -} from '../src/discord/discord-send.js'; - -// ─── sendChunkedReply ────────────────────────────────────── -describe('sendChunkedReply', () => { - it('should reply with short content directly', async () => { - const target = { reply: vi.fn().mockResolvedValue(undefined) }; - await sendChunkedReply(target, 'hello'); - expect(target.reply).toHaveBeenCalledWith('hello'); - }); - - it('should split long content into multiple replies', async () => { - const target = { reply: vi.fn().mockResolvedValue(undefined) }; - const longText = `${'a'.repeat(1500)}\n${'b'.repeat(1500)}`; - await sendChunkedReply(target, longText); - expect(target.reply).toHaveBeenCalledTimes(2); - }); - - it('should strip SCHEDULE_SEPARATOR from content', async () => { - const target = { reply: vi.fn().mockResolvedValue(undefined) }; - await sendChunkedReply(target, 'line1{{SPLIT}}line2'); - const called = target.reply.mock.calls[0][0] as string; - expect(called).not.toContain('{{SPLIT}}'); - expect(called).toContain('line1'); - expect(called).toContain('line2'); - }); -}); - -// ─── sendChunkedMessage ──────────────────────────────────── -describe('sendChunkedMessage', () => { - it('should send short content directly', async () => { - const channel = { send: vi.fn().mockResolvedValue(undefined) }; - await sendChunkedMessage(channel, 'hello'); - expect(channel.send).toHaveBeenCalledWith('hello'); - }); - - it('should split long content into multiple sends', async () => { - const channel = { send: vi.fn().mockResolvedValue(undefined) }; - const longText = `${'a'.repeat(1500)}\n${'b'.repeat(1500)}`; - await sendChunkedMessage(channel, longText); - expect(channel.send).toHaveBeenCalledTimes(2); - }); - - it('should strip SCHEDULE_SEPARATOR from content', async () => { - const channel = { send: vi.fn().mockResolvedValue(undefined) }; - await sendChunkedMessage(channel, 'line1{{SPLIT}}line2'); - const called = channel.send.mock.calls[0][0] as string; - expect(called).not.toContain('{{SPLIT}}'); - }); -}); - -// ─── sendScheduleContent ─────────────────────────────────── -describe('sendScheduleContent', () => { - it('should use reply mode by default', async () => { - const target = { - reply: vi.fn().mockResolvedValue(undefined), - send: vi.fn().mockResolvedValue(undefined), - }; - await sendScheduleContent(target, 'short', 'reply'); - expect(target.reply).toHaveBeenCalled(); - expect(target.send).not.toHaveBeenCalled(); - }); - - it('should use send mode when specified', async () => { - const target = { - reply: vi.fn().mockResolvedValue(undefined), - send: vi.fn().mockResolvedValue(undefined), - }; - await sendScheduleContent(target, 'short', 'send'); - expect(target.send).toHaveBeenCalled(); - expect(target.reply).not.toHaveBeenCalled(); - }); - - it('should use interaction mode with followUp for overflow', async () => { - const target = { - reply: vi.fn().mockResolvedValue(undefined), - followUp: vi.fn().mockResolvedValue(undefined), - }; - // Create content that exceeds DISCORD_MAX_LENGTH (2000) - const longText = `${'a'.repeat(1200)}\n{{SPLIT}}\n${'b'.repeat(1200)}`; - await sendScheduleContent(target, longText, 'interaction'); - expect(target.reply).toHaveBeenCalledTimes(1); - expect(target.followUp.mock.calls.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/tests/error-utils.test.ts b/tests/error-utils.test.ts index 238179e..ede9520 100644 --- a/tests/error-utils.test.ts +++ b/tests/error-utils.test.ts @@ -18,12 +18,6 @@ describe('formatErrorDetail', () => { expect(result).toContain('Process exited unexpectedly with code 1'); }); - it('returns circuit breaker message', () => { - const result = formatErrorDetail('Circuit breaker open'); - expect(result).toContain('🔌'); - expect(result).toContain('一時停止中'); - }); - it('returns generic error with truncation', () => { const longMsg = 'x'.repeat(500); const result = formatErrorDetail(longMsg); diff --git a/tests/feedback-loop.test.ts b/tests/feedback-loop.test.ts deleted file mode 100644 index 0d5f8e1..0000000 --- a/tests/feedback-loop.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// handleDiscordCommandsInResponse をモック -vi.mock('../src/discord/discord-commands.js', () => ({ - handleDiscordCommandsInResponse: vi.fn(), -})); - -import { handleDiscordCommandsInResponse } from '../src/discord/discord-commands.js'; -import { executeCommandsWithFeedback } from '../src/lib/feedback-loop.js'; - -const mockHandleCommands = vi.mocked(handleDiscordCommandsInResponse); - -describe('executeCommandsWithFeedback', () => { - const dummyClient = {} as Parameters[1]; - const dummyScheduler = {} as Parameters[2]; - - beforeEach(() => { - mockHandleCommands.mockReset(); - }); - - it('should not call runAgent when no feedback results', async () => { - mockHandleCommands.mockResolvedValueOnce([]); - const runAgent = vi.fn(); - - await executeCommandsWithFeedback('結果テキスト', dummyClient, dummyScheduler, { - runAgent, - }); - - expect(mockHandleCommands).toHaveBeenCalledTimes(1); - expect(runAgent).not.toHaveBeenCalled(); - }); - - it('should call runAgent with feedback prompt when feedback exists', async () => { - mockHandleCommands.mockResolvedValueOnce(['📺 チャンネル一覧:...']); - mockHandleCommands.mockResolvedValueOnce([]); // 再注入後 - const runAgent = vi.fn().mockResolvedValue('再注入応答'); - - await executeCommandsWithFeedback('結果テキスト', dummyClient, dummyScheduler, { - runAgent, - }); - - expect(runAgent).toHaveBeenCalledTimes(1); - const prompt = runAgent.mock.calls[0][0] as string; - expect(prompt).toContain('コマンドの結果'); - expect(prompt).toContain('📺 チャンネル一覧:...'); - }); - - it('should execute commands on re-injected response', async () => { - mockHandleCommands.mockResolvedValueOnce(['フィードバック1']); - mockHandleCommands.mockResolvedValueOnce([]); // 再注入後 - const runAgent = vi.fn().mockResolvedValue('再注入応答'); - - await executeCommandsWithFeedback('結果テキスト', dummyClient, dummyScheduler, { - runAgent, - }); - - // 2回呼ばれる: 1回目は元の応答、2回目は再注入後の応答 - expect(mockHandleCommands).toHaveBeenCalledTimes(2); - expect(mockHandleCommands.mock.calls[1][0]).toBe('再注入応答'); - }); - - it('should pass sourceMessage to handleDiscordCommandsInResponse', async () => { - mockHandleCommands.mockResolvedValueOnce([]); - const sourceMessage = { id: 'msg-1' } as Parameters< - typeof executeCommandsWithFeedback - >[3]['sourceMessage']; - - await executeCommandsWithFeedback('結果テキスト', dummyClient, dummyScheduler, { - sourceMessage, - runAgent: vi.fn(), - }); - - expect(mockHandleCommands.mock.calls[0][4]).toBe(sourceMessage); - }); - - it('should pass fallbackChannelId to handleDiscordCommandsInResponse', async () => { - mockHandleCommands.mockResolvedValueOnce([]); - - await executeCommandsWithFeedback('結果テキスト', dummyClient, dummyScheduler, { - fallbackChannelId: 'ch-123', - runAgent: vi.fn(), - }); - - expect(mockHandleCommands.mock.calls[0][5]).toBe('ch-123'); - }); - - it('should join multiple feedback results with newlines', async () => { - mockHandleCommands.mockResolvedValueOnce(['結果1', '結果2']); - mockHandleCommands.mockResolvedValueOnce([]); - const runAgent = vi.fn().mockResolvedValue(''); - - await executeCommandsWithFeedback('結果テキスト', dummyClient, dummyScheduler, { - runAgent, - }); - - const prompt = runAgent.mock.calls[0][0] as string; - expect(prompt).toContain('結果1'); - expect(prompt).toContain('結果2'); - }); - - it('should not re-inject if runAgent returns empty', async () => { - mockHandleCommands.mockResolvedValueOnce(['フィードバック']); - mockHandleCommands.mockResolvedValueOnce([]); - const runAgent = vi.fn().mockResolvedValue(''); - - await executeCommandsWithFeedback('結果テキスト', dummyClient, dummyScheduler, { - runAgent, - }); - - // runAgent returns empty but handleDiscordCommandsInResponse is still called - expect(mockHandleCommands).toHaveBeenCalledTimes(2); - }); -}); diff --git a/tests/heartbeat.test.ts b/tests/heartbeat.test.ts new file mode 100644 index 0000000..0a924af --- /dev/null +++ b/tests/heartbeat.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Brain } from '../src/brain/brain.js'; +import { Priority } from '../src/brain/brain.js'; +import { Heartbeat } from '../src/brain/heartbeat.js'; + +function createMockBrain(overrides: Partial = {}): Brain { + return { + isBusy: vi.fn().mockReturnValue(false), + getIdleTime: vi.fn().mockReturnValue(600_000), // 10 minutes + submit: vi.fn().mockResolvedValue({ result: 'HEARTBEAT_OK', sessionId: 'sess' }), + run: vi.fn(), + runStream: vi.fn(), + cancel: vi.fn(), + cancelAll: vi.fn(), + shutdown: vi.fn(), + getSessionId: vi.fn().mockReturnValue('sess'), + getStatus: vi.fn(), + ...overrides, + } as unknown as Brain; +} + +describe('Heartbeat', () => { + let heartbeat: Heartbeat; + let brain: Brain; + + beforeEach(() => { + vi.useFakeTimers(); + brain = createMockBrain(); + heartbeat = new Heartbeat(brain, { + minIntervalMs: 1000, + maxIntervalMs: 2000, + idleThresholdMs: 5000, + channelId: 'ch-heartbeat', + }); + }); + + afterEach(() => { + heartbeat.stop(); + vi.useRealTimers(); + }); + + it('should start and stop', () => { + heartbeat.start(); + heartbeat.stop(); + }); + + it('should not double-start', () => { + heartbeat.start(); + heartbeat.start(); // should be no-op + heartbeat.stop(); + }); + + it('should fire tick after interval', async () => { + heartbeat.start(); + + // Advance past max interval + await vi.advanceTimersByTimeAsync(3000); + + expect(brain.submit).toHaveBeenCalledWith( + expect.objectContaining({ + priority: Priority.HEARTBEAT, + options: { channelId: 'ch-heartbeat' }, + }) + ); + }); + + it('should skip tick when brain is busy', async () => { + (brain.isBusy as ReturnType).mockReturnValue(true); + heartbeat.start(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(brain.submit).not.toHaveBeenCalled(); + }); + + it('should skip tick when user is recently active', async () => { + (brain.getIdleTime as ReturnType).mockReturnValue(1000); // 1 second + heartbeat.start(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(brain.submit).not.toHaveBeenCalled(); + }); + + it('should suppress HEARTBEAT_OK results', async () => { + const handler = vi.fn(); + heartbeat.setResultHandler(handler); + heartbeat.start(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should forward non-HEARTBEAT_OK results', async () => { + (brain.submit as ReturnType).mockResolvedValue({ + result: '!discord send <#ch> hello', + sessionId: 'sess', + }); + + const handler = vi.fn(); + heartbeat.setResultHandler(handler); + heartbeat.start(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(handler).toHaveBeenCalledWith('!discord send <#ch> hello', 'ch-heartbeat'); + }); +}); diff --git a/tests/mcp-discord-tools.test.ts b/tests/mcp-discord-tools.test.ts new file mode 100644 index 0000000..e1fe190 --- /dev/null +++ b/tests/mcp-discord-tools.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RunContext } from '../src/mcp/context.js'; + +// Mock discord.js ChannelType +vi.mock('discord.js', () => ({ + ChannelType: { GuildText: 0 }, +})); + +// Mock the SDK tool function to just return the handler +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + tool: (_name: string, _desc: string, _schema: any, handler: any) => ({ + name: _name, + handler, + }), +})); + +// Mock channel-utils +vi.mock('../src/discord/channel-utils.js', () => ({ + isSendableChannel: (ch: any) => ch && typeof ch.send === 'function', +})); + +import { createDiscordTools } from '../src/mcp/discord-tools.js'; + +function createMockClient(overrides: any = {}) { + return { + channels: { + fetch: vi.fn(), + }, + guilds: { + cache: { + get: vi.fn(), + }, + }, + user: { id: 'bot-user-id' }, + ...overrides, + } as any; +} + +function createSendableChannel(guildId?: string) { + return { + send: vi.fn().mockResolvedValue({}), + name: 'test-channel', + guildId, + type: 0, + }; +} + +describe('MCP Discord Tools', () => { + let client: any; + let runContext: RunContext; + let tools: Record Promise }>; + + beforeEach(() => { + client = createMockClient(); + runContext = new RunContext(); + const toolArray = createDiscordTools(client, runContext); + tools = {}; + for (const t of toolArray) { + tools[(t as any).name] = t as any; + } + }); + + describe('discord_send', () => { + it('should send a message to a channel', async () => { + const channel = createSendableChannel('guild-1'); + client.channels.fetch.mockResolvedValue(channel); + runContext.set({ channelId: 'ch-1', guildId: 'guild-1' }); + + const result = await tools.discord_send.handler({ + channel_id: 'ch-1', + message: 'Hello', + }); + + expect(channel.send).toHaveBeenCalledWith({ + content: 'Hello', + allowedMentions: { parse: [] }, + }); + expect(result.content[0].text).toContain('Sent message'); + }); + + it('should reject non-sendable channels', async () => { + client.channels.fetch.mockResolvedValue({ name: 'voice' }); + + const result = await tools.discord_send.handler({ + channel_id: 'ch-1', + message: 'Hello', + }); + + expect(result.content[0].text).toContain('not sendable'); + }); + + it('should reject cross-guild sends', async () => { + const channel = createSendableChannel('other-guild'); + client.channels.fetch.mockResolvedValue(channel); + runContext.set({ channelId: 'ch-1', guildId: 'guild-1' }); + + const result = await tools.discord_send.handler({ + channel_id: 'ch-2', + message: 'Hello', + }); + + expect(result.content[0].text).toContain('different guild'); + }); + + it('should handle fetch errors', async () => { + client.channels.fetch.mockRejectedValue(new Error('Not found')); + + const result = await tools.discord_send.handler({ + channel_id: 'invalid', + message: 'Hello', + }); + + expect(result.content[0].text).toContain('Error'); + }); + }); + + describe('discord_channels', () => { + it('should list guild text channels', async () => { + runContext.set({ channelId: 'ch-1', guildId: 'guild-1' }); + const channels = new Map([ + ['ch-1', { type: 0, name: 'general', id: 'ch-1' }], + ['ch-2', { type: 0, name: 'dev', id: 'ch-2' }], + ['ch-3', { type: 2, name: 'voice', id: 'ch-3' }], // Not text + ]); + client.guilds.cache.get.mockReturnValue({ + channels: { + cache: { + filter: (fn: any) => { + const filtered = new Map(); + for (const [k, v] of channels) { + if (fn(v)) filtered.set(k, v); + } + return { + map: (mapFn: any) => { + const results: string[] = []; + for (const v of filtered.values()) results.push(mapFn(v)); + return { join: (sep: string) => results.join(sep) }; + }, + }; + }, + }, + }, + }); + + const result = await tools.discord_channels.handler({}); + + expect(result.content[0].text).toContain('general'); + expect(result.content[0].text).toContain('dev'); + expect(result.content[0].text).not.toContain('voice'); + }); + + it('should return error without guild context', async () => { + runContext.set({ channelId: 'ch-1' }); + + const result = await tools.discord_channels.handler({}); + + expect(result.content[0].text).toContain('No guild context'); + }); + }); + + describe('discord_history', () => { + it('should fetch channel history', async () => { + runContext.set({ channelId: 'ch-1', guildId: 'guild-1' }); + const messages = new Map([ + [ + 'msg-1', + { + id: 'msg-1', + author: { tag: 'user#1234' }, + content: 'Hello', + createdAt: new Date('2024-01-01'), + attachments: { size: 0, map: () => [] }, + }, + ], + ]); + const channel = { + name: 'test', + messages: { + fetch: vi.fn().mockResolvedValue({ + size: 1, + reverse: () => ({ + map: (fn: any) => { + const results: string[] = []; + for (const v of messages.values()) results.push(fn(v)); + return { join: (sep: string) => results.join(sep) }; + }, + }), + }), + }, + }; + client.channels.fetch.mockResolvedValue(channel); + + const result = await tools.discord_history.handler({}); + + expect(result.content[0].text).toContain('#test'); + expect(result.content[0].text).toContain('Hello'); + }); + + it('should return error when no channel specified', async () => { + runContext.set({ channelId: '' }); + + const result = await tools.discord_history.handler({}); + + expect(result.content[0].text).toContain('No channel specified'); + }); + }); + + describe('discord_delete', () => { + it('should delete own bot message', async () => { + const msg = { + author: { id: 'bot-user-id' }, + delete: vi.fn().mockResolvedValue({}), + }; + const channel = { + messages: { fetch: vi.fn().mockResolvedValue(msg) }, + }; + client.channels.fetch.mockResolvedValue(channel); + runContext.set({ channelId: 'ch-1' }); + + const result = await tools.discord_delete.handler({ + message_id_or_link: '12345', + }); + + expect(msg.delete).toHaveBeenCalled(); + expect(result.content[0].text).toContain('deleted'); + }); + + it('should reject deleting non-bot messages', async () => { + const msg = { + author: { id: 'other-user-id' }, + delete: vi.fn(), + }; + const channel = { + messages: { fetch: vi.fn().mockResolvedValue(msg) }, + }; + client.channels.fetch.mockResolvedValue(channel); + runContext.set({ channelId: 'ch-1' }); + + const result = await tools.discord_delete.handler({ + message_id_or_link: '12345', + }); + + expect(msg.delete).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('own (bot) messages'); + }); + + it('should parse Discord message links', async () => { + const msg = { + author: { id: 'bot-user-id' }, + delete: vi.fn().mockResolvedValue({}), + }; + const channel = { + messages: { fetch: vi.fn().mockResolvedValue(msg) }, + }; + client.channels.fetch.mockResolvedValue(channel); + + const result = await tools.discord_delete.handler({ + message_id_or_link: 'https://discord.com/channels/111/222/333', + }); + + expect(client.channels.fetch).toHaveBeenCalledWith('222'); + expect(channel.messages.fetch).toHaveBeenCalledWith('333'); + expect(result.content[0].text).toContain('deleted'); + }); + + it('should reject invalid format', async () => { + const result = await tools.discord_delete.handler({ + message_id_or_link: 'not-valid', + }); + + expect(result.content[0].text).toContain('Invalid format'); + }); + }); +}); diff --git a/tests/mcp-schedule-tools.test.ts b/tests/mcp-schedule-tools.test.ts new file mode 100644 index 0000000..d5b3160 --- /dev/null +++ b/tests/mcp-schedule-tools.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RunContext } from '../src/mcp/context.js'; + +// Mock the SDK tool function to just return the handler +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + tool: (_name: string, _desc: string, _schema: any, handler: any) => ({ + name: _name, + handler, + }), +})); + +import { createScheduleTools } from '../src/mcp/schedule-tools.js'; + +function createMockScheduler() { + return { + add: vi.fn().mockReturnValue({ + id: 'sch-1', + type: 'cron', + expression: '0 9 * * *', + message: 'test', + enabled: true, + }), + list: vi.fn().mockReturnValue([]), + remove: vi.fn(), + toggle: vi.fn(), + } as any; +} + +describe('MCP Schedule Tools', () => { + let scheduler: any; + let runContext: RunContext; + let tools: Record Promise }>; + + beforeEach(() => { + scheduler = createMockScheduler(); + runContext = new RunContext(); + runContext.set({ channelId: 'ch-1' }); + const toolArray = createScheduleTools(scheduler, runContext); + tools = {}; + for (const t of toolArray) { + tools[(t as any).name] = t as any; + } + }); + + describe('schedule_create', () => { + it('should create a schedule from valid input', async () => { + const result = await tools.schedule_create.handler({ + input: '毎日 9:00 good morning', + }); + + expect(scheduler.add).toHaveBeenCalled(); + expect(result.content[0].text).toContain('Schedule created'); + expect(result.content[0].text).toContain('sch-1'); + }); + + it('should return error for invalid input', async () => { + const result = await tools.schedule_create.handler({ + input: '???', + }); + + expect(result.content[0].text).toContain('Error'); + expect(scheduler.add).not.toHaveBeenCalled(); + }); + }); + + describe('schedule_list', () => { + it('should list schedules', async () => { + scheduler.list.mockReturnValue([]); + + const result = await tools.schedule_list.handler({}); + + expect(result.content[0].text).toBeDefined(); + }); + + it('should handle errors', async () => { + scheduler.list.mockImplementation(() => { + throw new Error('DB error'); + }); + + const result = await tools.schedule_list.handler({}); + + expect(result.content[0].text).toContain('Error'); + }); + }); + + describe('schedule_remove', () => { + it('should remove existing schedule', async () => { + scheduler.remove.mockReturnValue(true); + + const result = await tools.schedule_remove.handler({ id: 'sch-1' }); + + expect(result.content[0].text).toContain('removed'); + }); + + it('should return error for non-existing schedule', async () => { + scheduler.remove.mockReturnValue(false); + + const result = await tools.schedule_remove.handler({ id: 'nope' }); + + expect(result.content[0].text).toContain('not found'); + }); + + it('should handle errors', async () => { + scheduler.remove.mockImplementation(() => { + throw new Error('fail'); + }); + + const result = await tools.schedule_remove.handler({ id: 'sch-1' }); + + expect(result.content[0].text).toContain('Error'); + }); + }); + + describe('schedule_toggle', () => { + it('should toggle schedule to disabled', async () => { + scheduler.toggle.mockReturnValue({ id: 'sch-1', enabled: false }); + + const result = await tools.schedule_toggle.handler({ id: 'sch-1' }); + + expect(result.content[0].text).toContain('disabled'); + }); + + it('should toggle schedule to enabled', async () => { + scheduler.toggle.mockReturnValue({ id: 'sch-1', enabled: true }); + + const result = await tools.schedule_toggle.handler({ id: 'sch-1' }); + + expect(result.content[0].text).toContain('enabled'); + }); + + it('should return error for non-existing schedule', async () => { + scheduler.toggle.mockReturnValue(null); + + const result = await tools.schedule_toggle.handler({ id: 'nope' }); + + expect(result.content[0].text).toContain('not found'); + }); + + it('should handle errors', async () => { + scheduler.toggle.mockImplementation(() => { + throw new Error('fail'); + }); + + const result = await tools.schedule_toggle.handler({ id: 'sch-1' }); + + expect(result.content[0].text).toContain('Error'); + }); + }); +}); diff --git a/tests/message-handler.test.ts b/tests/message-handler.test.ts deleted file mode 100644 index 7a1e57a..0000000 --- a/tests/message-handler.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { formatErrorDetail } from '../src/lib/error-utils.js'; - -describe('formatErrorDetail', () => { - it('should format timeout error with label', () => { - const result = formatErrorDetail('Request timed out after 600000ms', { - timeoutLabel: '600秒', - }); - expect(result).toBe('⏱️ タイムアウトしました(600秒)'); - }); - - it('should format timeout error without label', () => { - const result = formatErrorDetail('Request timed out'); - expect(result).toBe('⏱️ タイムアウトしました'); - }); - - it('should format process exit error', () => { - const result = formatErrorDetail('Process exited unexpectedly with code 1'); - expect(result).toContain('💥 AIプロセスが予期せず終了しました'); - expect(result).toContain('code 1'); - }); - - it('should format circuit breaker error', () => { - const result = formatErrorDetail('Circuit breaker open: too many crashes'); - expect(result).toContain('🔌'); - expect(result).toContain('一時停止中'); - }); - - it('should format generic error with truncation', () => { - const longError = 'A'.repeat(300); - const result = formatErrorDetail(longError); - expect(result).toContain('❌ エラーが発生しました'); - expect(result.length).toBeLessThan(250); - }); - - it('should format short generic error', () => { - const result = formatErrorDetail('Something went wrong'); - expect(result).toBe('❌ エラーが発生しました: Something went wrong'); - }); -}); diff --git a/tests/message-utils.test.ts b/tests/message-utils.test.ts new file mode 100644 index 0000000..9c828bc --- /dev/null +++ b/tests/message-utils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { splitMessage, splitScheduleContent } from '../src/lib/message-utils.js'; + +describe('splitMessage', () => { + it('should return single chunk for short text', () => { + expect(splitMessage('hello', 100)).toEqual(['hello']); + }); + + it('should split at newline boundaries', () => { + const text = 'line1\nline2\nline3'; + const chunks = splitMessage(text, 12); + expect(chunks).toEqual(['line1\nline2', 'line3']); + }); + + it('should handle empty text', () => { + expect(splitMessage('', 100)).toEqual(['']); + }); + + it('should handle text exactly at limit', () => { + const text = 'a'.repeat(100); + expect(splitMessage(text, 100)).toEqual([text]); + }); + + it('should fall back to line splitting for oversized blocks', () => { + const text = 'a'.repeat(50) + '\n' + 'b'.repeat(50); + // With custom separator that doesn't match, force oversized block + const chunks = splitMessage(text, 40, '---'); + expect(chunks.length).toBeGreaterThan(1); + }); + + it('should split with custom separator', () => { + const text = 'part1---part2---part3'; + // part1---part2 = 13 chars, fits in limit 15 + const chunks = splitMessage(text, 15, '---'); + expect(chunks).toEqual(['part1---part2', 'part3']); + }); + + it('should hard-split oversized single lines', () => { + const longLine = 'X'.repeat(5000); + const chunks = splitMessage(longLine, 2000); + expect(chunks.length).toBe(3); + expect(chunks[0].length).toBe(2000); + expect(chunks[1].length).toBe(2000); + expect(chunks[2].length).toBe(1000); + }); +}); + +describe('splitScheduleContent', () => { + it('should split schedule content', () => { + const chunks = splitScheduleContent('schedule1', 100); + expect(chunks.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/tests/persistent-runner.test.ts b/tests/persistent-runner.test.ts deleted file mode 100644 index bc54988..0000000 --- a/tests/persistent-runner.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PersistentRunner } from '../src/agent/persistent-runner.js'; - -// Child process をモック -vi.mock('child_process', () => { - const EventEmitter = require('node:events'); - - class MockProcess extends EventEmitter { - stdin = { - write: vi.fn(), - end: vi.fn(), - }; - stdout = new EventEmitter(); - stderr = new EventEmitter(); - killed = false; - - kill() { - this.killed = true; - this.emit('close', 0); - } - } - - let mockProcess: MockProcess; - - return { - spawn: vi.fn(() => { - mockProcess = new MockProcess(); - // 少し遅延してから init メッセージを送信 - setTimeout(() => { - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'system', - subtype: 'init', - session_id: 'test-session-123', - })}\n` - ); - }, 10); - return mockProcess; - }), - getMockProcess: () => mockProcess, - }; -}); - -describe('PersistentRunner', () => { - let runner: PersistentRunner; - - beforeEach(() => { - vi.clearAllMocks(); - runner = new PersistentRunner({ - workdir: '/test/workdir', - }); - }); - - afterEach(async () => { - // shutdown で発生する Promise rejection を無視 - try { - runner.shutdown(); - } catch { - // ignore - } - // 未処理の Promise を待つ - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - - it('should create a runner instance', () => { - expect(runner).toBeInstanceOf(PersistentRunner); - expect(runner.isAlive()).toBe(false); // まだプロセス起動前 - }); - - it('should start process on first request', async () => { - const { spawn, getMockProcess } = await import('node:child_process'); - - // リクエストを送信(レスポンスは手動でシミュレート) - const runPromise = runner.run('test prompt'); - - // プロセスが起動したか確認 - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(spawn).toHaveBeenCalledWith( - 'claude', - expect.arrayContaining(['-p', '--input-format', 'stream-json']), - expect.any(Object) - ); - - // レスポンスをシミュレート - const mockProcess = getMockProcess(); - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'test response', - session_id: 'test-session-123', - is_error: false, - })}\n` - ); - - const result = await runPromise; - expect(result.result).toBe('test response'); - expect(result.sessionId).toBe('test-session-123'); - }); - - it('should queue multiple requests', async () => { - const { getMockProcess } = await import('node:child_process'); - - // 複数のリクエストを送信 - const promise1 = runner.run('prompt 1'); - const promise2 = runner.run('prompt 2'); - - expect(runner.getQueueLength()).toBeGreaterThanOrEqual(1); - - // 最初のレスポンス - await new Promise((resolve) => setTimeout(resolve, 50)); - const mockProcess = getMockProcess(); - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'response 1', - session_id: 'test-session-123', - is_error: false, - })}\n` - ); - - const result1 = await promise1; - expect(result1.result).toBe('response 1'); - - // 2番目のレスポンス - await new Promise((resolve) => setTimeout(resolve, 50)); - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'response 2', - session_id: 'test-session-123', - is_error: false, - })}\n` - ); - - const result2 = await promise2; - expect(result2.result).toBe('response 2'); - }); - - it('should call streaming callbacks', async () => { - const { getMockProcess } = await import('node:child_process'); - - const onText = vi.fn(); - const onComplete = vi.fn(); - - const promise = runner.runStream('test prompt', { onText, onComplete }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const mockProcess = getMockProcess(); - - // テキストストリーム - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'assistant', - message: { - content: [{ type: 'text', text: 'Hello ' }], - }, - })}\n` - ); - - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'assistant', - message: { - content: [{ type: 'text', text: 'World!' }], - }, - })}\n` - ); - - // 結果 - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'Hello World!', - session_id: 'test-session-123', - is_error: false, - })}\n` - ); - - await promise; - - expect(onText).toHaveBeenCalledWith('Hello ', 'Hello '); - expect(onText).toHaveBeenCalledWith('World!', 'Hello World!'); - expect(onComplete).toHaveBeenCalled(); - }); - - it('should handle errors', async () => { - const { getMockProcess } = await import('node:child_process'); - - const onError = vi.fn(); - const promise = runner.runStream('test prompt', { onError }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const mockProcess = getMockProcess(); - - // エラーレスポンス - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'Something went wrong', - session_id: 'test-session-123', - is_error: true, - })}\n` - ); - - await expect(promise).rejects.toThrow('Something went wrong'); - expect(onError).toHaveBeenCalled(); - }); - - it('should shutdown properly', async () => { - // プロセスを起動 - const promise = runner.run('test').catch(() => { - // shutdown によるエラーは無視 - }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - runner.shutdown(); - expect(runner.isAlive()).toBe(false); - - // Promise が終了するのを待つ - await promise; - }); - - it('should cancel current request', async () => { - const onError = vi.fn(); - const promise = runner.runStream('test prompt', { onError }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - // cancel を呼ぶ - const cancelled = runner.cancel(); - expect(cancelled).toBe(true); - expect(onError).toHaveBeenCalled(); - - await expect(promise).rejects.toThrow('Request cancelled by user'); - }); - - it('should return false when cancelling with no active request', () => { - const cancelled = runner.cancel(); - expect(cancelled).toBe(false); - }); - - it('should process next queued request after cancel', async () => { - const { getMockProcess } = await import('node:child_process'); - - const onError1 = vi.fn(); - const promise1 = runner.runStream('prompt 1', { onError: onError1 }); - const promise2 = runner.run('prompt 2'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - // 最初のリクエストをキャンセル - runner.cancel(); - await expect(promise1).rejects.toThrow('Request cancelled by user'); - - // 2番目のリクエストが処理される - await new Promise((resolve) => setTimeout(resolve, 50)); - const mockProcess = getMockProcess(); - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'response 2', - session_id: 'test-session-123', - is_error: false, - })}\n` - ); - - const result2 = await promise2; - expect(result2.result).toBe('response 2'); - }); - - it('should preserve streamed text when result only has final text', async () => { - // 問題2のテスト: ツール呼び出し前に出力されたテキストが result で消えないこと - const { getMockProcess } = await import('node:child_process'); - - const onText = vi.fn(); - const onComplete = vi.fn(); - - const promise = runner.runStream('test prompt', { onText, onComplete }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const mockProcess = getMockProcess(); - - // ツール呼び出し前にテキスト出力(!discord send を含む) - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'assistant', - message: { - content: [{ type: 'text', text: '!discord send <#123> 作業開始します\n' }], - }, - })}\n` - ); - - // ツール呼び出し後にテキスト出力 - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'assistant', - message: { - content: [{ type: 'text', text: '調査が完了しました。' }], - }, - })}\n` - ); - - // result には最後のテキストだけが入る(Claude Code CLIの実際の挙動) - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: '調査が完了しました。', - session_id: 'test-session-123', - is_error: false, - })}\n` - ); - - const result = await promise; - - // 累積テキスト全体が保持されていること - expect(result.result).toContain('!discord send <#123> 作業開始します'); - expect(result.result).toContain('調査が完了しました。'); - }); - - it('should not duplicate text when result matches streamed', async () => { - // result と streamed が同一の場合は重複しないこと - const { getMockProcess } = await import('node:child_process'); - - const promise = runner.run('test prompt'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const mockProcess = getMockProcess(); - - // テキスト出力 - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'assistant', - message: { - content: [{ type: 'text', text: 'Hello World!' }], - }, - })}\n` - ); - - // result が streamed と同じ - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'Hello World!', - session_id: 'test-session-123', - is_error: false, - })}\n` - ); - - const result = await promise; - // 重複していないこと - expect(result.result).toBe('Hello World!'); - }); - - it('should report session ID', async () => { - const { getMockProcess } = await import('node:child_process'); - - const promise = runner.run('test'); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const mockProcess = getMockProcess(); - - mockProcess.stdout.emit( - 'data', - `${JSON.stringify({ - type: 'result', - result: 'ok', - session_id: 'my-session-id', - is_error: false, - })}\n` - ); - - await promise; - expect(runner.getSessionId()).toBe('my-session-id'); - - // テスト終了前に明示的に shutdown してエラーを catch - try { - runner.shutdown(); - } catch { - // ignore - } - }); -}); diff --git a/tests/response-parser.test.ts b/tests/response-parser.test.ts deleted file mode 100644 index 465d0a7..0000000 --- a/tests/response-parser.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseAgentResponse } from '../src/lib/response-parser.js'; - -describe('parseAgentResponse', () => { - it('should return plain text as displayText with no commands', () => { - const result = parseAgentResponse('普通のテキスト\n改行あり'); - expect(result.displayText).toBe('普通のテキスト\n改行あり'); - expect(result.commands).toEqual([]); - }); - - it('should extract single-line !discord send', () => { - const result = parseAgentResponse('前文\n!discord send <#123> メッセージ\n後文'); - // インライン内容がある場合、後続行も次のコマンドまで吸収される - expect(result.commands).toEqual(['!discord send <#123> メッセージ\n後文']); - expect(result.displayText).toBe('前文'); - }); - - it('should extract multi-line !discord send (no inline content)', () => { - const result = parseAgentResponse('!discord send <#123>\n1行目\n2行目'); - expect(result.commands).toEqual(['!discord send <#123> 1行目\n2行目']); - expect(result.displayText).toBe(''); - }); - - it('should extract multi-line !discord send (with inline content)', () => { - const result = parseAgentResponse('!discord send <#123> 【ニュース1】タイトル\n→ 要点\nURL'); - expect(result.commands).toEqual(['!discord send <#123> 【ニュース1】タイトル\n→ 要点\nURL']); - expect(result.displayText).toBe(''); - }); - - it('should stop multi-line send at next command', () => { - const result = parseAgentResponse('!discord send <#123>\nメッセージ\n!discord channels\n後文'); - expect(result.commands).toEqual(['!discord send <#123> メッセージ', '!discord channels']); - expect(result.displayText).toBe('後文'); - }); - - it('should extract !discord channels', () => { - const result = parseAgentResponse('テキスト\n!discord channels\n後文'); - expect(result.commands).toEqual(['!discord channels']); - expect(result.displayText).toBe('テキスト\n後文'); - }); - - it('should extract !discord history', () => { - const result = parseAgentResponse('テキスト\n!discord history 20\n後文'); - expect(result.commands).toEqual(['!discord history 20']); - expect(result.displayText).toBe('テキスト\n後文'); - }); - - it('should extract !discord delete', () => { - const result = parseAgentResponse('テキスト\n!discord delete 123456789012345678\n後文'); - expect(result.commands).toEqual(['!discord delete 123456789012345678']); - expect(result.displayText).toBe('テキスト\n後文'); - }); - - it('should extract !schedule commands', () => { - const result = parseAgentResponse('テキスト\n!schedule 5分後 テスト\n後文'); - expect(result.commands).toEqual(['!schedule 5分後 テスト']); - expect(result.displayText).toBe('テキスト\n後文'); - }); - - it('should extract !schedule without args', () => { - const result = parseAgentResponse('テキスト\n!schedule\n後文'); - expect(result.commands).toEqual(['!schedule']); - expect(result.displayText).toBe('テキスト\n後文'); - }); - - it('should strip SYSTEM_COMMAND lines from display', () => { - const result = parseAgentResponse('テキスト\nSYSTEM_COMMAND:restart\n続き'); - expect(result.commands).toEqual(['SYSTEM_COMMAND:restart']); - expect(result.displayText).toBe('テキスト\n続き'); - }); - - it('should skip commands inside code blocks', () => { - const result = parseAgentResponse('例:\n```\n!discord send <#123> メッセージ\n```\n以上'); - expect(result.commands).toEqual([]); - expect(result.displayText).toBe('例:\n```\n!discord send <#123> メッセージ\n```\n以上'); - }); - - it('should skip !schedule inside code blocks', () => { - const result = parseAgentResponse('例:\n```\n!schedule 5分後 テスト\n```\n以上'); - expect(result.commands).toEqual([]); - expect(result.displayText).toBe('例:\n```\n!schedule 5分後 テスト\n```\n以上'); - }); - - it('should handle code blocks in multiline send body', () => { - const result = parseAgentResponse( - '!discord send <#123>\n本文開始\n```\n!discord send <#999> コードブロック内\n```\n本文続き\n!discord channels' - ); - expect(result.commands).toEqual([ - '!discord send <#123> 本文開始\n```\n!discord send <#999> コードブロック内\n```\n本文続き', - '!discord channels', - ]); - }); - - it('should handle multiple multiline sends', () => { - const result = parseAgentResponse( - '!discord send <#111>\nメッセージ1\n!discord send <#222>\nメッセージ2' - ); - expect(result.commands).toEqual([ - '!discord send <#111> メッセージ1', - '!discord send <#222> メッセージ2', - ]); - }); - - it('should skip empty multiline send', () => { - const result = parseAgentResponse('!discord send <#123>\n!discord channels'); - expect(result.commands).toEqual(['!discord channels']); - }); - - it('should handle mixed commands and text', () => { - const result = parseAgentResponse( - '説明:\n```\n!discord send <#123> 例文\n```\n実行:\n!discord send <#456> 本文\n以上' - ); - expect(result.commands).toEqual(['!discord send <#456> 本文\n以上']); - expect(result.displayText).toBe('説明:\n```\n!discord send <#123> 例文\n```\n実行:'); - }); - - it('should handle empty text', () => { - const result = parseAgentResponse(''); - expect(result.commands).toEqual([]); - expect(result.displayText).toBe(''); - }); -}); diff --git a/tests/runner-manager.test.ts b/tests/runner-manager.test.ts deleted file mode 100644 index 8724919..0000000 --- a/tests/runner-manager.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { RunnerManager } from '../src/agent/runner-manager.js'; - -// PersistentRunner をモック -vi.mock('../src/agent/persistent-runner.js', () => { - class MockPersistentRunner { - private alive = true; - private busy = false; - private currentPrompt: string | null = null; - private sessionId = ''; - private resolveRun: ((value: unknown) => void) | null = null; - - async run(prompt: string) { - this.currentPrompt = prompt; - this.busy = true; - const result = { result: `response for: ${prompt}`, sessionId: 'session-123' }; - this.busy = false; - return result; - } - - async runStream( - prompt: string, - callbacks: { onText?: (...args: never) => unknown; onComplete?: (...args: never) => unknown } - ) { - this.currentPrompt = prompt; - this.busy = true; - const result = { result: `stream response for: ${prompt}`, sessionId: 'session-123' }; - callbacks.onComplete?.(result); - this.busy = false; - return result; - } - - // テスト用: run を呼ぶと resolve されるまでビジーのまま待機する - runBlocking(prompt: string): Promise<{ result: string; sessionId: string }> { - this.currentPrompt = prompt; - this.busy = true; - return new Promise((resolve) => { - this.resolveRun = () => { - this.busy = false; - resolve({ result: `response for: ${prompt}`, sessionId: 'session-123' }); - }; - }); - } - - // テスト用: blocking run を完了させる - completeRun() { - this.resolveRun?.(); - this.resolveRun = null; - } - - cancel() { - if (this.currentPrompt) { - this.currentPrompt = null; - this.busy = false; - return true; - } - return false; - } - - cancelAll() { - if (this.currentPrompt) { - this.currentPrompt = null; - this.busy = false; - return 1; - } - return 0; - } - - shutdown() { - this.alive = false; - } - - isBusy() { - return this.busy; - } - - isAlive() { - return this.alive; - } - - getSessionId() { - return this.sessionId; - } - - setSessionId(id: string) { - this.sessionId = id; - } - - getQueueLength() { - return 0; - } - } - - return { PersistentRunner: MockPersistentRunner }; -}); - -describe('RunnerManager', () => { - let manager: RunnerManager; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - manager?.shutdown(); - vi.useRealTimers(); - }); - - it('should create a manager instance', () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - expect(manager).toBeInstanceOf(RunnerManager); - - const status = manager.getStatus(); - expect(status.poolSize).toBe(0); - expect(status.maxProcesses).toBe(3); - }); - - it('should create a runner for a new channel', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - const result = await manager.run('hello', { channelId: 'ch1' }); - expect(result.result).toBe('response for: hello'); - - const status = manager.getStatus(); - expect(status.poolSize).toBe(1); - expect(status.channels[0].channelId).toBe('ch1'); - }); - - it('should reuse runner for same channel', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - await manager.run('msg1', { channelId: 'ch1' }); - await manager.run('msg2', { channelId: 'ch1' }); - - const status = manager.getStatus(); - expect(status.poolSize).toBe(1); // 同じチャンネルのランナーを再利用 - }); - - it('should create separate runners for different channels', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - await manager.run('msg1', { channelId: 'ch1' }); - await manager.run('msg2', { channelId: 'ch2' }); - await manager.run('msg3', { channelId: 'ch3' }); - - const status = manager.getStatus(); - expect(status.poolSize).toBe(3); - }); - - it('should evict LRU runner when pool is full', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 2 }); - - await manager.run('msg1', { channelId: 'ch1' }); - - // ch1 の lastUsed を古くするために時間を進める - vi.advanceTimersByTime(1000); - - await manager.run('msg2', { channelId: 'ch2' }); - - // プールが満杯の状態で新しいチャンネルからリクエスト - vi.advanceTimersByTime(1000); - await manager.run('msg3', { channelId: 'ch3' }); - - const status = manager.getStatus(); - expect(status.poolSize).toBe(2); - - // ch1 (最も古い) が evict されて、ch2 と ch3 が残る - const channelIds = status.channels.map((c) => c.channelId); - expect(channelIds).toContain('ch2'); - expect(channelIds).toContain('ch3'); - expect(channelIds).not.toContain('ch1'); - }); - - it('should cleanup idle runners', async () => { - const idleTimeoutMs = 10 * 60 * 1000; // 10分 - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 5, idleTimeoutMs }); - - await manager.run('msg1', { channelId: 'ch1' }); - await manager.run('msg2', { channelId: 'ch2' }); - - expect(manager.getStatus().poolSize).toBe(2); - - // アイドルタイムアウトを超えるまで時間を進める - vi.advanceTimersByTime(idleTimeoutMs + 1000); - - // クリーンアップ間隔(5分)のタイマーが発火 - vi.advanceTimersByTime(5 * 60 * 1000); - - expect(manager.getStatus().poolSize).toBe(0); - }); - - it('should use default channel when channelId is not specified', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - const result = await manager.run('hello'); - expect(result.result).toBe('response for: hello'); - - const status = manager.getStatus(); - expect(status.poolSize).toBe(1); - expect(status.channels[0].channelId).toBe('__default__'); - }); - - it('should support streaming', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - const onComplete = vi.fn(); - const result = await manager.runStream('hello', { onComplete }, { channelId: 'ch1' }); - - expect(result.result).toBe('stream response for: hello'); - expect(onComplete).toHaveBeenCalled(); - }); - - it('should cancel by channel', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - // ランナーを作成 - await manager.run('msg1', { channelId: 'ch1' }); - await manager.run('msg2', { channelId: 'ch2' }); - - // ch1 のキャンセルを試みる(モックは currentPrompt が null なので false) - const cancelled = manager.cancel('ch1'); - expect(typeof cancelled).toBe('boolean'); - }); - - it('should cancel returns false for unknown channel', () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - const cancelled = manager.cancel('unknown'); - expect(cancelled).toBe(false); - }); - - it('should shutdown all runners', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - await manager.run('msg1', { channelId: 'ch1' }); - await manager.run('msg2', { channelId: 'ch2' }); - - expect(manager.getStatus().poolSize).toBe(2); - - manager.shutdown(); - - expect(manager.getStatus().poolSize).toBe(0); - }); - - it('should use default maxProcesses of 10', () => { - manager = new RunnerManager({ workdir: '/test' }); - - const status = manager.getStatus(); - expect(status.maxProcesses).toBe(10); - }); - - it('should report status correctly', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 5 }); - - await manager.run('msg1', { channelId: 'ch1' }); - - vi.advanceTimersByTime(5000); - - await manager.run('msg2', { channelId: 'ch2' }); - - const status = manager.getStatus(); - expect(status.poolSize).toBe(2); - expect(status.maxProcesses).toBe(5); - - const ch1 = status.channels.find((c) => c.channelId === 'ch1'); - const ch2 = status.channels.find((c) => c.channelId === 'ch2'); - - expect(ch1).toBeDefined(); - expect(ch2).toBeDefined(); - expect(ch1?.idleSeconds).toBeGreaterThanOrEqual(5); - expect(ch2?.idleSeconds).toBeLessThanOrEqual(1); - }); - - it('should cancelAll across multiple runners for a channel', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 5 }); - - // 同じチャンネルで2つのリクエストを実行(内部キューで順次処理) - await manager.run('msg1', { channelId: 'ch1' }); - await manager.run('msg2', { channelId: 'ch1' }); - - const cancelled = manager.cancelAll('ch1'); - expect(typeof cancelled).toBe('number'); - }); - - it('should destroy all runners for a channel', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 5 }); - - await manager.run('msg1', { channelId: 'ch1' }); - await manager.run('msg2', { channelId: 'ch1' }); - - expect(manager.destroy('ch1')).toBe(true); - expect(manager.getStatus().poolSize).toBe(0); - expect(manager.destroy('ch1')).toBe(false); - }); - - // Session management tests - it('should track sessionId from run result', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - await manager.run('hello', { channelId: 'ch1' }); - - // RunResult の sessionId が内部で保持される - expect(manager.getSessionId('ch1')).toBe('session-123'); - }); - - it('should return undefined sessionId for unknown channel', () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - expect(manager.getSessionId('unknown')).toBeUndefined(); - }); - - it('should clear sessionId when channel is destroyed', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - await manager.run('hello', { channelId: 'ch1' }); - expect(manager.getSessionId('ch1')).toBe('session-123'); - - manager.destroy('ch1'); - expect(manager.getSessionId('ch1')).toBeUndefined(); - }); - - it('should not require sessionId in RunOptions', async () => { - manager = new RunnerManager({ workdir: '/test' }, { maxProcesses: 3 }); - - // sessionId を渡さなくても内部で自動管理される - const result = await manager.run('msg1', { channelId: 'ch1' }); - expect(result.sessionId).toBe('session-123'); - - // 2回目は内部で保持されたsessionIdを自動的に使う - const result2 = await manager.run('msg2', { channelId: 'ch1' }); - expect(result2.sessionId).toBe('session-123'); - }); -}); diff --git a/tests/schedule-handler.test.ts b/tests/schedule-handler.test.ts index 70dcc4e..3d5520a 100644 --- a/tests/schedule-handler.test.ts +++ b/tests/schedule-handler.test.ts @@ -8,15 +8,7 @@ vi.mock('../src/scheduler/scheduler.js', () => ({ SCHEDULE_SEPARATOR: '{{SPLIT}}', })); -vi.mock('../src/discord/discord-types.js', () => ({ - isSendableChannel: vi.fn().mockReturnValue(true), -})); - -import { - executeScheduleFromResponse, - handleScheduleCommand, - handleScheduleMessage, -} from '../src/scheduler/schedule-handler.js'; +import { handleScheduleCommand } from '../src/scheduler/schedule-handler.js'; import { formatScheduleList, parseScheduleInput } from '../src/scheduler/scheduler.js'; const mockParseScheduleInput = vi.mocked(parseScheduleInput); @@ -36,13 +28,6 @@ function createMockInteraction(overrides: Record = {}) { } as unknown as Parameters[0]; } -function createMockMessage(channelId = 'ch-1') { - return { - channel: { id: channelId, send: vi.fn().mockResolvedValue(undefined) }, - reply: vi.fn().mockResolvedValue(undefined), - } as unknown as Parameters[0]; -} - function createMockScheduler(schedules: Array<{ id: string; enabled?: boolean }> = []) { return { list: vi.fn().mockReturnValue(schedules), @@ -181,223 +166,3 @@ describe('handleScheduleCommand', () => { expect(replyArg).toContain('⏸️ 無効'); }); }); - -// ─── handleScheduleMessage ───────────────────────────────── -describe('handleScheduleMessage', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockFormatScheduleList.mockReturnValue('📋 スケジュールはありません'); - }); - - it('should list schedules for "!schedule" (no args)', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - - await handleScheduleMessage(message, '!schedule', scheduler); - - expect(scheduler.list).toHaveBeenCalled(); - expect(message.reply).toHaveBeenCalled(); - }); - - it('should list schedules for "!schedule list"', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - - await handleScheduleMessage(message, '!schedule list', scheduler); - - expect(scheduler.list).toHaveBeenCalled(); - expect(message.reply).toHaveBeenCalled(); - }); - - it('should remove schedule by id with "!schedule remove"', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_1' }]); - - await handleScheduleMessage(message, '!schedule remove sch_1', scheduler); - - expect(scheduler.remove).toHaveBeenCalledWith('sch_1'); - const replyArg = (message.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('✅'); - expect(replyArg).toContain('1件削除'); - }); - - it('should remove schedule by number index', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_a' }, { id: 'sch_b' }]); - - await handleScheduleMessage(message, '!schedule remove 2', scheduler); - - expect(scheduler.remove).toHaveBeenCalledWith('sch_b'); - }); - - it('should report error for out-of-range number', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_a' }]); - - await handleScheduleMessage(message, '!schedule remove 5', scheduler); - - const replyArg = (message.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('⚠️'); - expect(replyArg).toContain('番号 5 は範囲外'); - }); - - it('should fall through to add path for "!schedule remove" (no id)', async () => { - // args = "remove" after trim, "remove".startsWith("remove ") is false (no trailing space) - // So it falls through to the add path, and parseScheduleInput returns null → error - const message = createMockMessage(); - const scheduler = createMockScheduler(); - mockParseScheduleInput.mockReturnValue(null); - - await handleScheduleMessage(message, '!schedule remove', scheduler); - - const replyArg = (message.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('❌'); - expect(replyArg).toContain('対応フォーマット'); - }); - - it('should support "!schedule delete" alias', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_x' }]); - - await handleScheduleMessage(message, '!schedule delete sch_x', scheduler); - - expect(scheduler.remove).toHaveBeenCalledWith('sch_x'); - }); - - it('should support "!schedule rm" alias', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_y' }]); - - await handleScheduleMessage(message, '!schedule rm sch_y', scheduler); - - expect(scheduler.remove).toHaveBeenCalledWith('sch_y'); - }); - - it('should toggle schedule by id', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_1', enabled: true }]); - - await handleScheduleMessage(message, '!schedule toggle sch_1', scheduler); - - expect(scheduler.toggle).toHaveBeenCalledWith('sch_1'); - }); - - it('should toggle schedule by number index', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_a', enabled: true }]); - (scheduler.list as ReturnType).mockReturnValue([{ id: 'sch_a', enabled: true }]); - - await handleScheduleMessage(message, '!schedule toggle 1', scheduler); - - expect(scheduler.toggle).toHaveBeenCalledWith('sch_a'); - }); - - it('should add schedule for "!schedule add ..." with valid input', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - mockParseScheduleInput.mockReturnValue({ - type: 'cron', - expression: '0 9 * * *', - message: 'おはよう', - }); - - await handleScheduleMessage(message, '!schedule add 毎日 9:00 おはよう', scheduler); - - expect(scheduler.add).toHaveBeenCalled(); - const replyArg = (message.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('✅'); - }); - - it('should add schedule for "!schedule ..." without "add" prefix', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - mockParseScheduleInput.mockReturnValue({ - type: 'cron', - expression: '0 9 * * *', - message: 'おはよう', - }); - - await handleScheduleMessage(message, '!schedule 毎日 9:00 おはよう', scheduler); - - expect(scheduler.add).toHaveBeenCalled(); - }); - - it('should reply with error for unparseable input', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - mockParseScheduleInput.mockReturnValue(null); - - await handleScheduleMessage(message, '!schedule なんか変なもの', scheduler); - - const replyArg = (message.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('❌'); - expect(replyArg).toContain('対応フォーマット'); - }); -}); - -// ─── executeScheduleFromResponse ─────────────────────────── -describe('executeScheduleFromResponse', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockFormatScheduleList.mockReturnValue('📋 スケジュールはありません'); - }); - - it('should list schedules for "!schedule list"', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - - await executeScheduleFromResponse('!schedule list', message, scheduler); - - expect(scheduler.list).toHaveBeenCalled(); - expect(message.channel.send).toHaveBeenCalled(); - }); - - it('should remove schedules for "!schedule remove sch_1"', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler([{ id: 'sch_1' }]); - - await executeScheduleFromResponse('!schedule remove sch_1', message, scheduler); - - expect(scheduler.remove).toHaveBeenCalledWith('sch_1'); - }); - - it('should add schedule for valid input', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - mockParseScheduleInput.mockReturnValue({ - type: 'cron', - expression: '0 9 * * *', - message: 'おはよう', - }); - - await executeScheduleFromResponse('!schedule 毎日 9:00 おはよう', message, scheduler); - - expect(scheduler.add).toHaveBeenCalled(); - }); - - it('should silently ignore unparseable input', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - mockParseScheduleInput.mockReturnValue(null); - - await executeScheduleFromResponse('!schedule 意味不明', message, scheduler); - - expect(scheduler.add).not.toHaveBeenCalled(); - expect(message.channel.send).not.toHaveBeenCalled(); - }); - - it('should use channel.send instead of message.reply', async () => { - const message = createMockMessage(); - const scheduler = createMockScheduler(); - mockParseScheduleInput.mockReturnValue({ - type: 'cron', - expression: '0 9 * * *', - message: 'test', - }); - - await executeScheduleFromResponse('!schedule 毎日 9:00 test', message, scheduler); - - expect(message.channel.send).toHaveBeenCalled(); - expect(message.reply).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/schedule-send.test.ts b/tests/schedule-send.test.ts new file mode 100644 index 0000000..6baccef --- /dev/null +++ b/tests/schedule-send.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { sendScheduleContent } from '../src/discord/schedule-send.js'; + +// ─── sendScheduleContent ─────────────────────────────────── +describe('sendScheduleContent', () => { + it('should reply with short content directly', async () => { + const target = { + reply: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), + }; + await sendScheduleContent(target, 'short'); + expect(target.reply).toHaveBeenCalledWith('short'); + expect(target.followUp).not.toHaveBeenCalled(); + }); + + it('should use followUp for overflow', async () => { + const target = { + reply: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), + }; + // Create content that exceeds DISCORD_MAX_LENGTH (2000) + const longText = `${'a'.repeat(1200)}\n{{SPLIT}}\n${'b'.repeat(1200)}`; + await sendScheduleContent(target, longText); + expect(target.reply).toHaveBeenCalledTimes(1); + expect(target.followUp.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('should strip SCHEDULE_SEPARATOR from content', async () => { + const target = { + reply: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), + }; + await sendScheduleContent(target, 'line1{{SPLIT}}line2'); + const called = target.reply.mock.calls[0][0] as string; + expect(called).not.toContain('{{SPLIT}}'); + expect(called).toContain('line1'); + expect(called).toContain('line2'); + }); +}); diff --git a/tests/sdk-runner.test.ts b/tests/sdk-runner.test.ts new file mode 100644 index 0000000..6ab1d8b --- /dev/null +++ b/tests/sdk-runner.test.ts @@ -0,0 +1,277 @@ +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CANCELLED_ERROR_MESSAGE } from '../src/lib/constants.js'; +import { RunContext } from '../src/mcp/context.js'; + +// Mock the SDK's query function +vi.mock('@anthropic-ai/claude-agent-sdk', async () => { + const actual = await vi.importActual('@anthropic-ai/claude-agent-sdk'); + return { + ...actual, + query: vi.fn(), + }; +}); + +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { SdkRunner } from '../src/agent/sdk-runner.js'; + +const mockQuery = query as ReturnType; + +function createMockMcpServer() { + return { + server: {}, + transport: {}, + } as any; +} + +async function* generateMessages(messages: SDKMessage[]): AsyncGenerator { + for (const msg of messages) { + yield msg; + } +} + +describe('SdkRunner', () => { + let runner: SdkRunner; + let runContext: RunContext; + + beforeEach(() => { + vi.clearAllMocks(); + runContext = new RunContext(); + runner = new SdkRunner( + { model: 'test-model', timeoutMs: 5000, workdir: '/tmp/test' }, + createMockMcpServer(), + runContext + ); + }); + + afterEach(() => { + runner.shutdown(); + }); + + describe('run / runStream', () => { + it('should process a simple text result', async () => { + const messages: SDKMessage[] = [ + { + type: 'system', + subtype: 'init', + session_id: 'sess-123', + } as any, + { + type: 'result', + subtype: 'success', + result: 'Hello world', + session_id: 'sess-123', + } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + const result = await runner.run('test prompt'); + expect(result.result).toBe('Hello world'); + expect(result.sessionId).toBe('sess-123'); + }); + + it('should capture session ID from system/init', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 'new-session' } as any, + { type: 'result', subtype: 'success', result: 'ok', session_id: 'new-session' } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + await runner.run('test'); + expect(runner.getSessionId()).toBe('new-session'); + }); + + it('should call onText callback for assistant messages', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 's1' } as any, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'Hello' }] }, + } as any, + { type: 'result', subtype: 'success', result: 'Hello', session_id: 's1' } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + const onText = vi.fn(); + await runner.runStream('test', { onText }); + expect(onText).toHaveBeenCalledWith('Hello', 'Hello'); + }); + + it('should call onProgress for tool_use blocks', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 's1' } as any, + { + type: 'assistant', + message: { + content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }], + }, + } as any, + { type: 'result', subtype: 'success', result: '', session_id: 's1' } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + const onProgress = vi.fn(); + await runner.runStream('test', { onProgress }); + expect(onProgress).toHaveBeenCalledWith('Bash', { command: 'ls' }); + }); + + it('should call onComplete for successful result', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 's1' } as any, + { type: 'result', subtype: 'success', result: 'done', session_id: 's1' } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + const onComplete = vi.fn(); + await runner.runStream('test', { onComplete }); + expect(onComplete).toHaveBeenCalledWith({ result: 'done', sessionId: 's1' }); + }); + + it('should call onError for error result', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 's1' } as any, + { type: 'result', subtype: 'error', errors: ['Something failed'] } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + const onError = vi.fn(); + // The result message with error subtype calls onError but doesn't throw; + // fullText will be empty since no text was streamed + const result = await runner.runStream('test', { onError }); + expect(onError).toHaveBeenCalled(); + expect(result.result).toBe(''); + }); + + it('should handle stream_event text deltas', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 's1' } as any, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'partial ' }, + }, + } as any, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'result' }, + }, + } as any, + { + type: 'result', + subtype: 'success', + result: 'partial result', + session_id: 's1', + } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + const onText = vi.fn(); + const result = await runner.runStream('test', { onText }); + expect(onText).toHaveBeenCalledTimes(2); + expect(result.result).toBe('partial result'); + }); + }); + + describe('context propagation', () => { + it('should set channelId and guildId in RunContext', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 's1' } as any, + { type: 'result', subtype: 'success', result: 'ok', session_id: 's1' } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + await runner.run('test', { channelId: 'ch-1', guildId: 'guild-1' }); + // After run completes, context should be cleared + expect(runContext.get().channelId).toBe(''); + expect(runContext.get().guildId).toBeUndefined(); + }); + }); + + describe('session management', () => { + it('should update sessionId from result message', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 'old' } as any, + { type: 'result', subtype: 'success', result: 'ok', session_id: 'new' } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + await runner.run('test'); + expect(runner.getSessionId()).toBe('new'); + }); + + it('should use resumeSessionId when set', async () => { + const messages: SDKMessage[] = [ + { type: 'system', subtype: 'init', session_id: 'resumed' } as any, + { type: 'result', subtype: 'success', result: 'ok', session_id: 'resumed' } as any, + ]; + mockQuery.mockReturnValue(generateMessages(messages)); + + runner.setSessionId('resume-target'); + await runner.run('test'); + + // Verify query was called with resume option + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + resume: 'resume-target', + }), + }) + ); + }); + }); + + describe('cancel / abort', () => { + it('should cancel via AbortController', async () => { + async function* abortableStream(): AsyncGenerator { + yield { type: 'system', subtype: 'init', session_id: 's1' } as any; + // Simulate SDK throwing AbortError when aborted + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + throw error; + } + mockQuery.mockReturnValue(abortableStream()); + + const onError = vi.fn(); + await expect(runner.runStream('test', { onError })).rejects.toThrow(CANCELLED_ERROR_MESSAGE); + expect(onError).toHaveBeenCalled(); + }); + + it('should return false when nothing to cancel', () => { + expect(runner.cancel()).toBe(false); + }); + }); + + describe('busy state', () => { + it('should report busy during execution', async () => { + let resolveStream: () => void; + const streamPromise = new Promise((r) => { + resolveStream = r; + }); + + async function* slowStream(): AsyncGenerator { + yield { type: 'system', subtype: 'init', session_id: 's1' } as any; + await streamPromise; + yield { type: 'result', subtype: 'success', result: 'ok', session_id: 's1' } as any; + } + mockQuery.mockReturnValue(slowStream()); + + const runPromise = runner.run('test'); + // Give the async generator a tick to start + await new Promise((r) => setTimeout(r, 0)); + expect(runner.isBusy()).toBe(true); + + resolveStream!(); + await runPromise; + expect(runner.isBusy()).toBe(false); + }); + }); + + describe('isAlive', () => { + it('should always return true', () => { + expect(runner.isAlive()).toBe(true); + }); + }); +}); diff --git a/tests/sessions.test.ts b/tests/sessions.test.ts deleted file mode 100644 index f969c6f..0000000 --- a/tests/sessions.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - clearSessions, - deleteSession, - getSession, - getSessionCount, - initSessions, - setSession, -} from '../src/lib/sessions.js'; - -describe('sessions', () => { - let testDir: string; - - beforeEach(() => { - clearSessions(); - testDir = mkdtempSync(join(tmpdir(), 'sessions-test-')); - }); - - afterEach(() => { - clearSessions(); - if (testDir && existsSync(testDir)) { - rmSync(testDir, { recursive: true }); - } - }); - - describe('initSessions', () => { - it('should initialize with empty sessions', () => { - initSessions(testDir); - expect(getSessionCount()).toBe(0); - }); - - it('should load existing sessions from file', () => { - // 事前にファイルを作成 - const sessionsPath = join(testDir, 'sessions.json'); - const data = { 'channel-1': 'session-abc', 'channel-2': 'session-def' }; - require('node:fs').writeFileSync(sessionsPath, JSON.stringify(data)); - - initSessions(testDir); - expect(getSessionCount()).toBe(2); - expect(getSession('channel-1')).toBe('session-abc'); - expect(getSession('channel-2')).toBe('session-def'); - }); - }); - - describe('getSession', () => { - it('should return undefined for unknown channel', () => { - initSessions(testDir); - expect(getSession('unknown')).toBeUndefined(); - }); - - it('should return session ID for known channel', () => { - initSessions(testDir); - setSession('channel-1', 'session-123'); - expect(getSession('channel-1')).toBe('session-123'); - }); - }); - - describe('setSession', () => { - it('should save session and persist to file', () => { - initSessions(testDir); - setSession('channel-1', 'session-123'); - - // ファイルに保存されたか確認 - const sessionsPath = join(testDir, 'sessions.json'); - expect(existsSync(sessionsPath)).toBe(true); - - const saved = JSON.parse(readFileSync(sessionsPath, 'utf-8')); - expect(saved['channel-1']).toBe('session-123'); - }); - - it('should update existing session', () => { - initSessions(testDir); - setSession('channel-1', 'session-old'); - setSession('channel-1', 'session-new'); - - expect(getSession('channel-1')).toBe('session-new'); - }); - }); - - describe('deleteSession', () => { - it('should delete session and persist', () => { - initSessions(testDir); - setSession('channel-1', 'session-123'); - expect(getSession('channel-1')).toBe('session-123'); - - const deleted = deleteSession('channel-1'); - expect(deleted).toBe(true); - expect(getSession('channel-1')).toBeUndefined(); - - // ファイルからも削除されたか確認 - const sessionsPath = join(testDir, 'sessions.json'); - const saved = JSON.parse(readFileSync(sessionsPath, 'utf-8')); - expect(saved['channel-1']).toBeUndefined(); - }); - - it('should return false for unknown channel', () => { - initSessions(testDir); - const deleted = deleteSession('unknown'); - expect(deleted).toBe(false); - }); - }); - - describe('persistence across restarts', () => { - it('should persist sessions across init calls', () => { - initSessions(testDir); - setSession('channel-1', 'session-abc'); - setSession('channel-2', 'session-def'); - - // シミュレート: プロセス再起動 - clearSessions(); - initSessions(testDir); - - expect(getSession('channel-1')).toBe('session-abc'); - expect(getSession('channel-2')).toBe('session-def'); - }); - }); -}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 5a2a511..0c0942e 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { clearSettingsCache, - formatSettings, initSettings, loadSettings, saveSettings, @@ -84,17 +83,4 @@ describe('settings', () => { expect(loaded.autoRestart).toBe(false); }); }); - - describe('formatSettings', () => { - it('should format settings with ON status', () => { - const result = formatSettings({ autoRestart: true }); - expect(result).toContain('✅ ON'); - expect(result).toContain('自動再起動'); - }); - - it('should format settings with OFF status', () => { - const result = formatSettings({ autoRestart: false }); - expect(result).toContain('❌ OFF'); - }); - }); }); diff --git a/tests/skills.test.ts b/tests/skills.test.ts deleted file mode 100644 index 1eac551..0000000 --- a/tests/skills.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { formatSkillList, type Skill } from '../src/lib/skills.js'; - -describe('skills', () => { - describe('formatSkillList', () => { - it('should show empty message when no skills', () => { - const result = formatSkillList([]); - expect(result).toContain('利用可能なスキルはありません'); - }); - - it('should format single skill', () => { - const skills: Skill[] = [ - { name: 'test-skill', description: 'テスト用スキル', path: '/path/to/skill' }, - ]; - const result = formatSkillList(skills); - - expect(result).toContain('利用可能なスキル'); - expect(result).toContain('test-skill'); - expect(result).toContain('テスト用スキル'); - }); - - it('should format multiple skills', () => { - const skills: Skill[] = [ - { name: 'skill-1', description: '説明1', path: '/path/1' }, - { name: 'skill-2', description: '説明2', path: '/path/2' }, - ]; - const result = formatSkillList(skills); - - expect(result).toContain('skill-1'); - expect(result).toContain('skill-2'); - expect(result).toContain('説明1'); - expect(result).toContain('説明2'); - }); - - it('should include usage instructions', () => { - const skills: Skill[] = [{ name: 'test', description: 'desc', path: '/path' }]; - const result = formatSkillList(skills); - - expect(result).toContain('/skill'); - }); - }); -}); diff --git a/tests/slash-commands.test.ts b/tests/slash-commands.test.ts index 7eead5e..39b749d 100644 --- a/tests/slash-commands.test.ts +++ b/tests/slash-commands.test.ts @@ -1,47 +1,43 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AgentRunner } from '../src/agent/agent-runner.js'; +import type { Brain } from '../src/brain/brain.js'; import { buildSlashCommands, formatChannelStatus, - handleAutocomplete, handleSlashCommand, type SlashCommandDeps, } from '../src/discord/slash-commands.js'; -import type { Skill } from '../src/lib/skills.js'; // Mock settings module vi.mock('../src/lib/settings.js', () => ({ loadSettings: vi.fn().mockReturnValue({ autoRestart: false }), - formatSettings: vi.fn().mockReturnValue('⚙️ Settings'), saveSettings: vi.fn(), })); -// Mock message-handler -vi.mock('../src/discord/message-handler.js', () => ({ - executeSkillCommand: vi.fn().mockResolvedValue(undefined), -})); - // Mock schedule-handler vi.mock('../src/scheduler/schedule-handler.js', () => ({ handleScheduleCommand: vi.fn().mockResolvedValue(undefined), })); -import { executeSkillCommand } from '../src/discord/message-handler.js'; -import { loadSettings } from '../src/lib/settings.js'; - // ─── Test helpers ────────────────────────────────────────── -function createMockAgentRunner(overrides: Partial = {}): AgentRunner { +function createMockBrain(overrides: Partial = {}): Brain { return { - run: vi.fn().mockResolvedValue({ result: 'done' }), - runStream: vi.fn().mockResolvedValue({ result: 'done' }), - getSessionId: vi.fn().mockReturnValue(null), - deleteSession: vi.fn(), - destroy: vi.fn(), + run: vi.fn().mockResolvedValue({ result: 'done', sessionId: 'test' }), + runStream: vi.fn().mockResolvedValue({ result: 'done', sessionId: 'test' }), + cancel: vi.fn().mockReturnValue(false), cancelAll: vi.fn().mockReturnValue(0), - getStatus: vi.fn().mockReturnValue(null), - shutdown: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn(), + isBusy: vi.fn().mockReturnValue(false), + getIdleTime: vi.fn().mockReturnValue(0), + getSessionId: vi.fn().mockReturnValue(''), + getStatus: vi.fn().mockReturnValue({ + busy: false, + queueLength: 0, + currentPriority: null, + alive: false, + sessionId: '', + }), ...overrides, - } as unknown as AgentRunner; + } as unknown as Brain; } function createMockInteraction(commandName: string, options: Record = {}) { @@ -62,13 +58,10 @@ function createMockInteraction(commandName: string, options: Record = {}): SlashCommandDeps { return { - agentRunner: createMockAgentRunner(), + brain: createMockBrain(), scheduler: {} as SlashCommandDeps['scheduler'], config: { discord: {}, agent: {} } as SlashCommandDeps['config'], - skills: [], processingChannels: new Map(), - workdir: '/tmp/test', - reloadSkills: vi.fn().mockReturnValue([]), ...overrides, }; } @@ -76,138 +69,67 @@ function createDeps(overrides: Partial = {}): SlashCommandDeps // ─── formatChannelStatus ─────────────────────────────────── describe('formatChannelStatus', () => { it('should show idle status when no processing', () => { - const runner = createMockAgentRunner(); - const result = formatChannelStatus('ch-1', new Map(), runner); - expect(result).toContain('🔓 待機中'); - expect(result).toContain('❌ なし'); + const brain = createMockBrain(); + const result = formatChannelStatus('ch-1', new Map(), brain); + expect(result).toContain('idle'); }); it('should show processing count when channel is active', () => { - const runner = createMockAgentRunner(); + const brain = createMockBrain(); const channels = new Map([['ch-1', 3]]); - const result = formatChannelStatus('ch-1', channels, runner); - expect(result).toContain('🔒 3件処理中'); + const result = formatChannelStatus('ch-1', channels, brain); + expect(result).toContain('3 tasks'); }); it('should show session id when present', () => { - const runner = createMockAgentRunner({ - getSessionId: vi.fn().mockReturnValue('abcdef123456789'), - }); - const result = formatChannelStatus('ch-1', new Map(), runner); - expect(result).toContain('✅ abcdef123456...'); - }); - - it('should show runner pool status when available', () => { - const runner = createMockAgentRunner({ + const brain = createMockBrain({ getStatus: vi.fn().mockReturnValue({ - poolSize: 2, - maxProcesses: 5, - channels: [{ channelId: 'ch-1', alive: true, idleSeconds: 10 }], + busy: false, + queueLength: 0, + currentPriority: null, + alive: true, + sessionId: 'abcdef123456789', }), }); - const result = formatChannelStatus('ch-1', new Map(), runner); - expect(result).toContain('Runner pool: 2/5'); - expect(result).toContain('✅ alive'); - expect(result).toContain('idle 10s'); + const result = formatChannelStatus('ch-1', new Map(), brain); + expect(result).toContain('abcdef123456...'); }); - it('should show "なし" when channel has no runner', () => { - const runner = createMockAgentRunner({ + it('should show brain status', () => { + const brain = createMockBrain({ getStatus: vi.fn().mockReturnValue({ - poolSize: 1, - maxProcesses: 5, - channels: [{ channelId: 'other-ch', alive: true, idleSeconds: 0 }], + busy: true, + queueLength: 2, + currentPriority: 0, + alive: true, + sessionId: 'test-session', }), }); - const result = formatChannelStatus('ch-1', new Map(), runner); - expect(result).toContain('チャンネルランナー: なし'); - }); -}); - -// ─── handleAutocomplete ──────────────────────────────────── -describe('handleAutocomplete', () => { - it('should filter skills by name', async () => { - const skills: Skill[] = [ - { name: 'deploy', description: 'Deploy to prod', prompt: '' }, - { name: 'test', description: 'Run tests', prompt: '' }, - { name: 'debug', description: 'Debug app', prompt: '' }, - ]; - const interaction = { - options: { getFocused: vi.fn().mockReturnValue('de') }, - respond: vi.fn().mockResolvedValue(undefined), - } as unknown as Parameters[0]; - - await handleAutocomplete(interaction, skills); - - const responded = (interaction.respond as ReturnType).mock.calls[0][0]; - expect(responded).toHaveLength(2); // deploy, debug - expect(responded.map((r: { value: string }) => r.value)).toContain('deploy'); - expect(responded.map((r: { value: string }) => r.value)).toContain('debug'); - }); - - it('should filter skills by description', async () => { - const skills: Skill[] = [ - { name: 'a', description: 'Deploy to production', prompt: '' }, - { name: 'b', description: 'Run unit tests', prompt: '' }, - ]; - const interaction = { - options: { getFocused: vi.fn().mockReturnValue('prod') }, - respond: vi.fn().mockResolvedValue(undefined), - } as unknown as Parameters[0]; - - await handleAutocomplete(interaction, skills); - - const responded = (interaction.respond as ReturnType).mock.calls[0][0]; - expect(responded).toHaveLength(1); - expect(responded[0].value).toBe('a'); - }); - - it('should limit results to 25', async () => { - const skills: Skill[] = Array.from({ length: 30 }, (_, i) => ({ - name: `skill-${i}`, - description: `desc ${i}`, - prompt: '', - })); - const interaction = { - options: { getFocused: vi.fn().mockReturnValue('') }, - respond: vi.fn().mockResolvedValue(undefined), - } as unknown as Parameters[0]; - - await handleAutocomplete(interaction, skills); - - const responded = (interaction.respond as ReturnType).mock.calls[0][0]; - expect(responded).toHaveLength(25); + const result = formatChannelStatus('ch-1', new Map(), brain); + expect(result).toContain('busy'); + expect(result).toContain('queue: 2'); }); }); // ─── buildSlashCommands ──────────────────────────────────── describe('buildSlashCommands', () => { - it('should include all built-in commands', () => { - const commands = buildSlashCommands([]); + it('should include stop, status, and schedule commands', () => { + const commands = buildSlashCommands(); const names = commands.map((c) => c.name); - expect(names).toContain('new'); expect(names).toContain('stop'); expect(names).toContain('status'); - expect(names).toContain('settings'); - expect(names).toContain('restart'); expect(names).toContain('schedule'); - expect(names).toContain('personalize'); - expect(names).toContain('skills'); - expect(names).toContain('skill'); - }); - - it('should add skill-based commands', () => { - const skills: Skill[] = [{ name: 'deploy', description: 'Deploy app', prompt: '' }]; - const commands = buildSlashCommands(skills); - const names = commands.map((c) => c.name); - expect(names).toContain('deploy'); }); - it('should normalize skill names for slash commands', () => { - const skills: Skill[] = [{ name: 'My_Skill!', description: 'Test', prompt: '' }]; - const commands = buildSlashCommands(skills); + it('should not include removed commands', () => { + const commands = buildSlashCommands(); const names = commands.map((c) => c.name); - expect(names).toContain('my-skill-'); + expect(names).not.toContain('new'); + expect(names).not.toContain('settings'); + expect(names).not.toContain('restart'); + expect(names).not.toContain('skills'); + expect(names).not.toContain('skill'); + expect(names).not.toContain('personalize'); }); }); @@ -217,20 +139,9 @@ describe('handleSlashCommand', () => { vi.clearAllMocks(); }); - it('should handle /new command', async () => { - const deps = createDeps(); - const interaction = createMockInteraction('new'); - - await handleSlashCommand(interaction, 'ch-test', deps); - - expect(deps.agentRunner.deleteSession).toHaveBeenCalledWith('ch-test'); - expect(deps.agentRunner.destroy).toHaveBeenCalledWith('ch-test'); - expect(interaction.reply).toHaveBeenCalledWith('🆕 新しいセッションを開始しました'); - }); - it('should handle /stop with active tasks', async () => { const deps = createDeps({ - agentRunner: createMockAgentRunner({ cancelAll: vi.fn().mockReturnValue(2) }), + brain: createMockBrain({ cancelAll: vi.fn().mockReturnValue(2) }), processingChannels: new Map([['ch-test', 3]]), }); const interaction = createMockInteraction('stop'); @@ -239,8 +150,8 @@ describe('handleSlashCommand', () => { expect(deps.processingChannels.has('ch-test')).toBe(false); const replyArg = (interaction.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('🛑'); - expect(replyArg).toContain('2件キャンセル'); + expect(replyArg).toContain('Stopped'); + expect(replyArg).toContain('2 cancelled'); }); it('should handle /stop with no active tasks', async () => { @@ -250,7 +161,7 @@ describe('handleSlashCommand', () => { await handleSlashCommand(interaction, 'ch-test', deps); const replyArg = (interaction.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('実行中のタスクはありません'); + expect(replyArg).toContain('No tasks running'); }); it('should handle /status command', async () => { @@ -261,83 +172,6 @@ describe('handleSlashCommand', () => { expect(interaction.reply).toHaveBeenCalled(); const replyArg = (interaction.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('📊'); - }); - - it('should handle /settings show', async () => { - const deps = createDeps(); - const interaction = createMockInteraction('settings'); - - await handleSlashCommand(interaction, 'ch-test', deps); - - expect(loadSettings).toHaveBeenCalled(); - expect(interaction.reply).toHaveBeenCalled(); - }); - - it('should handle /settings update', async () => { - const deps = createDeps(); - const interaction = createMockInteraction('settings', { - key: 'autoRestart', - value: 'true', - }); - - await handleSlashCommand(interaction, 'ch-test', deps); - - const replyArg = (interaction.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('autoRestart'); - expect(replyArg).toContain('true'); - }); - - it('should handle /restart when autoRestart disabled', async () => { - vi.mocked(loadSettings).mockReturnValue({ autoRestart: false }); - const deps = createDeps(); - const interaction = createMockInteraction('restart'); - - await handleSlashCommand(interaction, 'ch-test', deps); - - const replyArg = (interaction.reply as ReturnType).mock.calls[0][0] as string; - expect(replyArg).toContain('⚠️'); - expect(replyArg).toContain('無効'); - }); - - it('should handle /skills command', async () => { - const deps = createDeps(); - const interaction = createMockInteraction('skills'); - - await handleSlashCommand(interaction, 'ch-test', deps); - - expect(deps.reloadSkills).toHaveBeenCalled(); - expect(interaction.reply).toHaveBeenCalled(); - }); - - it('should handle /skill command', async () => { - const deps = createDeps(); - const interaction = createMockInteraction('skill', { name: 'deploy', args: '--prod' }); - - await handleSlashCommand(interaction, 'ch-test', deps); - - expect(executeSkillCommand).toHaveBeenCalledWith( - interaction, - deps.agentRunner, - 'ch-test', - 'deploy', - '--prod' - ); - }); - - it('should handle individual skill command', async () => { - const skills: Skill[] = [{ name: 'deploy', description: 'Deploy', prompt: '' }]; - const deps = createDeps({ skills }); - const interaction = createMockInteraction('deploy', { args: '' }); - - await handleSlashCommand(interaction, 'ch-test', deps); - - expect(executeSkillCommand).toHaveBeenCalledWith( - interaction, - deps.agentRunner, - 'ch-test', - 'deploy', - '' - ); + expect(replyArg).toContain('Status'); }); }); diff --git a/tests/system-commands.test.ts b/tests/system-commands.test.ts new file mode 100644 index 0000000..33ed2cc --- /dev/null +++ b/tests/system-commands.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock settings module +vi.mock('../src/lib/settings.js', () => ({ + loadSettings: vi.fn().mockReturnValue({ autoRestart: true }), +})); + +import { loadSettings } from '../src/lib/settings.js'; +import { handleSystemCommand } from '../src/lib/system-commands.js'; + +describe('handleSystemCommand', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + exitSpy.mockRestore(); + }); + + it('should do nothing when no SYSTEM_COMMAND found', () => { + handleSystemCommand('just regular text'); + vi.advanceTimersByTime(2000); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('should trigger restart when autoRestart is enabled', () => { + handleSystemCommand('some text\nSYSTEM_COMMAND:restart\nmore text'); + expect(exitSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1000); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('should not restart when autoRestart is disabled', () => { + (loadSettings as ReturnType).mockReturnValue({ autoRestart: false }); + handleSystemCommand('SYSTEM_COMMAND:restart'); + vi.advanceTimersByTime(2000); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('should ignore unknown commands', () => { + handleSystemCommand('SYSTEM_COMMAND:unknown_action'); + vi.advanceTimersByTime(2000); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/system-prompt.test.ts b/tests/system-prompt.test.ts new file mode 100644 index 0000000..20f7b76 --- /dev/null +++ b/tests/system-prompt.test.ts @@ -0,0 +1,75 @@ +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { loadSoulMd, loadUserMd } from '../src/agent/system-prompt.js'; + +describe('loadUserMd', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'thor-test-')); + }); + + it('returns empty string when workdir is undefined', () => { + expect(loadUserMd()).toBe(''); + }); + + it('returns empty string when USER.md does not exist', () => { + expect(loadUserMd(tempDir)).toBe(''); + }); + + it('returns formatted content when USER.md exists', () => { + const content = '# User Info\nName: Alice'; + writeFileSync(join(tempDir, 'USER.md'), content); + + const result = loadUserMd(tempDir); + expect(result).toBe(`\n\n## USER.md\n\n${content}`); + }); + + it('returns empty string on read failure', () => { + const userPath = join(tempDir, 'USER.md'); + writeFileSync(userPath, 'content'); + chmodSync(userPath, 0o000); + + const result = loadUserMd(tempDir); + expect(result).toBe(''); + + chmodSync(userPath, 0o644); + }); +}); + +describe('loadSoulMd', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'thor-test-')); + }); + + it('returns empty string when workdir is undefined', () => { + expect(loadSoulMd()).toBe(''); + }); + + it('returns empty string when SOUL.md does not exist', () => { + expect(loadSoulMd(tempDir)).toBe(''); + }); + + it('returns formatted content when SOUL.md exists', () => { + const content = '# My Soul\nBe helpful and kind.'; + writeFileSync(join(tempDir, 'SOUL.md'), content); + + const result = loadSoulMd(tempDir); + expect(result).toBe(`\n\n## SOUL.md\n\n${content}`); + }); + + it('returns empty string on read failure', () => { + const soulPath = join(tempDir, 'SOUL.md'); + writeFileSync(soulPath, 'content'); + chmodSync(soulPath, 0o000); + + const result = loadSoulMd(tempDir); + expect(result).toBe(''); + + chmodSync(soulPath, 0o644); + }); +}); diff --git a/tests/triggers.test.ts b/tests/triggers.test.ts new file mode 100644 index 0000000..f327570 --- /dev/null +++ b/tests/triggers.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Brain } from '../src/brain/brain.js'; +import { TriggerManager } from '../src/brain/triggers.js'; + +// Mock node-cron +vi.mock('node-cron', () => ({ + default: { + schedule: vi.fn().mockReturnValue({ + stop: vi.fn(), + }), + }, +})); + +import cron from 'node-cron'; + +function createMockBrain(): Brain { + return { + isBusy: vi.fn().mockReturnValue(false), + getIdleTime: vi.fn().mockReturnValue(600_000), + submit: vi.fn().mockResolvedValue({ result: 'morning greeting', sessionId: 'sess' }), + run: vi.fn(), + runStream: vi.fn(), + cancel: vi.fn(), + cancelAll: vi.fn(), + shutdown: vi.fn(), + getSessionId: vi.fn().mockReturnValue('sess'), + getStatus: vi.fn(), + } as unknown as Brain; +} + +describe('TriggerManager', () => { + let triggerManager: TriggerManager; + let brain: Brain; + + beforeEach(() => { + vi.clearAllMocks(); + brain = createMockBrain(); + triggerManager = new TriggerManager(brain, { + channelId: 'ch-trigger', + morningHour: 8, + eveningHour: 22, + weeklyDay: 0, + }); + }); + + afterEach(() => { + triggerManager.stop(); + }); + + it('should start and register 3 cron jobs', () => { + triggerManager.start(); + expect(cron.schedule).toHaveBeenCalledTimes(3); + }); + + it('should set up morning trigger at correct hour', () => { + triggerManager.start(); + const calls = (cron.schedule as ReturnType).mock.calls; + expect(calls[0][0]).toBe('0 8 * * *'); + }); + + it('should set up evening trigger at correct hour', () => { + triggerManager.start(); + const calls = (cron.schedule as ReturnType).mock.calls; + expect(calls[1][0]).toBe('0 22 * * *'); + }); + + it('should set up weekly trigger on correct day', () => { + triggerManager.start(); + const calls = (cron.schedule as ReturnType).mock.calls; + expect(calls[2][0]).toBe('5 22 * * 0'); + }); + + it('should stop all cron jobs', () => { + triggerManager.start(); + triggerManager.stop(); + + const mockTask = (cron.schedule as ReturnType).mock.results[0].value; + expect(mockTask.stop).toHaveBeenCalled(); + }); + + it('should accept a result handler', () => { + const handler = vi.fn(); + triggerManager.setResultHandler(handler); + // Handler is stored, no error + }); +});