Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.workflow_run.head_sha }}
# The release fence verifies ancestry and artifacts at prior commits.
fetch-depth: 0

- name: Recheck tested SHA is still current main
env:
Expand Down
118 changes: 99 additions & 19 deletions server/public/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -5242,10 +5242,87 @@ <h2>Ask Addie</h2>
});

panel.append(title, detail, retryButton);
appendChatRequestReference(panel, options.requestId);
contentDiv.appendChild(panel);
appendCertificationStatus(contentDiv, options.certification);
}

function validChatRequestId(value) {
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
? value.toLowerCase() : null;
}

function appendChatRequestReference(container, requestId) {
const id = validChatRequestId(requestId);
if (!id) return;
const reference = document.createElement('div');
reference.className = 'chat-request-reference';
reference.textContent = `Support reference: ${id}`;
container.appendChild(reference);
}

function chatRequestError(payload, status, requestId, streaming = false) {
const data = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
const nested = data.error && typeof data.error === 'object' && !Array.isArray(data.error) ? data.error : {};
const code = typeof data.code === 'string' ? data.code
: typeof data.error === 'string' ? data.error
: typeof nested.code === 'string' ? nested.code : '';
const publicErrors = new Map([
['Invalid session', 'Your session has expired. Sign in again, then retry your message.'],
['An unambiguous organization selection is required', 'Your organization selection could not be verified. Choose your organization again or sign in again, then retry your message.'],
['CSRF validation failed', 'Your security token could not be verified. Refresh this page, then retry your message.'],
['Authentication service temporarily unavailable', 'Sign-in is temporarily unavailable. Please try again shortly.'],
['Authorization service temporarily unavailable', 'Your access could not be verified right now. Please try again shortly.'],
['Conversation not found', 'This conversation is unavailable for your current session. Sign in again or start a new chat.'],
['checkpoint_persistence_failed', 'An action completed, but its result could not be safely saved. Review the action before trying again.'],
]);
let message = publicErrors.get(code) || publicErrors.get(data.reason);
// `message` is the API's public prose field. Never show arbitrary error
// objects, provider errors/reasons, HTML responses or 5xx diagnostics.
const suppliedMessage = typeof data.message === 'string' ? data.message : nested.message;
if (!message && status >= 400 && status < 500 && typeof suppliedMessage === 'string'
&& suppliedMessage.trim().length > 0 && suppliedMessage.length <= 400
&& !/[<>\r\n\u0000-\u001f]/.test(suppliedMessage)
&& !/\b(?:stack|exception|postgres|bearer)\b|https?:\/\/|\bat \w+\(/i.test(suppliedMessage)) {
message = suppliedMessage.trim();
}
if (!message) {
message = status === 401 ? 'Sign in again, then retry your message.'
: status === 403 ? 'This request is not allowed for your current session. Check your account and organization, then try again.'
: status === 429 ? 'You have reached the message limit. Please try again later.'
: streaming ? 'The reply was interrupted before completion.'
: 'Addie could not complete this request. Please try again.';
}
const error = new Error(message);
if (status) error.httpStatus = status;
error.serverCode = /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : '';
error.requestId = validChatRequestId(data.request_id) || validChatRequestId(requestId);
if (typeof data.recoverable === 'boolean') error.recoverable = data.recoverable;
if (code === 'turn_in_progress') error.recoverable = true;
error.certification = data.certification || null;
return error;
}

function showChatRequestError(error) {
// Keep the support reference available after the transient toast would
// have disappeared. Server text is always rendered as text, never HTML.
const messageDiv = addMessage('', 'assistant');
const panel = document.createElement('div');
panel.className = 'chat-request-error';
panel.setAttribute('role', 'alert');
const detail = document.createElement('div');
detail.textContent = error.message;
panel.appendChild(detail);
if (error.httpStatus === 401) {
const link = document.createElement('a');
link.href = '/auth/login?return_to=/chat';
link.textContent = 'Sign in again';
panel.appendChild(link);
}
appendChatRequestReference(panel, error.requestId);
messageDiv.querySelector('.message-content').appendChild(panel);
}

// Send message with streaming
async function sendMessage(sendOptions = {}) {
const retrying = sendOptions.retry === true;
Expand Down Expand Up @@ -5294,39 +5371,38 @@ <h2>Ask Addie</h2>
let pendingSiSession = null;
let pendingSiAgents = null;
let pendingModelInfo = null;
// A transport attempt gets its own correlation ID; clientRequestId
// remains the stable logical turn ID used by safe continuation.
let requestId = crypto.randomUUID();

try {
const selectedOrganizationId = getSelectedOrganizationId();
const response = await authFetch('/api/addie/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-ID': requestId,
},
body: JSON.stringify({
message,
conversation_id: conversationId,
message_source: messageSource,
model_preference: selectedModel,
organization_id: getSelectedOrganizationId(),
...(typeof selectedOrganizationId === 'string' && selectedOrganizationId.trim()
? { organization_id: selectedOrganizationId.trim() } : {}),
attachments: buildAttachmentPayload(attachmentsToSend),
client_request_id: clientRequestId,
retry: retrying,
}),
});
requestId = validChatRequestId(response.headers.get('X-Request-ID')) || requestId;

if (!response.ok) {
// Surface server error messages (e.g., rate limit "daily limit reached")
let errorMsg = 'Failed to send message';
let errorCode = '';
let errorData = null;
try {
const errorData = await response.json();
if (errorData.message) errorMsg = errorData.message;
if (errorData.error) errorCode = errorData.error;
errorData = await response.json();
} catch (e) { /* ignore parse errors */ }
const httpError = new Error(errorMsg);
httpError.httpStatus = response.status;
httpError.serverCode = errorCode;
httpError.recoverable = errorCode === 'turn_in_progress';
throw httpError;
throw chatRequestError(errorData, response.status, requestId);
}

// Show remaining message count for anonymous users (when running low)
Expand Down Expand Up @@ -5406,13 +5482,12 @@ <h2>Ask Addie</h2>
pendingSiAgents = data.si_agents;
}
} else if (currentEventType === 'stream_error') {
streamFailure = data.reason || 'Reply interrupted';
const streamError = new Error(streamFailure);
streamError.recoverable = data.recoverable === true;
streamError.certification = data.certification || null;
const streamError = chatRequestError(data, 0, requestId, true);
streamFailure = streamError.message;
streamError.recoverable = streamError.recoverable === true;
throw streamError;
} else if (currentEventType === 'error') {
throw new Error(data.error || 'Unknown error');
throw chatRequestError(data, 0, requestId, true);
}
// tool_start and tool_end events are informational, could show status
currentEventType = '';
Expand Down Expand Up @@ -5503,9 +5578,10 @@ <h2>Ask Addie</h2>
} catch (error) {
// Network EOFs do not carry an SSE error event, but once the server
// assigned a conversation the same client request can be continued.
if (!error.recoverable && !error.httpStatus && conversationId && clientRequestId) {
if (typeof error.recoverable !== 'boolean' && !error.httpStatus && conversationId && clientRequestId) {
error.recoverable = true;
}
error.requestId = validChatRequestId(error.requestId) || requestId;
if (error.recoverable) {
console.warn('Addie reply interrupted; offering a safe continuation:', error.message);
} else {
Expand All @@ -5522,9 +5598,13 @@ <h2>Ask Addie</h2>
clientRequestId,
modelPreference: selectedModel,
certification: error.certification,
requestId: error.requestId,
});
} else {
showError(error.message || 'Failed to send message. Please try again.');
// Network/parser exceptions may contain browser or transport internals.
const visibleError = error.serverCode !== undefined ? error
: chatRequestError(null, 0, error.requestId);
showChatRequestError(visibleError);
}

