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
+3 -1
View File
@@ -2,7 +2,9 @@
# API # API
PORT=3000 PORT=3000
DATABASE_URL=postgres://wursor:wursor@localhost:5432/wursor # Set DATABASE_URL only when Postgres is running (see infrastructure/DEVOPS.md).
# Leave empty for the in-memory dev store.
DATABASE_URL=
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
# Auth # Auth
+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 Fastify from 'fastify';
import { authRoutes } from './routes/auth.ts'; import { authRoutes } from './routes/auth.ts';
import { chatRoutes } from './routes/chat.ts';
import { sessionRoutes } from './routes/sessions.ts'; import { sessionRoutes } from './routes/sessions.ts';
import { siteRoutes } from './routes/sites.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 { PairingService } from './services/pairing-service.ts';
import { InMemorySessionStore, type SessionStore } from './services/session-store.ts'; import { InMemorySessionStore, type SessionStore } from './services/session-store.ts';
import { InMemorySiteStore, type SiteStore } from './services/site-store.ts'; import { InMemorySiteStore, type SiteStore } from './services/site-store.ts';
@@ -14,6 +17,8 @@ export type BuildAppOptions = {
siteStore?: SiteStore; siteStore?: SiteStore;
pairingService?: PairingService; pairingService?: PairingService;
sandboxManager?: SandboxManager; sandboxManager?: SandboxManager;
llmClient?: LlmClient;
toolExecutor?: ToolExecutor;
}; };
export async function buildApp(opts: BuildAppOptions = {}) { export async function buildApp(opts: BuildAppOptions = {}) {
@@ -27,5 +32,6 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await authRoutes(app, userStore, sessionStore); await authRoutes(app, userStore, sessionStore);
await sessionRoutes(app, opts.sandboxManager); await sessionRoutes(app, opts.sandboxManager);
await siteRoutes(app, { sessionStore, siteStore, pairingService }); await siteRoutes(app, { sessionStore, siteStore, pairingService });
await chatRoutes(app, { sessionStore, llmClient: opts.llmClient, toolExecutor: opts.toolExecutor });
return app; return app;
} }
+25 -1
View File
@@ -1,12 +1,23 @@
import Docker from 'dockerode'; 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 { Pool } from 'pg';
import { buildApp } from './app.ts'; 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 { DockerodeClient } from './sandbox/dockerode-client.ts';
import { ImageManager } from './sandbox/image-manager.ts'; import { ImageManager } from './sandbox/image-manager.ts';
import { PostgresUserStore } from './services/postgres-user-store.ts'; import { PostgresUserStore } from './services/postgres-user-store.ts';
import { SandboxManager } from './services/sandbox-manager.ts'; import { SandboxManager } from './services/sandbox-manager.ts';
import { InMemoryUserStore } from './services/user-store.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 port = Number(process.env.PORT ?? 3000);
const userStore = process.env.DATABASE_URL const userStore = process.env.DATABASE_URL
@@ -26,6 +37,19 @@ const sandboxManager =
}) })
: undefined; : 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' }); 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 });
});
}
+21 -12
View File
@@ -8,6 +8,12 @@ function extractHeading(text: string): string | undefined {
return match?.[1]; return match?.[1];
} }
function mockReply(text: string, heading: string | undefined): string {
return heading !== undefined
? `Done — I updated the homepage heading to “${heading}”. Preview it below.`
: 'Done — your change is ready to preview.';
}
export function useChat() { export function useChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>('idle'); const [status, setStatus] = useState<ChatStatus>('idle');
@@ -17,18 +23,21 @@ export function useChat() {
const nextHeading = extractHeading(text); const nextHeading = extractHeading(text);
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]); setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]);
setStatus('working'); setStatus('working');
await new Promise((resolve) => setTimeout(resolve, 1200));
setMessages((prev) => [ try {
...prev, const res = await fetch('/chat', {
{ method: 'POST',
id: crypto.randomUUID(), headers: { 'Content-Type': 'application/json' },
role: 'agent', body: JSON.stringify({ message: text }),
text: });
nextHeading !== undefined if (!res.ok) throw new Error('chat unavailable');
? `Done — I updated the homepage heading to “${nextHeading}”. Preview it below.` const body = (await res.json()) as { reply?: string };
: 'Done — your change is ready to preview.', setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: body.reply ?? 'Done' }]);
}, } catch {
]); await new Promise((resolve) => setTimeout(resolve, 1200));
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: mockReply(text, nextHeading) }]);
}
if (nextHeading !== undefined) { if (nextHeading !== undefined) {
setHeading(nextHeading); setHeading(nextHeading);
} }
+3
View File
@@ -6,6 +6,9 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/auth': 'http://localhost:3000', '/auth': 'http://localhost:3000',
'/chat': 'http://localhost:3000',
'/sites': 'http://localhost:3000',
'/health': 'http://localhost:3000',
}, },
}, },
test: { test: {