feat: wire chat to the agent loop (POST /chat)
Mirror to GitHub / mirror (push) Canceled after 0s

- api: /chat route runs runAgent (session-required, 503 when unconfigured)
- api: index.ts builds OpenRouterLlmClient + WpRestExecutor from env; loads .env
- web: useChat calls /chat with mock fallback; proxy /chat+/sites+/health
- .env.example: DATABASE_URL empty by default (in-memory dev)
- 111 unit tests green; smoke-tested signup+chat
This commit is contained in:
SinachPat
2026-08-17 17:19:33 +01:00
parent 69287ebec0
commit 060b181e71
8 changed files with 178 additions and 14 deletions
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../../src/app.ts';
import type { LlmClient, LlmResponse } from '../../src/agents/llm-client.ts';
import type { ToolExecutor } from '../../src/agents/tool-executor.ts';
class FakeLlm implements LlmClient {
async complete(): Promise<LlmResponse> {
return { choices: [{ message: { role: 'assistant', content: 'Done', tool_calls: undefined } }] };
}
}
class FakeExecutor implements ToolExecutor {
async execute() {
return { result: 'ok' };
}
}
async function signup(app: FastifyInstance): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: `a-${Date.now()}@example.com`, password: 'password123' },
});
return res.json().sessionToken as string;
}
describe('POST /chat', () => {
it('requires a session', async () => {
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
const res = await app.inject({ method: 'POST', url: '/chat', payload: { message: 'hi' } });
expect(res.statusCode).toBe(401);
});
it('runs the agent and returns the reply', async () => {
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
const token = await signup(app);
const res = await app.inject({
method: 'POST',
url: '/chat',
headers: { authorization: `Bearer ${token}` },
payload: { message: 'Change the heading' },
});
expect(res.statusCode).toBe(200);
expect(res.json().reply).toBe('Done');
});
it('returns 503 when the agent is not configured', async () => {
const app = await buildApp();
const token = await signup(app);
const res = await app.inject({
method: 'POST',
url: '/chat',
headers: { authorization: `Bearer ${token}` },
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(503);
});
it('returns 400 for an empty message', async () => {
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
const token = await signup(app);
const res = await app.inject({
method: 'POST',
url: '/chat',
headers: { authorization: `Bearer ${token}` },
payload: { message: ' ' },
});
expect(res.statusCode).toBe(400);
});
});
+5
View File
@@ -0,0 +1,5 @@
export const AGENT_SYSTEM_PROMPT =
'You are an expert WordPress agent working inside a sandboxed copy of the user\u2019s site. ' +
'Use the provided tools to complete the request. Work step by step: read what you need, make the ' +
'changes, and verify. Never touch anything outside the sandbox. When finished, reply with a one-line ' +
'summary of what you changed.';
+6
View File
@@ -1,7 +1,10 @@
import Fastify from 'fastify';
import { authRoutes } from './routes/auth.ts';
import { chatRoutes } from './routes/chat.ts';
import { sessionRoutes } from './routes/sessions.ts';
import { siteRoutes } from './routes/sites.ts';
import type { LlmClient } from './agents/llm-client.ts';
import type { ToolExecutor } from './agents/tool-executor.ts';
import { PairingService } from './services/pairing-service.ts';
import { InMemorySessionStore, type SessionStore } from './services/session-store.ts';
import { InMemorySiteStore, type SiteStore } from './services/site-store.ts';
@@ -14,6 +17,8 @@ export type BuildAppOptions = {
siteStore?: SiteStore;
pairingService?: PairingService;
sandboxManager?: SandboxManager;
llmClient?: LlmClient;
toolExecutor?: ToolExecutor;
};
export async function buildApp(opts: BuildAppOptions = {}) {
@@ -27,5 +32,6 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await authRoutes(app, userStore, sessionStore);
await sessionRoutes(app, opts.sandboxManager);
await siteRoutes(app, { sessionStore, siteStore, pairingService });
await chatRoutes(app, { sessionStore, llmClient: opts.llmClient, toolExecutor: opts.toolExecutor });
return app;
}
+25 -1
View File
@@ -1,12 +1,23 @@
import Docker from 'dockerode';
import { dirname, join } from 'node:path';
import { loadEnvFile } from 'node:process';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import { buildApp } from './app.ts';
import { OpenRouterLlmClient } from './agents/openrouter-client.ts';
import { WpRestExecutor } from './agents/wp-executor.ts';
import { DockerodeClient } from './sandbox/dockerode-client.ts';
import { ImageManager } from './sandbox/image-manager.ts';
import { PostgresUserStore } from './services/postgres-user-store.ts';
import { SandboxManager } from './services/sandbox-manager.ts';
import { InMemoryUserStore } from './services/user-store.ts';
try {
loadEnvFile(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env'));
} catch {
// no .env — rely on the ambient environment
}
const port = Number(process.env.PORT ?? 3000);
const userStore = process.env.DATABASE_URL
@@ -26,6 +37,19 @@ const sandboxManager =
})
: undefined;
const app = await buildApp({ userStore, sandboxManager });
const llmClient = process.env.OPENROUTER_API_KEY
? new OpenRouterLlmClient({ apiKey: process.env.OPENROUTER_API_KEY, model: process.env.OPENROUTER_MODEL })
: undefined;
const toolExecutor =
process.env.WP_URL !== undefined && process.env.WP_USER !== undefined && process.env.WP_APP_PASSWORD !== undefined
? new WpRestExecutor({
baseUrl: process.env.WP_URL,
username: process.env.WP_USER,
appPassword: process.env.WP_APP_PASSWORD,
})
: undefined;
const app = await buildApp({ userStore, sandboxManager, llmClient, toolExecutor });
await app.listen({ port, host: '0.0.0.0' });
+38
View File
@@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify';
import { runAgent } from '../agents/agent-loop.ts';
import { AGENT_SYSTEM_PROMPT } from '../agents/system-prompt.ts';
import type { LlmClient } from '../agents/llm-client.ts';
import type { ToolExecutor } from '../agents/tool-executor.ts';
import { requireSession } from '../middleware/auth.ts';
import type { SessionStore } from '../services/session-store.ts';
export type ChatRoutesDeps = {
sessionStore: SessionStore;
llmClient?: LlmClient;
toolExecutor?: ToolExecutor;
maxRounds?: number;
};
type ChatBody = {
message?: string;
};
export async function chatRoutes(app: FastifyInstance, deps: ChatRoutesDeps): Promise<void> {
app.post('/chat', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
if (deps.llmClient === undefined || deps.toolExecutor === undefined) {
return reply.status(503).send({ error: 'agent_not_configured' });
}
const { message } = request.body as ChatBody;
if (message === undefined || message.trim() === '') {
return reply.status(400).send({ error: 'invalid_message' });
}
const result = await runAgent(deps.llmClient, deps.toolExecutor, {
systemPrompt: AGENT_SYSTEM_PROMPT,
userPrompt: message,
maxRounds: deps.maxRounds ?? 12,
});
return reply.send({ reply: result.finalContent, toolCalls: result.toolCalls, halted: result.halted });
});
}