feat: add POST /api/v1/chat/async endpoint for fire-and-forget messages (#329)

This commit is contained in:
Ezra
2026-03-04 11:45:09 -08:00
committed by GitHub
parent 587621d9e4
commit a9cab72426
2 changed files with 83 additions and 1 deletions

View File

@@ -6,7 +6,7 @@
import * as http from 'http';
import * as fs from 'fs';
import { validateApiKey } from './auth.js';
import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse, PairingListResponse, PairingApproveRequest, PairingApproveResponse } from './types.js';
import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse, AsyncChatResponse, PairingListResponse, PairingApproveRequest, PairingApproveResponse } from './types.js';
import { listPairingRequests, approvePairingCode } from '../pairing/store.js';
import { parseMultipart } from './multipart.js';
import type { AgentRouter } from '../core/interfaces.js';
@@ -226,6 +226,77 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
return;
}
// Route: POST /api/v1/chat/async (fire-and-forget: returns 202, processes in background)
if (req.url === '/api/v1/chat/async' && req.method === 'POST') {
try {
if (!validateApiKey(req.headers, options.apiKey)) {
sendError(res, 401, 'Unauthorized');
return;
}
const contentType = req.headers['content-type'] || '';
if (!contentType.includes('application/json')) {
sendError(res, 400, 'Content-Type must be application/json');
return;
}
const body = await readBody(req, MAX_BODY_SIZE);
let chatReq: ChatRequest;
try {
chatReq = JSON.parse(body);
} catch {
sendError(res, 400, 'Invalid JSON body');
return;
}
if (!chatReq.message || typeof chatReq.message !== 'string') {
sendError(res, 400, 'Missing required field: message');
return;
}
if (chatReq.message.length > MAX_TEXT_LENGTH) {
sendError(res, 400, `Message too long (max ${MAX_TEXT_LENGTH} chars)`);
return;
}
const agentName = chatReq.agent;
const agentNames = deliverer.getAgentNames();
const resolvedName = agentName || agentNames[0];
if (agentName && !agentNames.includes(agentName)) {
sendError(res, 404, `Agent not found: ${agentName}. Available: ${agentNames.join(', ')}`);
return;
}
console.log(`[API] Async chat request for agent "${resolvedName}": ${chatReq.message.slice(0, 100)}...`);
// Return 202 immediately
const asyncRes: AsyncChatResponse = {
success: true,
status: 'queued',
agentName: resolvedName,
};
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(asyncRes));
// Process in background (detached promise)
const context = { type: 'webhook' as const, outputMode: 'silent' as const };
deliverer.sendToAgent(agentName, chatReq.message, context).catch((error: any) => {
console.error(`[API] Async chat background error for agent "${resolvedName}":`, error);
});
} catch (error: any) {
console.error('[API] Async chat error:', error);
const asyncRes: AsyncChatResponse = {
success: false,
status: 'error',
error: error.message || 'Internal server error',
};
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(asyncRes));
}
return;
}
// Route: GET /api/v1/pairing/:channel - List pending pairing requests
const pairingListMatch = req.url?.match(/^\/api\/v1\/pairing\/([a-z0-9-]+)$/);
if (pairingListMatch && req.method === 'GET') {

View File

@@ -49,6 +49,17 @@ export interface ChatResponse {
error?: string;
}
/**
* POST /api/v1/chat/async - Fire-and-forget message to the agent
* Returns 202 immediately; agent processes in background.
*/
export interface AsyncChatResponse {
success: boolean;
status: 'queued' | 'error';
agentName?: string;
error?: string;
}
/**
* GET /api/v1/pairing/:channel - List pending pairing requests
*/