From bbba5f83b9a660c96d9c15b5eb560d7c87ca5de7 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 15:39:53 -0600 Subject: [PATCH] =?UTF-8?q?test:=20Built=20Orchestrator=20class=20with=205?= =?UTF-8?q?=20LLM=20tool=20definitions,=20tool-use=20ag=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "packages/daemon/src/orchestrator.ts" - "packages/daemon/src/orchestrator.test.ts" - "packages/daemon/package.json" GSD-Task: S05/T01 --- package-lock.json | 22 +- packages/daemon/package.json | 4 +- packages/daemon/src/orchestrator.test.ts | 583 +++++++++++++++++++++++ packages/daemon/src/orchestrator.ts | 440 +++++++++++++++++ 4 files changed, 1047 insertions(+), 2 deletions(-) create mode 100644 packages/daemon/src/orchestrator.test.ts create mode 100644 packages/daemon/src/orchestrator.ts diff --git a/package-lock.json b/package-lock.json index c68db802d..660307122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9428,9 +9428,11 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", "@gsd-build/rpc-client": "^2.52.0", "discord.js": "^14.25.1", - "yaml": "^2.8.0" + "yaml": "^2.8.0", + "zod": "^3.24.0" }, "bin": { "gsd-daemon": "dist/cli.js" @@ -9443,6 +9445,24 @@ "node": ">=22.0.0" } }, + "packages/daemon/node_modules/@anthropic-ai/sdk": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", + "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, + "packages/daemon/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/mcp-server": { "name": "@gsd-build/mcp-server", "version": "2.52.0", diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 548eed60e..74060981f 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -28,9 +28,11 @@ "test": "node --test dist/daemon.test.js" }, "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", "@gsd-build/rpc-client": "^2.52.0", "discord.js": "^14.25.1", - "yaml": "^2.8.0" + "yaml": "^2.8.0", + "zod": "^3.24.0" }, "devDependencies": { "@types/node": "^24.12.0", diff --git a/packages/daemon/src/orchestrator.test.ts b/packages/daemon/src/orchestrator.test.ts new file mode 100644 index 000000000..cd45904a5 --- /dev/null +++ b/packages/daemon/src/orchestrator.test.ts @@ -0,0 +1,583 @@ +/** + * Tests for Orchestrator — LLM agent for #gsd-control channel. + * + * Uses a MockAnthropicClient that simulates messages.create() responses, + * allowing tool execution and conversation flow testing without real API calls. + */ + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { Orchestrator, type OrchestratorConfig, type OrchestratorDeps, type DiscordMessageLike } from './orchestrator.js'; +import { Logger } from './logger.js'; +import type { ManagedSession, ProjectInfo, SessionStatus, CostAccumulator } from './types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tmpDir(): string { + return mkdtempSync(join(tmpdir(), `orch-test-${randomUUID().slice(0, 8)}-`)); +} + +const cleanupDirs: string[] = []; +const activeLoggers: Logger[] = []; + +async function cleanupAll(): Promise { + // Close all loggers first so write streams flush before dirs are removed + for (const logger of activeLoggers) { + try { await logger.close(); } catch { /* ignore */ } + } + activeLoggers.length = 0; + + while (cleanupDirs.length) { + const d = cleanupDirs.pop()!; + if (existsSync(d)) rmSync(d, { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// Mock Anthropic Client +// --------------------------------------------------------------------------- + +interface MockCreateParams { + model: string; + max_tokens: number; + system: string; + tools: unknown[]; + messages: unknown[]; +} + +type CreateHandler = (params: MockCreateParams) => { + stop_reason: string; + content: Array<{ type: string; text?: string; id?: string; name?: string; input?: unknown }>; +}; + +class MockAnthropicClient { + public createCallCount = 0; + public lastCreateParams: MockCreateParams | null = null; + private createHandler: CreateHandler; + + constructor(handler?: CreateHandler) { + this.createHandler = handler ?? MockAnthropicClient.defaultHandler; + } + + /** Default handler: returns a simple text response */ + static defaultHandler(): ReturnType { + return { + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'Mock LLM response' }], + }; + } + + /** Handler that simulates a tool call then end_turn */ + static toolThenTextHandler(toolName: string, toolInput: unknown, finalText: string): CreateHandler { + let callCount = 0; + return () => { + callCount++; + if (callCount === 1) { + return { + stop_reason: 'tool_use', + content: [ + { + type: 'tool_use', + id: `toolu_${randomUUID().slice(0, 8)}`, + name: toolName, + input: toolInput, + }, + ], + }; + } + return { + stop_reason: 'end_turn', + content: [{ type: 'text', text: finalText }], + }; + }; + } + + /** Handler that throws an error */ + static errorHandler(message: string): CreateHandler { + return () => { + throw new Error(message); + }; + } + + messages = { + create: async (params: MockCreateParams) => { + this.createCallCount++; + this.lastCreateParams = params; + return this.createHandler(params); + }, + }; +} + +// --------------------------------------------------------------------------- +// Mock SessionManager +// --------------------------------------------------------------------------- + +function makeMockSession(overrides: Partial = {}): ManagedSession { + return { + sessionId: overrides.sessionId ?? 'sess-123', + projectDir: overrides.projectDir ?? '/home/user/project', + projectName: overrides.projectName ?? 'my-project', + status: overrides.status ?? ('running' as SessionStatus), + client: {} as ManagedSession['client'], + events: [], + pendingBlocker: null, + cost: overrides.cost ?? { totalCost: 0.1234, tokens: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0 } }, + startTime: overrides.startTime ?? Date.now() - 300_000, // 5 min ago + ...overrides, + }; +} + +class MockSessionManager { + public sessions: ManagedSession[] = []; + public startSessionCalls: Array<{ projectDir: string; command?: string }> = []; + public cancelSessionCalls: string[] = []; + public getResultCalls: string[] = []; + + async startSession(opts: { projectDir: string; command?: string }): Promise { + this.startSessionCalls.push(opts); + return 'sess-new-123'; + } + + getSession(sessionId: string): ManagedSession | undefined { + return this.sessions.find((s) => s.sessionId === sessionId); + } + + getAllSessions(): ManagedSession[] { + return this.sessions; + } + + async cancelSession(sessionId: string): Promise { + this.cancelSessionCalls.push(sessionId); + } + + getResult(sessionId: string): Record { + const session = this.sessions.find((s) => s.sessionId === sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + return { + sessionId: session.sessionId, + projectDir: session.projectDir, + projectName: session.projectName, + status: session.status, + durationMs: 300_000, + cost: session.cost, + recentEvents: [], + pendingBlocker: null, + error: null, + }; + } +} + +// --------------------------------------------------------------------------- +// Mock ChannelManager (unused by orchestrator directly, but required by deps) +// --------------------------------------------------------------------------- + +class MockChannelManager {} + +// --------------------------------------------------------------------------- +// Mock Discord Message +// --------------------------------------------------------------------------- + +function makeMessage(overrides: Partial<{ + authorId: string; + bot: boolean; + channelId: string; + content: string; +}>): DiscordMessageLike & { sentMessages: string[] } { + const sentMessages: string[] = []; + return { + author: { + id: overrides.authorId ?? 'owner-123', + bot: overrides.bot ?? false, + }, + channelId: overrides.channelId ?? 'control-channel-1', + content: overrides.content ?? 'hello', + channel: { + send: async (content: string) => { + sentMessages.push(content); + }, + }, + sentMessages, + }; +} + +// --------------------------------------------------------------------------- +// Test Setup Factory +// --------------------------------------------------------------------------- + +function makeOrchestrator(opts?: { + client?: MockAnthropicClient; + sessions?: ManagedSession[]; + projects?: ProjectInfo[]; +}) { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'test.log'); + const logger = new Logger({ filePath: logPath, level: 'debug' }); + activeLoggers.push(logger); + + const sessionManager = new MockSessionManager(); + if (opts?.sessions) sessionManager.sessions = opts.sessions; + + const projects: ProjectInfo[] = opts?.projects ?? [ + { name: 'alpha', path: '/home/user/alpha', markers: ['git', 'node', 'gsd'], lastModified: Date.now() }, + { name: 'bravo', path: '/home/user/bravo', markers: ['git', 'rust'], lastModified: Date.now() }, + ]; + + const config: OrchestratorConfig = { + model: 'claude-sonnet-4-20250514', + max_tokens: 4096, + control_channel_id: 'control-channel-1', + }; + + const deps: OrchestratorDeps = { + sessionManager: sessionManager as unknown as OrchestratorDeps['sessionManager'], + channelManager: new MockChannelManager() as unknown as OrchestratorDeps['channelManager'], + scanProjects: async () => projects, + config, + logger, + ownerId: 'owner-123', + }; + + const mockClient = opts?.client ?? new MockAnthropicClient(); + const orchestrator = new Orchestrator(deps, mockClient as unknown as import('@anthropic-ai/sdk').default); + + return { orchestrator, mockClient, sessionManager, logger, logPath }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Orchestrator', () => { + // Clean up after each test so logger streams are flushed before dirs removed + afterEach(async () => { + await cleanupAll(); + }); + + // ---- Tool definitions ---- + + describe('tool definitions', () => { + it('passes 5 tools to the Anthropic API', async () => { + const { orchestrator, mockClient } = makeOrchestrator(); + const msg = makeMessage({ content: 'what can you do?' }); + await orchestrator.handleMessage(msg); + + assert.ok(mockClient.lastCreateParams); + const tools = mockClient.lastCreateParams.tools as Array<{ name: string }>; + assert.equal(tools.length, 5); + + const names = tools.map((t) => t.name).sort(); + assert.deepEqual(names, [ + 'get_session_detail', + 'get_status', + 'list_projects', + 'start_session', + 'stop_session', + ]); + }); + }); + + // ---- list_projects tool ---- + + describe('list_projects tool', () => { + it('returns project list from scanProjects', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler('list_projects', {}, 'Here are your projects'), + ); + const { orchestrator } = makeOrchestrator({ client: mockClient }); + const msg = makeMessage({ content: 'list my projects' }); + await orchestrator.handleMessage(msg); + + assert.equal(msg.sentMessages.length, 1); + assert.equal(msg.sentMessages[0], 'Here are your projects'); + // The tool was called (2 create calls: tool_use + end_turn) + assert.equal(mockClient.createCallCount, 2); + }); + }); + + // ---- start_session tool ---- + + describe('start_session tool', () => { + it('calls sessionManager.startSession and returns confirmation', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler( + 'start_session', + { projectPath: '/home/user/alpha' }, + 'Started session for alpha', + ), + ); + const { orchestrator, sessionManager } = makeOrchestrator({ client: mockClient }); + const msg = makeMessage({ content: 'start alpha' }); + await orchestrator.handleMessage(msg); + + assert.equal(sessionManager.startSessionCalls.length, 1); + assert.equal(sessionManager.startSessionCalls[0]!.projectDir, '/home/user/alpha'); + assert.equal(msg.sentMessages[0], 'Started session for alpha'); + }); + }); + + // ---- get_status tool ---- + + describe('get_status tool', () => { + it('returns formatted session status', async () => { + const session = makeMockSession({ projectName: 'alpha', status: 'running' as SessionStatus }); + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler('get_status', {}, 'Status: alpha is running'), + ); + const { orchestrator } = makeOrchestrator({ client: mockClient, sessions: [session] }); + const msg = makeMessage({ content: 'status' }); + await orchestrator.handleMessage(msg); + + assert.equal(msg.sentMessages[0], 'Status: alpha is running'); + }); + + it('handles empty session list', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler('get_status', {}, 'No sessions running'), + ); + const { orchestrator } = makeOrchestrator({ client: mockClient, sessions: [] }); + const msg = makeMessage({ content: 'status' }); + await orchestrator.handleMessage(msg); + + assert.equal(msg.sentMessages[0], 'No sessions running'); + }); + }); + + // ---- stop_session tool ---- + + describe('stop_session tool', () => { + it('stops session matched by sessionId', async () => { + const session = makeMockSession({ sessionId: 'sess-abc', projectName: 'alpha' }); + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler( + 'stop_session', + { identifier: 'sess-abc' }, + 'Stopped alpha', + ), + ); + const { orchestrator, sessionManager } = makeOrchestrator({ client: mockClient, sessions: [session] }); + const msg = makeMessage({ content: 'stop sess-abc' }); + await orchestrator.handleMessage(msg); + + assert.equal(sessionManager.cancelSessionCalls.length, 1); + assert.equal(sessionManager.cancelSessionCalls[0], 'sess-abc'); + }); + + it('fuzzy matches by project name', async () => { + const session = makeMockSession({ sessionId: 'sess-xyz', projectName: 'my-big-project' }); + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler( + 'stop_session', + { identifier: 'big-project' }, + 'Stopped my-big-project', + ), + ); + const { orchestrator, sessionManager } = makeOrchestrator({ client: mockClient, sessions: [session] }); + const msg = makeMessage({ content: 'stop big project' }); + await orchestrator.handleMessage(msg); + + assert.equal(sessionManager.cancelSessionCalls.length, 1); + assert.equal(sessionManager.cancelSessionCalls[0], 'sess-xyz'); + }); + + it('returns not-found for unmatched identifier', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler( + 'stop_session', + { identifier: 'nonexistent' }, + 'No session found', + ), + ); + const { orchestrator, sessionManager } = makeOrchestrator({ client: mockClient, sessions: [] }); + const msg = makeMessage({ content: 'stop nonexistent' }); + await orchestrator.handleMessage(msg); + + assert.equal(sessionManager.cancelSessionCalls.length, 0); + }); + }); + + // ---- get_session_detail tool ---- + + describe('get_session_detail tool', () => { + it('returns formatted session detail', async () => { + const session = makeMockSession({ sessionId: 'sess-detail' }); + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler( + 'get_session_detail', + { sessionId: 'sess-detail' }, + 'Session details for my-project', + ), + ); + const { orchestrator } = makeOrchestrator({ client: mockClient, sessions: [session] }); + const msg = makeMessage({ content: 'detail sess-detail' }); + await orchestrator.handleMessage(msg); + + assert.equal(msg.sentMessages[0], 'Session details for my-project'); + }); + }); + + // ---- Message routing / auth guards ---- + + describe('handleMessage routing', () => { + it('ignores bot messages', async () => { + const { orchestrator, mockClient } = makeOrchestrator(); + const msg = makeMessage({ bot: true, content: 'hello from bot' }); + await orchestrator.handleMessage(msg); + + assert.equal(mockClient.createCallCount, 0); + assert.equal(msg.sentMessages.length, 0); + }); + + it('ignores non-owner messages', async () => { + const { orchestrator, mockClient } = makeOrchestrator(); + const msg = makeMessage({ authorId: 'stranger-456', content: 'hack the planet' }); + await orchestrator.handleMessage(msg); + + assert.equal(mockClient.createCallCount, 0); + assert.equal(msg.sentMessages.length, 0); + }); + + it('ignores messages from non-control channels', async () => { + const { orchestrator, mockClient } = makeOrchestrator(); + const msg = makeMessage({ channelId: 'random-channel', content: 'hello' }); + await orchestrator.handleMessage(msg); + + assert.equal(mockClient.createCallCount, 0); + assert.equal(msg.sentMessages.length, 0); + }); + + it('ignores empty message content', async () => { + const { orchestrator, mockClient } = makeOrchestrator(); + const msg = makeMessage({ content: ' ' }); + await orchestrator.handleMessage(msg); + + assert.equal(mockClient.createCallCount, 0); + }); + + it('routes valid message through LLM and sends response', async () => { + const { orchestrator, mockClient } = makeOrchestrator(); + const msg = makeMessage({ content: 'hello orchestrator' }); + await orchestrator.handleMessage(msg); + + assert.equal(mockClient.createCallCount, 1); + assert.equal(msg.sentMessages.length, 1); + assert.equal(msg.sentMessages[0], 'Mock LLM response'); + }); + }); + + // ---- Conversation history ---- + + describe('conversation history', () => { + it('accumulates user and assistant entries', async () => { + const { orchestrator } = makeOrchestrator(); + + await orchestrator.handleMessage(makeMessage({ content: 'first' })); + await orchestrator.handleMessage(makeMessage({ content: 'second' })); + + const history = orchestrator.getHistory(); + assert.equal(history.length, 4); // 2 user + 2 assistant + assert.equal(history[0]!.role, 'user'); + assert.equal(history[1]!.role, 'assistant'); + assert.equal(history[2]!.role, 'user'); + assert.equal(history[3]!.role, 'assistant'); + }); + + it('trims to MAX_HISTORY (30) by removing oldest pairs', async () => { + const { orchestrator } = makeOrchestrator(); + + // Send 17 messages → 34 history entries (17 user + 17 assistant) + // After trimming: should be ≤30 + for (let i = 0; i < 17; i++) { + await orchestrator.handleMessage(makeMessage({ content: `msg-${i}` })); + } + + const history = orchestrator.getHistory(); + assert.ok(history.length <= 30, `History length ${history.length} exceeds 30`); + // Should have trimmed from the front — oldest entries gone + // 34 entries → trim 2 at a time until ≤30 → 30 entries (trimmed 4) + assert.equal(history.length, 30); + }); + }); + + // ---- Error handling ---- + + describe('error handling', () => { + it('sends error message to Discord when LLM API throws', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.errorHandler('API rate limit exceeded'), + ); + const { orchestrator } = makeOrchestrator({ client: mockClient }); + const msg = makeMessage({ content: 'hello' }); + await orchestrator.handleMessage(msg); + + assert.equal(msg.sentMessages.length, 1); + assert.ok(msg.sentMessages[0]!.includes('Something went wrong')); + }); + + it('appends error placeholder to history on LLM failure', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.errorHandler('Network error'), + ); + const { orchestrator } = makeOrchestrator({ client: mockClient }); + await orchestrator.handleMessage(makeMessage({ content: 'fail' })); + + const history = orchestrator.getHistory(); + assert.equal(history.length, 2); // user + error assistant + assert.equal(history[1]!.role, 'assistant'); + assert.equal(history[1]!.content, '[error — see logs]'); + }); + }); + + // ---- stop() ---- + + describe('stop()', () => { + it('clears conversation history and nulls client', async () => { + const { orchestrator } = makeOrchestrator(); + + await orchestrator.handleMessage(makeMessage({ content: 'hello' })); + assert.ok(orchestrator.getHistory().length > 0); + + orchestrator.stop(); + assert.equal(orchestrator.getHistory().length, 0); + }); + }); + + // ---- Tool execution direct tests ---- + + describe('tool execution (via agent loop)', () => { + it('list_projects returns empty message when no projects', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler('list_projects', {}, 'No projects'), + ); + const { orchestrator } = makeOrchestrator({ client: mockClient, projects: [] }); + const msg = makeMessage({ content: 'list' }); + await orchestrator.handleMessage(msg); + + // The second create call receives the tool result + assert.equal(mockClient.createCallCount, 2); + }); + + it('start_session with optional command passes through', async () => { + const mockClient = new MockAnthropicClient( + MockAnthropicClient.toolThenTextHandler( + 'start_session', + { projectPath: '/p', command: '/gsd quick fix tests' }, + 'Started', + ), + ); + const { orchestrator, sessionManager } = makeOrchestrator({ client: mockClient }); + const msg = makeMessage({ content: 'start with custom command' }); + await orchestrator.handleMessage(msg); + + assert.equal(sessionManager.startSessionCalls.length, 1); + assert.equal(sessionManager.startSessionCalls[0]!.command, '/gsd quick fix tests'); + }); + }); + +}); diff --git a/packages/daemon/src/orchestrator.ts b/packages/daemon/src/orchestrator.ts new file mode 100644 index 000000000..2722064be --- /dev/null +++ b/packages/daemon/src/orchestrator.ts @@ -0,0 +1,440 @@ +/** + * Orchestrator — LLM-powered agent for the #gsd-control Discord channel. + * + * Receives Discord messages, maintains conversation history, calls the + * Anthropic messages API with 5 tool definitions (list_projects, start_session, + * get_status, stop_session, get_session_detail), and sends the LLM's response + * back to Discord. + * + * Uses the standard messages.create() tool-use loop (not betaZodTool helpers, + * which don't exist in SDK v0.52). Zod schemas are used for input validation + * at the tool execution layer. + */ + +import { z } from 'zod'; +import type Anthropic from '@anthropic-ai/sdk'; +import type { + MessageParam, + ContentBlockParam, + Tool, + ToolResultBlockParam, + ToolUseBlock, + TextBlock, +} from '@anthropic-ai/sdk/resources/messages/messages'; +import type { SessionManager } from './session-manager.js'; +import type { ChannelManager } from './channel-manager.js'; +import type { ProjectInfo, ManagedSession } from './types.js'; +import type { Logger } from './logger.js'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +export interface OrchestratorConfig { + model: string; + max_tokens: number; + control_channel_id: string; +} + +export interface OrchestratorDeps { + sessionManager: SessionManager; + channelManager: ChannelManager; + scanProjects: () => Promise; + config: OrchestratorConfig; + logger: Logger; + ownerId: string; +} + +// --------------------------------------------------------------------------- +// System Prompt +// --------------------------------------------------------------------------- + +const SYSTEM_PROMPT = `You are GSD Control — a concise, capable orchestrator for managing GSD (Get Shit Done) coding agent sessions via Discord. + +You have tools to list projects, start sessions, get status, stop sessions, and inspect session details. Use them to fulfill the user's requests. + +Response guidelines: +- Be terse and direct. No filler, no performed enthusiasm. +- When reporting status, use bullet points with project name, status, duration, and cost. +- When starting a session, confirm with the project name and session ID. +- When stopping a session, confirm which session was stopped. +- If something fails, say what went wrong plainly. +- Use Discord markdown formatting (bold, code blocks) for readability. +- Never expose internal error stack traces to the user — summarize the issue.`; + +// --------------------------------------------------------------------------- +// Tool Definitions (Anthropic API format) +// --------------------------------------------------------------------------- + +const TOOLS: Tool[] = [ + { + name: 'list_projects', + description: 'List all detected projects across configured scan roots. Returns project names, paths, and detected markers (git, node, gsd, etc.).', + input_schema: { + type: 'object' as const, + properties: {}, + required: [], + }, + }, + { + name: 'start_session', + description: 'Start a new GSD auto-mode session for a project. Provide the absolute project path. Optionally provide a command to run instead of the default "/gsd auto".', + input_schema: { + type: 'object' as const, + properties: { + projectPath: { type: 'string', description: 'Absolute path to the project directory' }, + command: { type: 'string', description: 'Optional command to send instead of "/gsd auto"' }, + }, + required: ['projectPath'], + }, + }, + { + name: 'get_status', + description: 'Get the current status of all active GSD sessions. Shows project name, status, duration, and cost for each.', + input_schema: { + type: 'object' as const, + properties: {}, + required: [], + }, + }, + { + name: 'stop_session', + description: 'Stop a running GSD session. Provide a session ID or project name — fuzzy matching is used to find the session.', + input_schema: { + type: 'object' as const, + properties: { + identifier: { type: 'string', description: 'Session ID or project name to match' }, + }, + required: ['identifier'], + }, + }, + { + name: 'get_session_detail', + description: 'Get detailed information about a specific session including cost breakdown, recent events, pending blockers, and error state.', + input_schema: { + type: 'object' as const, + properties: { + sessionId: { type: 'string', description: 'The session ID to inspect' }, + }, + required: ['sessionId'], + }, + }, +]; + +// --------------------------------------------------------------------------- +// Zod schemas for tool input validation +// --------------------------------------------------------------------------- + +const StartSessionInput = z.object({ + projectPath: z.string(), + command: z.string().optional(), +}); + +const StopSessionInput = z.object({ + identifier: z.string(), +}); + +const GetSessionDetailInput = z.object({ + sessionId: z.string(), +}); + +// --------------------------------------------------------------------------- +// Conversation History Cap +// --------------------------------------------------------------------------- + +const MAX_HISTORY = 30; + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +export class Orchestrator { + private readonly deps: OrchestratorDeps; + private client: Anthropic | null; + private history: MessageParam[] = []; + + /** + * @param deps - orchestrator dependencies (session manager, channel manager, etc.) + * @param client - optional Anthropic client for testability; if omitted, created from env + */ + constructor(deps: OrchestratorDeps, client?: Anthropic) { + this.deps = deps; + this.client = client ?? null; + } + + /** + * Lazily initialise the Anthropic client. Dynamic import handles K007 module resolution. + */ + private async getClient(): Promise { + if (this.client) return this.client; + const { default: AnthropicSDK } = await import('@anthropic-ai/sdk'); + this.client = new AnthropicSDK(); + return this.client; + } + + /** + * Handle an incoming Discord message. Entry point called by the bot's + * message handler for every message in every channel. + * + * Guards: ignores bot messages, non-owner messages, and non-control-channel messages. + */ + async handleMessage(message: DiscordMessageLike): Promise { + // Ignore bot messages + if (message.author.bot) return; + + // Ignore non-control-channel messages + if (message.channelId !== this.deps.config.control_channel_id) return; + + // Auth guard — only the owner can use the orchestrator + if (message.author.id !== this.deps.ownerId) { + this.deps.logger.debug('orchestrator auth rejected', { userId: message.author.id }); + return; + } + + const content = message.content?.trim(); + if (!content) return; + + this.deps.logger.info('orchestrator message received', { + userId: message.author.id, + channelId: message.channelId, + contentLength: content.length, + }); + + // Append user message to history + this.history.push({ role: 'user', content }); + + try { + const responseText = await this.runAgentLoop(); + + // Send response to Discord + await message.channel.send(responseText); + + this.deps.logger.info('orchestrator response sent', { + channelId: message.channelId, + responseLength: responseText.length, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + this.deps.logger.error('orchestrator error', { + error: errorMsg, + userId: message.author.id, + channelId: message.channelId, + }); + + // Send error feedback to Discord + try { + await message.channel.send('⚠️ Something went wrong processing your request.'); + } catch (sendErr) { + this.deps.logger.warn('orchestrator error reply failed', { + error: sendErr instanceof Error ? sendErr.message : String(sendErr), + }); + } + + // Still append a synthetic assistant message so history stays paired + this.history.push({ role: 'assistant', content: '[error — see logs]' }); + } + + this.trimHistory(); + } + + /** + * Run the tool-use loop: call messages.create(), execute any tool calls, + * feed results back, repeat until the model produces a final text response. + */ + private async runAgentLoop(): Promise { + const client = await this.getClient(); + const { model, max_tokens } = this.deps.config; + + let loopMessages: MessageParam[] = [...this.history]; + const maxIterations = 10; // safety valve + + for (let i = 0; i < maxIterations; i++) { + const response = await client.messages.create({ + model, + max_tokens, + system: SYSTEM_PROMPT, + tools: TOOLS, + messages: loopMessages, + }); + + // If the model stopped for end_turn (no tool calls), extract text and return + if (response.stop_reason === 'end_turn' || response.stop_reason !== 'tool_use') { + const textBlocks = response.content.filter( + (b): b is TextBlock => b.type === 'text', + ); + const finalText = textBlocks.map((b) => b.text).join('\n') || '(No response)'; + + // Append assistant message to conversation history + this.history.push({ role: 'assistant', content: finalText }); + + return finalText; + } + + // Model wants to use tools — execute them all + const toolUseBlocks = response.content.filter( + (b): b is ToolUseBlock => b.type === 'tool_use', + ); + + // Build tool results + const toolResults: ToolResultBlockParam[] = []; + for (const toolUse of toolUseBlocks) { + const result = await this.executeTool(toolUse.name, toolUse.input as Record); + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: result, + }); + } + + // Append the assistant message (with tool_use blocks) and user tool_result message + loopMessages = [ + ...loopMessages, + { role: 'assistant', content: response.content as ContentBlockParam[] }, + { role: 'user', content: toolResults }, + ]; + } + + // If we hit max iterations, return a fallback + return 'I hit the maximum number of tool iterations. Please try a simpler request.'; + } + + /** + * Execute a single tool by name. Returns a string result for the LLM. + * All errors are caught and returned as error strings (the LLM can reason about them). + */ + private async executeTool(name: string, input: Record): Promise { + try { + switch (name) { + case 'list_projects': + return await this.toolListProjects(); + case 'start_session': + return await this.toolStartSession(input); + case 'get_status': + return this.toolGetStatus(); + case 'get_session_detail': + return this.toolGetSessionDetail(input); + case 'stop_session': + return await this.toolStopSession(input); + default: + return `Unknown tool: ${name}`; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.deps.logger.error('tool execution error', { tool: name, error: msg }); + return `Error: ${msg}`; + } + } + + // --------------------------------------------------------------------------- + // Tool implementations + // --------------------------------------------------------------------------- + + private async toolListProjects(): Promise { + const projects = await this.deps.scanProjects(); + if (projects.length === 0) return 'No projects found.'; + return JSON.stringify( + projects.map((p) => ({ name: p.name, path: p.path, markers: p.markers })), + null, + 2, + ); + } + + private async toolStartSession(input: Record): Promise { + const parsed = StartSessionInput.parse(input); + const sessionId = await this.deps.sessionManager.startSession({ + projectDir: parsed.projectPath, + command: parsed.command, + }); + return `Session started: ${sessionId} for ${parsed.projectPath}`; + } + + private toolGetStatus(): string { + const sessions = this.deps.sessionManager.getAllSessions(); + if (sessions.length === 0) return 'No active sessions.'; + + return sessions + .map((s: ManagedSession) => { + const durationMin = Math.floor((Date.now() - s.startTime) / 60_000); + const cost = s.cost.totalCost.toFixed(4); + return `• ${s.projectName} — ${s.status} (${durationMin}m, $${cost})`; + }) + .join('\n'); + } + + private async toolStopSession(input: Record): Promise { + const parsed = StopSessionInput.parse(input); + const { identifier } = parsed; + + // Try exact sessionId match first + const byId = this.deps.sessionManager.getSession(identifier); + if (byId) { + await this.deps.sessionManager.cancelSession(identifier); + return `Stopped session ${identifier} (${byId.projectName})`; + } + + // Fuzzy match by project name + const all = this.deps.sessionManager.getAllSessions(); + const match = all.find( + (s: ManagedSession) => + s.projectName.toLowerCase().includes(identifier.toLowerCase()) || + s.projectDir.toLowerCase().includes(identifier.toLowerCase()), + ); + if (match) { + await this.deps.sessionManager.cancelSession(match.sessionId); + return `Stopped session ${match.sessionId} (${match.projectName})`; + } + + return `No session found matching "${identifier}"`; + } + + private toolGetSessionDetail(input: Record): string { + const parsed = GetSessionDetailInput.parse(input); + const result = this.deps.sessionManager.getResult(parsed.sessionId); + return JSON.stringify(result, null, 2); + } + + // --------------------------------------------------------------------------- + // History management + // --------------------------------------------------------------------------- + + /** + * Trim conversation history to MAX_HISTORY entries. + * Removes the oldest user+assistant pair from the front to keep pairs aligned. + */ + private trimHistory(): void { + while (this.history.length > MAX_HISTORY) { + // Remove from front — two messages at a time to keep user/assistant pairs + this.history.splice(0, 2); + } + } + + /** + * Return a copy of the conversation history (for debugging / observability). + */ + getHistory(): MessageParam[] { + return [...this.history]; + } + + /** + * Stop the orchestrator — clears history and nulls client reference. + */ + stop(): void { + this.history = []; + this.client = null; + } +} + +// --------------------------------------------------------------------------- +// Discord message type (minimal interface for testability) +// --------------------------------------------------------------------------- + +/** + * Minimal Discord message interface — avoids importing discord.js directly, + * making the orchestrator testable without full discord.js mocking. + */ +export interface DiscordMessageLike { + author: { id: string; bot: boolean }; + channelId: string; + content: string; + channel: { send: (content: string) => Promise }; +}