// Clear loading state on error
Expand Down
2 changes: 1 addition & 1 deletion server/src/addie/config-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { loadRules, loadResponseStyle } from './rules/index.js';
* Format: YYYY.MM.N where N is incremented for multiple changes in a month
* Example: 2025.01.1, 2025.01.2, 2025.02.1
*/
export const CODE_VERSION = '2026.09.67';
export const CODE_VERSION = '2026.09.68';

// Types
export interface ConfigVersion {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"server/src/addie/prompt-assembly.ts": "6c002ae5a52cdd26e13f424f6e00757dff163b3b273a1b4ab069fdb86f4f375f",
"server/src/addie/register-baseline-tools.ts": "52893917fa19fa8904e1667857a7c4bfc2dc90bd3341e60d1ff45a40400d17aa",
"server/src/addie/bolt-app.ts": "60b068f6f2e417d14ff9be5f263b5cc0a971199e83614acae1843c186ef53f22",
"server/src/routes/addie-chat.ts": "0d2936a0ac58455dfa8e9528e291dbb0bab1f3389e2c5ad99d7ccc4143135303",
"server/src/routes/addie-chat.ts": "c3c949c3e9ff1d8ff88f95acd0e8021bda0f1e1350fdc149c502e06807f1f7fa",
"server/src/routes/tavus.ts": "2026b89e14588371f6efd9d756c2fb161d6fdeb1c6937ef9cdb1652022711ae6",
"server/src/addie/email-conversation-handler.ts": "d4f5090d6f71937fff4ff8774ff516ca814be4bd7b23566b366fa037dcc642f3",
"server/src/mcp/chat-tool.ts": "a55a9412eb57ce2560b3cdbf2a63c1c1e9fb71361ff1690fc70d5b37b8158258",
Expand Down
4 changes: 4 additions & 0 deletions server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import cookieParser from "cookie-parser";
import DOMPurify from "isomorphic-dompurify";
import { Marked } from "marked";
import { csrfProtection } from "./middleware/csrf.js";
import { chatRequestCorrelation } from "./middleware/chat-request-correlation.js";
import { slowResponseTracker } from "./middleware/slow-response.js";
import { requestMetrics } from "./middleware/request-metrics.js";
import escapeHtml from "escape-html";
Expand Down Expand Up @@ -1336,6 +1337,9 @@ export class HTTPServer {
// Capture request duration metrics for all API calls
this.app.use(requestMetrics);

// Include parser, CSRF and authentication rejections in chat support traces.
this.app.use('/api/addie/chat', chatRequestCorrelation);

// Use JSON parser for all routes EXCEPT those that need raw body for signature verification
// Limit increased to 10MB to support base64-encoded logo uploads in member profiles
this.app.use((req, res, next) => {
Expand Down
69 changes: 69 additions & 0 deletions server/src/middleware/chat-request-correlation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { randomUUID } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import { validate as validateUuid } from 'uuid';
import { createLogger } from '../logger.js';

const logger = createLogger('addie-chat-request');
const errorCodes = new Map([
['Invalid session', 'invalid_session'],
['An unambiguous organization selection is required', 'organization_selection_conflict'],
['CSRF validation failed', 'csrf_validation_failed'],
['Authentication service temporarily unavailable', 'authentication_unavailable'],
['Authorization service temporarily unavailable', 'authorization_unavailable'],
['Conversation not found', 'conversation_not_found'],
['turn_in_progress', 'turn_in_progress'],
['Malformed request body', 'malformed_request'],
]);

/** Transport correlation only; never use this caller-supplied ID as authority or idempotency. */
export function chatRequestCorrelation(req: Request, res: Response, next: NextFunction): void {
// Also installed on the router for standalone hosts. The HTTP entry point
// installs it before parsing/CSRF/auth so those rejections are covered too.
if (res.locals.chatRequestId) return next();
const supplied = req.headers['x-request-id'];
const requestId = typeof supplied === 'string' && supplied.length === 36 && validateUuid(supplied)
? supplied.toLowerCase()
: randomUUID();
res.locals.chatRequestId = requestId;
res.setHeader('X-Request-ID', requestId);
const startedAt = Date.now();
// Log a fixed route family, not URL query strings, identities, cookies or bodies.
let errorCode: string | undefined;
const json = res.json.bind(res);
res.json = (body: unknown) => {
if (res.statusCode >= 400 && body && typeof body === 'object' && !Array.isArray(body)) {
const payload = body as Record<string, unknown>;
errorCode = typeof payload.error === 'string' ? errorCodes.get(payload.error) : undefined;
return json({ ...payload, request_id: requestId });
}
return json(body);
};
res.on('finish', () => {
if (res.statusCode >= 400) {
logger.warn({
event: 'addie_chat_request_failed', request_id: requestId,
route: '/api/addie/chat', method: req.method, status: res.statusCode,
error_code: errorCode, duration_ms: Date.now() - startedAt,
}, 'Addie chat HTTP request failed');
}
});
res.on('close', () => {
if (!res.writableFinished) {
logger.warn({
event: 'addie_chat_request_interrupted', request_id: requestId,
route: '/api/addie/chat', method: req.method, duration_ms: Date.now() - startedAt,
}, 'Addie chat connection closed before completion');
}
});
next();
}

export function correlateChatStreamError(res: Response, data: unknown): Record<string, unknown> {
const payload = data && typeof data === 'object' && !Array.isArray(data)
? data as Record<string, unknown> : {};
logger.warn({
event: 'addie_chat_stream_failed', request_id: res.locals.chatRequestId,
recoverable: payload.recoverable === true,
}, 'Addie chat stream failed');
return { ...payload, request_id: res.locals.chatRequestId };
}
15 changes: 10 additions & 5 deletions server/src/routes/addie-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import cors from "cors";
import { createLogger } from "../logger.js";
import { CachedPostgresStore } from "../middleware/pg-rate-limit-store.js";
import { optionalAuth } from "../middleware/auth.js";
import { chatRequestCorrelation, correlateChatStreamError } from '../middleware/chat-request-correlation.js';
import { serveHtmlWithConfig } from "../utils/html-config.js";
import { AddieClaudeClient, type AddieResponse, type RequestTools } from "../addie/claude-client.js";
import {
Expand Down Expand Up @@ -1117,8 +1118,8 @@ const chatCorsOptions: cors.CorsOptions = {
],
credentials: true,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Conversation-Id', 'RateLimit-Limit', 'RateLimit-Remaining'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Conversation-Id', 'RateLimit-Limit', 'RateLimit-Remaining', 'X-Request-ID'],
};

export function createAddieChatRouter(options?: {
Expand All @@ -1142,6 +1143,7 @@ export function createAddieChatRouter(options?: {

// Enable CORS for all API routes (for native app support)
apiRouter.use(cors(chatCorsOptions));
apiRouter.use(chatRequestCorrelation);

// Initialize client after server starts (deferred to avoid blocking startup with sync I/O)
if (!injectedChatClient) {
Expand Down Expand Up @@ -1507,7 +1509,7 @@ export function createAddieChatRouter(options?: {
logger.warn({ error }, "Addie Chat: Conversation exceeded context limit");
errorMessage = "This conversation is too long for me to process. Please start a new conversation and I'll be happy to help!";
} else {
logger.error({ error }, "Addie Chat: Error processing message");
logger.error({ error, request_id: res.locals.chatRequestId }, "Addie Chat: Error processing message");
errorMessage = isRetriesExhaustedError(error)
? `${error.reason}. Please try again in a moment.`
: "I'm sorry, I encountered an error. Please try again.";
Expand Down Expand Up @@ -1613,7 +1615,7 @@ export function createAddieChatRouter(options?: {
message: ATTACHMENT_VALIDATION_CLIENT_MESSAGE,
});
}
logger.error({ err: error }, "Addie Chat: Error handling message");
logger.error({ err: error, request_id: res.locals.chatRequestId }, "Addie Chat: Error handling message");
res.status(500).json({
error: "Internal server error",
message: "Unable to process message",
Expand Down Expand Up @@ -1666,6 +1668,9 @@ export function createAddieChatRouter(options?: {

// Helper to send SSE events (checks if connection is still open)
const sendEvent = (event: string, data: unknown) => {
if (event === 'error' || event === 'stream_error') {
data = correlateChatStreamError(res, data);
}
if (connectionClosed) return;
try {
res.write(`event: ${event}\n`);
Expand Down Expand Up @@ -2554,7 +2559,7 @@ export function createAddieChatRouter(options?: {
message: error.statusCode === 403 ? 'Sign in to choose a model.' : 'This model selection is unavailable for the request.',
});
}
logger.error({ err: error }, "Addie Chat Stream: Error handling message");
logger.error({ err: error, request_id: res.locals.chatRequestId }, "Addie Chat Stream: Error handling message");
let interruptedMessageId: string | undefined;
if (claimedTurn) {
try {
Expand Down
2 changes: 2 additions & 0 deletions server/tests/unit/addie-chat-object-authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,8 @@ describe('Addie chat conversation object authorization', () => {
});
expect(response.status).toBe(200);
expect(response.text).toContain('event: stream_error');
expect(response.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/);
expect(response.text).toContain(`"request_id":"${response.headers['x-request-id']}"`);
expect(markDelivery).toHaveBeenCalledWith('interrupted', 'message_assistant');
} finally {
prepare.mockRestore();
Expand Down
2 changes: 2 additions & 0 deletions server/tests/unit/addie-chat-org-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,9 @@ describe('mounted Addie web-thread ownership', () => {
expect(response.body).toEqual({
error: 'Internal server error',
message: 'Unable to process message',
request_id: response.headers['x-request-id'],
});
expect(response.body.request_id).toMatch(/^[0-9a-f-]{36}$/);
expect(router.route).toHaveBeenCalledOnce();
expect(chatClient.processMessage).not.toHaveBeenCalled();
expect(chatClient.processMessageStream).not.toHaveBeenCalled();
Expand Down
Loading
Loading