test: Built Orchestrator class with 5 LLM tool definitions, tool-use ag…
- "packages/daemon/src/orchestrator.ts" - "packages/daemon/src/orchestrator.test.ts" - "packages/daemon/package.json" GSD-Task: S05/T01
This commit is contained in:
parent
6ef99ee727
commit
bbba5f83b9
4 changed files with 1047 additions and 2 deletions
22
package-lock.json
generated
22
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
583
packages/daemon/src/orchestrator.test.ts
Normal file
583
packages/daemon/src/orchestrator.test.ts
Normal file
|
|
@ -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<void> {
|
||||
// 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<CreateHandler> {
|
||||
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> = {}): 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<string> {
|
||||
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<void> {
|
||||
this.cancelSessionCalls.push(sessionId);
|
||||
}
|
||||
|
||||
getResult(sessionId: string): Record<string, unknown> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
440
packages/daemon/src/orchestrator.ts
Normal file
440
packages/daemon/src/orchestrator.ts
Normal file
|
|
@ -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<ProjectInfo[]>;
|
||||
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<Anthropic> {
|
||||
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<void> {
|
||||
// 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<string> {
|
||||
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<string, unknown>);
|
||||
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<string, unknown>): Promise<string> {
|
||||
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<string> {
|
||||
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<string, unknown>): Promise<string> {
|
||||
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<string, unknown>): Promise<string> {
|
||||
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, unknown>): 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<unknown> };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue