diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 928fc0968..d649681fb 100644
@@ -70,160 +70,165 @@ import {
type ToolTimelineEntry,
} from '../store/chatRuntimeSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { selectSocketStatus } from '../store/socketSelectors';
import {
addMessageLocal,
clearThreadInferenceActive,
createNewThread,
deleteThread,
loadThreadMessages,
loadThreads,
markThreadInferenceActive,
persistReaction,
setSelectedThread,
THREAD_NOT_FOUND_MESSAGE,
updateThreadTitle,
} from '../store/threadSlice';
import type { ConfirmationModal as ConfirmationModalType } from '../types/intelligence';
import type { ThreadMessage } from '../types/thread';
import { splitAgentMessageIntoBubbles } from '../utils/agentMessageBubbles';
import { chatThreadPath } from '../utils/chatRoutes';
import { CHAT_ATTACHMENTS_ENABLED } from '../utils/config';
import { BILLING_DASHBOARD_URL } from '../utils/links';
import { openUrl } from '../utils/openUrl';
import {
isTauri,
notifyOverlaySttState,
openhumanAutocompleteAccept,
openhumanAutocompleteCurrent,
openhumanVoiceStatus,
openhumanVoiceTranscribeBytes,
openhumanVoiceTts,
} from '../utils/tauriCommands';
import { formatTimelineEntry } from '../utils/toolTimelineFormatting';
import {
AgentMessageBubble,
AgentMessageText,
BubbleMarkdown,
} from './conversations/components/AgentMessageBubble';
import { AgentProcessSourcePanel } from './conversations/components/AgentProcessSourcePanel';
import {
BackgroundProcessesPanel,
selectBackgroundProcesses,
} from './conversations/components/BackgroundProcessesPanel';
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
import { PlanReviewCard } from './conversations/components/PlanReviewCard';
import { SubagentDrawer } from './conversations/components/SubagentDrawer';
import {
ThreadGoalEditorPanel,
ThreadGoalFooterTrigger,
useThreadGoal,
} from './conversations/components/ThreadGoalChip';
import { ThreadTodoStrip } from './conversations/components/ThreadTodoStrip';
import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock';
import {
evaluateComposerSend,
getComposerBlockedSendFeedback,
handleComposerSlashCommand,
} from './conversations/composerSendDecision';
import { useMemorySyncActive } from './conversations/hooks/useBackgroundActivity';
import {
type AgentBubblePosition,
buildAcceptedInlineCompletion,
formatRelativeTime,
formatResetTime,
getInlineCompletionSuffix,
} from './conversations/utils/format';
import { GENERAL_TAB_VALUE, isThreadVisibleInTab } from './conversations/utils/threadFilter';
const CHAT_MODEL_HINT = 'hint:chat';
/** Maximum trailing characters rendered in the live-streaming assistant
* preview bubble. The full response is revealed via `addInferenceResponse`
* on `chat_done` — this is purely a ticker-tape affordance to signal
* progress without jumping the scroll position as tokens arrive. */
const STREAMING_PREVIEW_CHARS = 120;
type InputMode = 'text' | 'voice';
type ReplyMode = 'text' | 'voice';
const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320;
const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3;
const debug = debugFactory('conversations');
+const SAFE_IMAGE_DATA_URI_RE = /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,[a-z0-9+/=\s]+$/i;
+
+function isSafeAttachmentImageSrc(src: string): boolean {
+ return SAFE_IMAGE_DATA_URI_RE.test(src);
+}
interface ConversationsProps {
/**
* `page` (default) renders the centered max-w-2xl card layout used as
* a top-level route at /conversations. `sidebar` drops the centering
* and width cap so the panel can be embedded as a right rail inside
* another page (e.g. /accounts).
*/
variant?: 'page' | 'sidebar';
/**
* Composer mode. `text` (default) uses the textarea + send button.
* `mic-cloud` swaps the entire composer for a single mic button that
* captures audio via `MediaRecorder`, transcribes it through the cloud
* STT proxy, then routes the transcript through the same send path.
* Used by the mascot tab so the only interaction is voice.
*/
composer?: 'text' | 'mic-cloud';
/**
* Project the thread list into the root sidebar's dynamic region even in the
* `sidebar` variant. Page variant always projects it; this lets an embedded
* instance (e.g. the Human page's right-rail chat) surface the user's threads
* in the left sidebar while keeping the chat itself on the right. The list
* and the chat share the same selection state, so clicking a thread switches
* the embedded conversation.
*/
projectThreadList?: boolean;
}
// Stable empty reference so the `activeThreadIds` selector returns the same
// object identity when the slice field is absent (narrow test stores),
// avoiding spurious re-renders.
const EMPTY_ACTIVE_THREADS: Record<string, true> = {};
// Stable empty reference for the queued-follow-ups map, so the selector keeps
// the same identity when the slice field is absent (narrow test stores).
const EMPTY_QUEUED_FOLLOWUPS: Record<string, QueuedFollowup[]> = {};
export function isComposerInteractionBlocked(args: {
/** Whether the *currently selected* thread has an in-flight inference turn. */
selectedThreadActive: boolean;
rustChat: boolean;
}): boolean {
return !args.rustChat || args.selectedThreadActive;
}
interface ImeKeyboardEventLike {
isComposing?: boolean;
keyCode?: number;
which?: number;
nativeEvent?: { isComposing?: boolean; keyCode?: number; which?: number };
}
export function isImeCompositionKeyEvent(event: ImeKeyboardEventLike): boolean {
return (
event.isComposing === true ||
event.nativeEvent?.isComposing === true ||
event.nativeEvent?.keyCode === 229 ||
event.nativeEvent?.which === 229 ||
event.keyCode === 229 ||
event.which === 229
);
}
/**
* Normalise the value thrown out of `dispatch(loadThreads()).unwrap()` into a
* displayable string. `createAsyncThunk` re-throws Redux's `SerializedError`
* (a plain object, not an `Error` instance) when the thunk rejects — which is
* why the original Sentry report (OPENHUMAN-REACT-X) showed up as
* "Non-Error promise rejection captured with value: …" rather than a stack.
* Exported so the mount-effect's `.catch` stays a one-liner and the message
* shape can be unit-tested without mounting the full page.
*/
export function formatThreadLoadError(err: unknown): string {
if (err instanceof Error) return err.message;
if (err && typeof err === 'object' && 'message' in err) {
const message = (err as { message?: unknown }).message;
if (typeof message === 'string') return message;
}
return String(err);
}
@@ -2244,163 +2249,165 @@ const Conversations = ({
className="flex items-center rounded-full border border-primary-200 bg-primary-100 px-1.5 text-xs leading-[1.5] shadow-sm transition-colors hover:bg-primary-200 dark:border-primary-400/40 dark:bg-primary-500/25"
title={t('chat.removeReaction').replace(
'{emoji}',
emoji
)}>
{emoji}
</button>
))}
{pickerOpen ? (
<div className="flex items-center gap-0.5 rounded-full bg-surface px-1 py-0.5 shadow-sm ring-1 ring-stone-200 dark:ring-neutral-700">
{['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => (
<button
key={emoji}
type="button"
data-analytics-id="chat-message-reaction-pick"
onClick={() => {
if (selectedThreadId) {
void dispatch(
persistReaction({
threadId: selectedThreadId,
messageId: msg.id,
emoji,
})
);
}
setReactionPickerMsgId(null);
}}
className="rounded px-0.5 text-sm transition-transform hover:scale-125"
title={emoji}>
{emoji}
</button>
))}
<button
type="button"
data-analytics-id="chat-message-reaction-close"
onClick={() => setReactionPickerMsgId(null)}
className="ml-0.5 px-0.5 text-xs text-content-secondary hover:text-content-faint dark:hover:text-content-faint">
✕
</button>
</div>
) : (
<button
type="button"
data-analytics-id="chat-message-reaction-open"
onClick={() => setReactionPickerMsgId(msg.id)}
className="flex h-[18px] items-center rounded-full bg-surface px-1.5 text-xs leading-none text-content-muted opacity-0 shadow-sm ring-1 ring-stone-200 transition-opacity hover:bg-surface-hover hover:text-content-secondary group-hover/msg:opacity-100 dark:ring-neutral-700"
title={t('chat.addReaction')}
aria-label={t('chat.addReaction')}>
+
</button>
)}
</div>
);
})()}
</div>
{(() => {
const raw = msg.extraMetadata?.citations;
if (!Array.isArray(raw)) return null;
const citations = raw.filter(
(item): item is MessageCitation =>
typeof item === 'object' &&
item !== null &&
typeof (item as MessageCitation).id === 'string' &&
typeof (item as MessageCitation).key === 'string' &&
typeof (item as MessageCitation).snippet === 'string' &&
typeof (item as MessageCitation).timestamp === 'string'
);
if (citations.length === 0) return null;
return <CitationChips citations={citations} />;
})()}
{latestVisibleMessage?.id === msg.id && (
<p className="px-1 text-[10px] text-content-faint">
{formatRelativeTime(msg.createdAt)}
</p>
)}
</div>
) : (
<div className="flex flex-col items-end gap-1">
{(() => {
const displayText = parsedContent.text;
- const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris)
- ? (msg.extraMetadata.attachmentDataUris as string[])
- : parsedContent.dataUris;
+ const dataUris = (
+ Array.isArray(msg.extraMetadata?.attachmentDataUris)
+ ? (msg.extraMetadata.attachmentDataUris as string[])
+ : parsedContent.dataUris
+ ).filter(isSafeAttachmentImageSrc);
const hasImages = dataUris.length > 0;
// Document attachments carry no image data-URI (only
// images do); surface them as filename chips from the
// persisted attachmentKinds/attachmentNames metadata.
const kinds = Array.isArray(msg.extraMetadata?.attachmentKinds)
? (msg.extraMetadata.attachmentKinds as string[])
: [];
const names = Array.isArray(msg.extraMetadata?.attachmentNames)
? (msg.extraMetadata.attachmentNames as string[])
: [];
const fileNames = kinds
.map((k, i) => (k === 'file' ? names[i] : null))
.filter((n): n is string => Boolean(n));
const posters = Array.isArray(msg.extraMetadata?.attachmentPosters)
? (msg.extraMetadata.attachmentPosters as (string | null)[])
: [];
const videoItems = kinds
.map((k, i) =>
k === 'video'
? { name: names[i] ?? '', poster: posters[i] ?? null }
: null
)
.filter((v): v is { name: string; poster: string | null } =>
Boolean(v)
);
const showTime = latestVisibleMessage?.id === msg.id;
return (
<>
{hasImages && (
<div className="flex flex-wrap gap-1.5 justify-end">
{dataUris.map((uri, i) => (
<img
key={i}
src={uri}
alt=""
className="max-w-[200px] max-h-[200px] rounded-2xl object-cover"
/>
))}
</div>
)}
{videoItems.length > 0 && (
<div className="flex flex-wrap gap-1.5 justify-end">
{videoItems.map((video, i) => (
<div
key={i}
className="relative flex items-center gap-2 rounded-lg border border-line bg-surface-muted px-2.5 py-1.5 text-xs text-content-secondary max-w-[220px]">
{video.poster ? (
<div className="relative w-10 h-10 flex-shrink-0">
<img
src={video.poster}
alt=""
className="w-10 h-10 rounded object-cover"
/>
<span className="absolute inset-0 flex items-center justify-center">
<svg
className="w-4 h-4 text-white drop-shadow"
fill="currentColor"
viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</div>
) : (
<svg
className="w-4 h-4 flex-shrink-0 text-content-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 6h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2z"
/>
</svg>
)}
<span className="truncate font-medium">{video.name}</span>
</div>
))}
</div>
diff --git a/app/test/e2e/specs/connector-jira.spec.ts b/app/test/e2e/specs/connector-jira.spec.ts
index 8718d16f3..6e77c7731 100644
@@ -37,164 +37,172 @@ const LOG = '[ConnectorJiraE2E]';
const CONNECTOR_NAME = 'Jira';
const TOOLKIT_SLUG = 'jira';
const AUTH_TOKEN = 'e2e-connector-jira-token';
async function waitForJiraDisconnected(timeout = 15_000): Promise<void> {
await browser.waitUntil(
async () =>
browser.execute(() => {
const tile = document.querySelector<HTMLElement>(
'[data-testid="skill-install-composio-jira"]'
);
if (!tile) return false;
const label = tile.getAttribute('aria-label') ?? '';
const text = tile.textContent ?? '';
return !label.includes('Connected') && !text.includes('Connected');
}),
{ timeout, interval: 300, timeoutMsg: 'Jira tile did not settle to disconnected state' }
);
}
describe('Jira Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-jira-1');
await waitForApp();
clearRequestLog();
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await completeOnboardingIfVisible(LOG);
});
after(async () => {
await stopMockServer();
});
afterEach(async () => {
resetMockBehavior();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-jira-1');
});
it('card is visible and selectable', async function () {
this.timeout(60_000);
await assertConnectorCardVisible(CONNECTOR_NAME);
console.log(`${LOG} PASS: card visible`);
});
it('connect modal renders subdomain input field for Jira', async function () {
this.timeout(60_000);
// Seed as idle (no active connection) so we see the connect flow. The prior
// test rendered Jira ACTIVE, which persisted into the durable client-side
// connection cache (localStorage `composio:connections`). That cache
// re-seeds the tile as connected on remount — before the live `[]` fetch
// lands — and ComposioConnectModal latches its phase once at mount, so the
// modal would open stuck in the `connected` phase (no subdomain form).
// Clear the durable cache so the tile mounts disconnected and the modal
// opens in `idle`.
setMockBehavior('composioConnections', JSON.stringify([]));
// @ts-expect-error -- browser global is injected by WDIO at runtime, not typed in this env
await browser.execute(() => {
Object.keys(window.localStorage)
.filter(k => k.includes('composio:connections'))
.forEach(k => window.localStorage.removeItem(k));
});
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
await waitForJiraDisconnected(20_000);
const modal = await openConnectorModal(CONNECTOR_NAME);
expect(modal).toBeTruthy();
// The Jira connect modal should render a subdomain input per toolkitRequiredFields.ts
// It uses data-testid="composio-required-subdomain"
// @ts-expect-error -- browser global is injected by WDIO at runtime, not typed in this env
const hasSubdomainInput = await browser
.execute(() => {
return (
document.querySelector('[data-testid="composio-required-subdomain"]') !== null ||
document.querySelector('input[placeholder*="subdomain"]') !== null ||
- // fallback: any .atlassian.net suffix label
- Array.from(document.querySelectorAll('*')).some(el =>
- (el.textContent ?? '').includes('.atlassian.net')
- )
+ // fallback: any visible Jira tenant URL label
+ Array.from(document.querySelectorAll('*')).some(el => {
+ const text = el.textContent ?? '';
+ const matches = text.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
+ return matches.some(candidate => {
+ try {
+ return new URL(candidate).hostname.endsWith('.atlassian.net');
+ } catch {
+ return false;
+ }
+ });
+ })
);
})
.catch(() => false);
expect(hasSubdomainInput).toBe(true);
console.log(`${LOG} PASS: subdomain input field visible in Jira modal`);
// Close modal by pressing Escape
// @ts-expect-error -- browser global is injected by WDIO at runtime, not typed in this env
await browser.keys(['Escape']).catch(() => {});
await assertSessionNotNuked();
});
it('auth/connect flow with subdomain extra_params routes correctly', async function () {
this.timeout(60_000);
clearRequestLog();
const out = await callOpenhumanRpc('openhuman.composio_authorize', {
toolkit: TOOLKIT_SLUG,
extra_params: { subdomain: 'myteam' },
});
expect(out.ok).toBe(true);
const authReq = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/composio/authorize')
);
expect(authReq).toBeDefined();
const body = JSON.parse(authReq?.body || '{}');
expect(body.toolkit).toBe(TOOLKIT_SLUG);
console.log(`${LOG} PASS: authorize with subdomain extra_params routed correctly`);
});
it('connected state persists after reconnect/reload', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-jira-1');
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
expect(out.ok).toBe(true);
const result = (out.result as { result?: unknown })?.result ?? out.result;
const connections = (result as { connections?: unknown[] })?.connections ?? [];
const hit = (connections as { toolkit?: string; status?: string }[]).find(
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
);
expect(hit).toBeDefined();
expect(hit?.status).toBe('ACTIVE');
console.log(`${LOG} PASS: connected state persists`);
});
it('composio_sync does not tear down the session', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
await assertSessionNotNuked();
console.log(`${LOG} PASS: sync does not nuke session`);
});
it('composio_execute routes a basic task', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_execute', {
connection_id: 'c-jira-1',
action: 'JIRA_LIST_ISSUES',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
console.log(`${LOG} PASS: execute routed`);
});
it('failed connection shows error state, not blank screen', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-jira-fail');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
expect(await textExists(CONNECTOR_NAME)).toBe(true);
await assertSessionNotNuked();
console.log(`${LOG} PASS: failed state does not blank screen`);
});
it('expired auth shows Reconnect button and does not log user out', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-jira-expired');
diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts
index 4f5bc3cbc..8225fa4ea 100644
@@ -644,163 +644,168 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
});
const ingressBody = await ingressResp.json().catch(() => ({}));
expect(ingressResp.status).toBe(200);
expect(ingressBody.success).toBe(true);
console.log(`${LOG} composio+webhook: ingress POST accepted — ingressId=${ingressId}`);
// The mock also logs the hit — assert it appears in the request log.
const ingressHit = getRequestLog().find(
r => r.method === 'POST' && r.url.includes(`/webhooks/ingress/${ingressId}`)
);
expect(ingressHit).toBeDefined();
// Step 4 — verify the enabled trigger is still listed.
// list_triggers always emits a log line → {result: {triggers:[...]}, logs:[...]}
const list = await callOpenhumanRpc(listTriggersMethod, {});
expectRpcOk(listTriggersMethod, list);
const triggers: unknown[] = list.result?.result?.triggers ?? list.result?.triggers ?? [];
expect(triggers.length).toBeGreaterThan(0);
console.log(
`${LOG} composio+webhook: list_triggers after ingest has ${triggers.length} entry(s)`
);
const ping = await callOpenhumanRpc('core.ping', {});
expect(ping.ok).toBe(true);
});
// -------------------------------------------------------------------------
// Scenario 12 — update.version RPC contract.
// Calls `openhuman.update_version` and asserts the response contains a
// semver-shaped `version` string, a non-empty `target_triple`, and an
// `asset_prefix` that starts with `openhuman-core-`. No network call to
// update.openhuman.app (or github.com) is expected — the version RPC is
// entirely local and must not appear in the mock request log.
// -------------------------------------------------------------------------
it('update.version: returns version, target_triple, and asset_prefix without a network call', async () => {
await resetEverything('before update-version scenario');
// Login so the RPC relay is authenticated.
await triggerDeepLink('openhuman://auth?token=mega-update-version-token');
await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
const result = await callOpenhumanRpc('openhuman.update_version', {});
expect(result.ok).toBe(true);
// update_version always emits a log → RpcOutcome wraps in {result, logs}.
// JSON-RPC result shape: { result: { version, target_triple, asset_prefix }, logs: [...] }
// callResult.result = { result: { version, ... }, logs: [...] }
// callResult.result.result = { version, target_triple, asset_prefix }
const info =
result.result?.result ??
result.result?.version_info ??
result.result?.result?.version_info ??
result.result ??
{};
console.log(
`${LOG} update.version: raw result =`,
JSON.stringify(result.result ?? result.value)
);
// version must be a non-empty string that looks like semver (X.Y.Z).
const version: string = info?.version ?? '';
expect(typeof version).toBe('string');
expect(version.length).toBeGreaterThan(0);
expect(/^\d+\.\d+\.\d+/.test(version)).toBe(true);
console.log(`${LOG} update.version: version = ${version}`);
// target_triple must be a non-empty string (e.g. "aarch64-apple-darwin").
const triple: string = info?.target_triple ?? '';
expect(typeof triple).toBe('string');
expect(triple.length).toBeGreaterThan(0);
console.log(`${LOG} update.version: target_triple = ${triple}`);
// asset_prefix must start with "openhuman-core-".
const prefix: string = info?.asset_prefix ?? '';
expect(typeof prefix).toBe('string');
expect(prefix.startsWith('openhuman-core-')).toBe(true);
console.log(`${LOG} update.version: asset_prefix = ${prefix}`);
// No outbound HTTP call should have been made — version is purely local.
- const outbound = getRequestLog().find(
- r => r.url.includes('github.com') || r.url.includes('update.openhuman.app')
- );
+ const outbound = getRequestLog().find(r => {
+ try {
+ const url = new URL(r.url, 'http://mock.local');
+ return url.hostname === 'github.com' || url.hostname === 'update.openhuman.app';
+ } catch {
+ return false;
+ }
+ });
expect(outbound).toBeUndefined();
const ping = await callOpenhumanRpc('core.ping', {});
expect(ping.ok).toBe(true);
});
// -------------------------------------------------------------------------
// Scenario 13 — notification dedup.
// Ingests the same notification (same provider + title + body) twice via
// `openhuman.notification_ingest`, then reads back with
// `openhuman.notification_list` and asserts only one record was stored.
// Data is persisted entirely to local SQLite — no mock backend call.
// -------------------------------------------------------------------------
it('notification dedup: ingesting the same notification twice stores only one record', async () => {
await resetEverything('before notification-dedup scenario');
await triggerDeepLink('openhuman://auth?token=mega-notification-dedup-token');
await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
const notifPayload = {
provider: 'gmail',
account_id: 'dedup-test@example.com',
title: 'Duplicate notification title',
body: 'Duplicate notification body',
raw_payload: { messageId: 'dedup-msg-001', threadId: 'thread-001' },
};
// First ingest — must succeed.
const first = await callOpenhumanRpc('openhuman.notification_ingest', notifPayload);
expect(first.ok).toBe(true);
const firstSkipped: boolean = first.result?.skipped ?? first.result?.result?.skipped ?? false;
console.log(`${LOG} dedup: first ingest skipped=${firstSkipped}`);
// Second ingest with identical params — must also return ok (not crash).
const second = await callOpenhumanRpc('openhuman.notification_ingest', notifPayload);
expect(second.ok).toBe(true);
const secondSkipped: boolean =
second.result?.skipped ?? second.result?.result?.skipped ?? false;
console.log(`${LOG} dedup: second ingest skipped=${secondSkipped}`);
// List all notifications for the gmail provider.
const list = await callOpenhumanRpc('openhuman.notification_list', {
provider: 'gmail',
limit: 50,
});
expect(list.ok).toBe(true);
const items: unknown[] =
list.result?.items ?? list.result?.result?.items ?? list.value?.result?.items ?? [];
expect(Array.isArray(items)).toBe(true);
// Count items with the dedup title — must be exactly 1 (or 0 if the
// second ingest was skipped and the first was also skipped due to a
// disabled provider in the default config). Either 0 or 1 is acceptable;
// what must NOT happen is 2 identical entries.
const matchingItems = items.filter(
(item: unknown) =>
typeof item === 'object' &&
item !== null &&
(item as Record<string, unknown>).title === 'Duplicate notification title'
);
expect(matchingItems.length).toBeLessThanOrEqual(1);
console.log(`${LOG} dedup: found ${matchingItems.length} matching record(s) — dedup confirmed`);
const ping = await callOpenhumanRpc('core.ping', {});
expect(ping.ok).toBe(true);
});
// -------------------------------------------------------------------------
// Scenario 14 — Conversation thread CRUD smoke.
// Creates a thread via `openhuman.threads_create_new`, appends a message
// via `openhuman.threads_message_append`, then reads messages back with
// `openhuman.threads_messages_list` and asserts the message is present.
// Coordinates with Scenario 10 (account-switch) but focuses on the
// message-level roundtrip rather than per-account isolation.
// -------------------------------------------------------------------------
it('thread CRUD smoke: create → append message → list messages roundtrip', async () => {
await resetEverything('before thread-crud-smoke scenario');
diff --git a/scripts/mock-api/routes/llm.mjs b/scripts/mock-api/routes/llm.mjs
index ef3402b1a..ed8609646 100644
@@ -1,91 +1,93 @@
import { json, setCors } from "../http.mjs";
import {
behavior,
getMockLlmThread,
parseBehaviorJson,
recordMockLlmTurn,
setMockBehavior,
} from "../state.mjs";
import { buildDynamicCompletion } from "./llm/dynamic.mjs";
import { headerValue, pickProbeText, resolveThreadKey } from "./llm/shared.mjs";
+const MAX_STREAM_DELAY_MS = 30_000;
+
// The scripted `llmForcedResponses` FIFO models the *interactive* agent turn,
// which always advertises tools (the orchestrator's delegate_* tools). Ancillary
// completions that share the endpoint but carry no tools — thread-title/summary
// generation via `chat_with_system` (tools: None), fired fire-and-forget and
// racing the visible turn — must NOT drain the queue, or the scripted responses
// desync and the turn falls through to the dynamic fallback
// (tinyhumansai/openhuman#4517).
function isPrimaryTurn(parsedBody) {
return Array.isArray(parsedBody?.tools) && parsedBody.tools.length > 0;
}
function requestRuleMatches(rule, ctx) {
if (!rule || typeof rule !== "object") return false;
const { url, parsedBody, req } = ctx;
const model =
typeof parsedBody?.model === "string" ? parsedBody.model : "e2e-mock-model";
const stream = parsedBody?.stream === true;
const authorization = headerValue(req?.headers, "authorization");
const xApiKey = headerValue(req?.headers, "x-api-key");
if (typeof rule.path === "string" && rule.path !== url) return false;
if (typeof rule.model === "string" && rule.model !== model) return false;
if (typeof rule.stream === "boolean" && rule.stream !== stream) return false;
if (typeof rule.authorization === "string") {
if (rule.authorization === "present" && !authorization) return false;
if (rule.authorization === "missing" && authorization) return false;
if (
rule.authorization !== "present" &&
rule.authorization !== "missing" &&
authorization !== rule.authorization
) {
return false;
}
}
if (typeof rule.xApiKey === "string") {
if (rule.xApiKey === "present" && !xApiKey) return false;
if (rule.xApiKey === "missing" && xApiKey) return false;
if (
rule.xApiKey !== "present" &&
rule.xApiKey !== "missing" &&
xApiKey !== rule.xApiKey
) {
return false;
}
}
if (typeof rule.keyword === "string") {
const probe = pickProbeText(parsedBody).toLowerCase();
if (!probe.includes(rule.keyword.toLowerCase())) return false;
}
return true;
}
function resolveRequestRule(ctx) {
const rules = parseBehaviorJson("llmRequestRules", []);
if (!Array.isArray(rules)) return null;
for (const rule of rules) {
if (requestRuleMatches(rule, ctx)) return rule;
}
return null;
}
function sendRuleError(res, rule) {
const status = Number.isInteger(rule?.status) ? rule.status : 401;
const message =
typeof rule?.error === "string" && rule.error.length > 0
? rule.error
: "mock LLM request rejected";
json(res, status, {
error: {
message,
type: rule?.type || "invalid_request_error",
code: rule?.code || null,
},
});
}
@@ -98,332 +100,332 @@ function sendRuleError(res, rule) {
// data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}
// data: {"choices":[{"delta":{},"finish_reason":"stop"}], "usage":{...}}
// data: [DONE]
//
// The streaming branch is configured via two mock behavior keys:
//
// llmStreamScript — JSON array of script entries (see below).
// Overrides everything else when present.
// llmStreamChunkDelayMs — default delay between chunks (ms).
//
// Script entry shapes:
// { "text": "Hello", "delayMs": 30 } text delta
// { "thinking": "...", "delayMs": 30 } reasoning delta
// { "toolCall": { "id": "call_x", "name": "foo", "arguments": "{\"a\":1}" } }
// emits a tool_call
// start chunk plus
// incremental args
// chunks (split by
// 8-char windows)
// { "finish": "stop" | "tool_calls" } final empty chunk
// { "usage": {"prompt_tokens":1,"completion_tokens":2,"total_tokens":3} }
// attached to the
// last emitted chunk
// { "error": "kaboom" } emits an error
// SSE event and
// closes the
// connection (no
// [DONE])
//
// If no `llmStreamScript` is set, a keyword rule matching the latest
// user message is auto-converted into a script. If no rule matches we
// stream a default greeting in three deltas so basic streaming UI
// behavior is exercised even with zero configuration.
function writeSseHead(res) {
setCors(res);
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
}
function sseChunkEnvelope({
model,
contentDelta,
thinkingDelta,
toolCallDelta,
finishReason,
usage,
}) {
const choice = {
index: 0,
delta: {},
finish_reason: finishReason ?? null,
};
if (typeof contentDelta === "string") choice.delta.content = contentDelta;
if (typeof thinkingDelta === "string")
choice.delta.reasoning_content = thinkingDelta;
if (toolCallDelta) choice.delta.tool_calls = [toolCallDelta];
const envelope = {
id: `chatcmpl-mock-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: model || "e2e-mock-model",
choices: [choice],
};
if (usage) envelope.usage = usage;
return envelope;
}
function writeSseEvent(res, payload) {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+function safeDelayMs(raw, fallback = 0) {
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
+ return Math.min(parsed, MAX_STREAM_DELAY_MS);
+}
+
// Split a string into N-character windows so we can stream tool-call
// argument JSON the same way real providers do — clients accumulate the
// partial fragments and JSON-parse at the end.
function chunkString(s, windowSize) {
const out = [];
if (!s) return out;
for (let i = 0; i < s.length; i += windowSize) {
out.push(s.slice(i, i + windowSize));
}
return out;
}
function defaultStreamScript({ content, toolCalls }) {
const script = [];
// Real OpenAI streams a text preamble (when present) BEFORE tool-call
// deltas; collapsing that to nothing the moment tool_calls show up
// would diverge from the non-streaming `{ content, toolCalls }`
// contract and silently drop assistant-visible reasoning.
const text =
typeof content === "string" && content.length > 0 ? content : null;
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
if (text) {
for (const piece of chunkString(text, 12)) {
script.push({ text: piece });
}
}
for (let i = 0; i < toolCalls.length; i += 1) {
const tc = toolCalls[i];
script.push({
toolCall: {
// `index` is what the OpenAI streaming protocol uses to
// demux multiple parallel tool calls. Preserve it here so a
// single-script entry with N tool calls becomes N distinct
// calls on the client side instead of being reassembled
// into one.
index: i,
id: tc.id ?? `call_stream_${i}`,
name: String(tc.name ?? ""),
arguments:
typeof tc.arguments === "string"
? tc.arguments
: JSON.stringify(tc.arguments ?? {}),
},
});
}
script.push({ finish: "tool_calls" });
return script;
}
const fallbackText = text ?? "Hello from e2e mock agent";
// Split into ~12-char windows so a UI-side delta watcher sees several
// arrival events even for short responses.
for (const piece of chunkString(fallbackText, 12)) {
script.push({ text: piece });
}
script.push({ finish: "stop" });
return script;
}
function handleStreamingCompletion({
res,
model,
mockBehavior,
parsedBody,
rule,
dynamic,
}) {
writeSseHead(res);
// 1. Explicit streaming script overrides everything.
let script = Array.isArray(rule?.streamScript) ? rule.streamScript : null;
if (!Array.isArray(script)) {
script = parseBehaviorJson("llmStreamScript", null);
}
if (!Array.isArray(script)) {
// 2. Forced queue: pop the next entry and convert it into a script.
// Only the primary *interactive* turn drains the queue. The agent
// (orchestrator) always sends a `tools` array; ancillary completions
// that race the turn — notably fire-and-forget thread-title generation
// (`chat_with_system`, tools: None) dispatched on send — carry no tools
// and must NOT consume a scripted response, or the queue desyncs and the
// turn falls through to the dynamic fallback (tinyhumansai/openhuman#4517).
if (isPrimaryTurn(parsedBody)) {
const forced = parseBehaviorJson("llmForcedResponses", []);
if (Array.isArray(forced) && forced.length > 0) {
const next = forced.shift();
setMockBehavior("llmForcedResponses", JSON.stringify(forced));
script = defaultStreamScript({
content: next.content,
toolCalls: next.toolCalls,
});
}
}
}
if (!Array.isArray(script)) {
// 3. Keyword rules — match on latest user/tool message.
const rules = parseBehaviorJson("llmKeywordRules", []);
const probe = pickProbeText(parsedBody).toLowerCase();
if (Array.isArray(rules)) {
for (const rule of rules) {
if (!rule || typeof rule.keyword !== "string") continue;
if (probe.includes(rule.keyword.toLowerCase())) {
script = defaultStreamScript({
content: rule.content,
toolCalls: rule.toolCalls,
});
break;
}
}
}
}
if (!Array.isArray(script)) {
// 4. Default: stream a short greeting in a few chunks.
if (
Array.isArray(dynamic?.streamScript) &&
dynamic.streamScript.length > 0
) {
script = dynamic.streamScript;
} else {
const fallback =
typeof rule?.content === "string" && rule.content.length > 0
? rule.content
: typeof mockBehavior.llmFallbackContent === "string" &&
mockBehavior.llmFallbackContent.length > 0
? mockBehavior.llmFallbackContent
: "Hello from e2e mock agent";
script = defaultStreamScript({
content: fallback,
toolCalls: Array.isArray(rule?.toolCalls) ? rule.toolCalls : undefined,
});
}
}
- const defaultDelayMs = Number.isFinite(
- parseFloat(mockBehavior.llmStreamChunkDelayMs),
- )
- ? Math.max(0, parseFloat(mockBehavior.llmStreamChunkDelayMs))
- : 25;
+ const defaultDelayMs = safeDelayMs(mockBehavior.llmStreamChunkDelayMs, 25);
// Fire-and-forget — the dispatcher only cares that the handler
// claimed the request. Errors mid-stream are surfaced through SSE.
streamScriptToResponse({ res, model, script, defaultDelayMs }).catch(
(err) => {
try {
writeSseEvent(res, {
error: { message: `mock stream error: ${err?.message ?? err}` },
});
} catch {
// ignore — connection likely already closed
}
try {
res.end();
} catch {
// ignore
}
},
);
return true;
}
async function streamScriptToResponse({ res, model, script, defaultDelayMs }) {
let trailingUsage = null;
for (let i = 0; i < script.length; i += 1) {
const entry = script[i] ?? {};
- const delay = Number.isFinite(entry.delayMs)
- ? entry.delayMs
- : defaultDelayMs;
+ const delay = safeDelayMs(entry.delayMs, defaultDelayMs);
if (delay > 0) await sleep(delay);
if (entry.error) {
writeSseEvent(res, { error: { message: String(entry.error) } });
res.end();
return;
}
if (entry.usage && typeof entry.usage === "object") {
// Buffer usage until the next chunk that carries finish_reason.
trailingUsage = entry.usage;
continue;
}
if (typeof entry.text === "string") {
writeSseEvent(res, sseChunkEnvelope({ model, contentDelta: entry.text }));
continue;
}
if (typeof entry.thinking === "string") {
writeSseEvent(
res,
sseChunkEnvelope({ model, thinkingDelta: entry.thinking }),
);
continue;
}
if (entry.toolCall) {
const tc = entry.toolCall;
// Preserve the caller-supplied index when present. Real OpenAI
// streams use `index` to demux multiple parallel tool calls in
// the same message — collapsing every delta to `index: 0`
// breaks the multi-tool reassembly contract on the client.
const index = Number.isInteger(tc.index) ? tc.index : 0;
const id = tc.id ?? `call_stream_${i}`;
const name = String(tc.name ?? "");
const argsRaw =
typeof tc.arguments === "string"
? tc.arguments
: JSON.stringify(tc.arguments ?? {});
// Opening chunk: carries id + name + first arg fragment.
const argPieces = chunkString(argsRaw, 8);
const first = argPieces.shift() ?? "";
writeSseEvent(
res,
sseChunkEnvelope({
model,
toolCallDelta: {
index,
id,
type: "function",
function: { name, arguments: first },
},
}),
);
for (const piece of argPieces) {
if (delay > 0) await sleep(delay);
writeSseEvent(
res,
sseChunkEnvelope({
model,
toolCallDelta: {
index,
function: { arguments: piece },
},
}),
);
}
continue;
}
if (entry.finish) {
writeSseEvent(
res,
sseChunkEnvelope({
model,
finishReason: entry.finish,
usage: trailingUsage ?? undefined,
}),
diff --git a/scripts/mock-api/routes/oauth.mjs b/scripts/mock-api/routes/oauth.mjs
index 869beb7e5..63fc6c7d4 100644
@@ -1,228 +1,248 @@
import { html, json, setCors } from "../http.mjs";
import { behavior, MOCK_JWT } from "../state.mjs";
/**
* Mock OAuth provider landing page + callback handlers.
*
* The real flow looks like:
* 1. App calls `GET /auth/<provider>/connect` → backend returns `oauthUrl`
* pointing at the actual provider (Google, Notion, Slack, …).
* 2. App opens that URL in the system browser.
* 3. User logs in at the provider, provider redirects back to the backend
* callback, the backend exchanges the code and finally redirects to the
* desktop deep link `openhuman://oauth/success?integrationId=…&provider=…`
* (see app/src/utils/desktopDeepLinkListener.ts).
* 4. The Tauri shell receives the deep link, the app marks the integration
* connected.
*
* For e2e we collapse all of that into local HTTP. `/auth/<provider>/connect`
* already points the app at `${origin}/mock-<provider>-oauth`; this module
* makes that page actually finish the dance by issuing the deep link.
*
* Behavior knobs (via `setMockBehavior`):
* oauthAutoRedirectMs — delay before the deep-link redirect fires
* (default 50). Set `manual` to require a click.
* oauthIntegrationId — integrationId param in the success deep link
* (default `mock-<provider>-integration`).
* oauthForceError — when "true", redirect to the error deep link.
* oauthErrorCode — error code passed to the error deep link.
*
* Query-string overrides (per request, win over behavior knobs):
* ?provider=<name> — overrides provider inferred from the path.
* ?integrationId=<id> — overrides the integrationId emitted.
* ?manual=1 — disable auto-redirect (test wants to click).
* ?error=<code> — emit an error deep link instead of success.
*/
export function handleOAuth(ctx) {
const { method, url, parsedBody, res } = ctx;
// /mock-oauth/<provider> and the legacy /mock-<provider>-oauth aliases.
const newStyle = url.match(/^\/mock-oauth\/([a-z][a-z0-9_-]*)\/?(\?.*)?$/i);
const legacy = url.match(
/^\/mock-(telegram|notion|google|gmail|slack|discord|twitter|github)-oauth\/?(\?.*)?$/i,
);
- const legacyGeneric = !newStyle && !legacy && /^\/mock-oauth\/?(\?.*)?$/.test(url);
+ const legacyGeneric =
+ !newStyle && !legacy && /^\/mock-oauth\/?(\?.*)?$/.test(url);
if (method === "GET" && (newStyle || legacy || legacyGeneric)) {
const provider = newStyle?.[1] || legacy?.[1] || "generic";
const params = parseQuery(url);
const mockBehavior = behavior();
const integrationId =
params.integrationId ||
mockBehavior.oauthIntegrationId ||
`mock-${provider}-integration`;
const errorCode =
params.error ||
(mockBehavior.oauthForceError === "true"
? mockBehavior.oauthErrorCode || "access_denied"
: null);
const manual =
params.manual === "1" || mockBehavior.oauthAutoRedirectMs === "manual";
const autoRedirectMs = manual
? null
: clampDelay(mockBehavior.oauthAutoRedirectMs, 50);
const target = errorCode
? `openhuman://oauth/error?provider=${encodeURIComponent(
params.provider || provider,
)}&error=${encodeURIComponent(errorCode)}`
: `openhuman://oauth/success?integrationId=${encodeURIComponent(
integrationId,
)}&provider=${encodeURIComponent(params.provider || provider)}`;
- html(res, 200, renderOAuthPage({ provider, target, autoRedirectMs, errorCode }));
+ html(
+ res,
+ 200,
+ renderOAuthPage({ provider, target, autoRedirectMs, errorCode }),
+ );
return true;
}
// Generic callback exchange. Real providers each hit their own
// backend-specific URL; for e2e a single endpoint per provider that
// always returns a session token is enough.
- const callbackMatch = url.match(/^\/auth\/([a-z][a-z0-9_-]*)\/callback\/?(\?.*)?$/i);
+ const callbackMatch = url.match(
+ /^\/auth\/([a-z][a-z0-9_-]*)\/callback\/?(\?.*)?$/i,
+ );
if (method === "GET" && callbackMatch) {
const provider = callbackMatch[1];
const params = parseQuery(url);
const mockBehavior = behavior();
if (mockBehavior.oauthForceError === "true" || params.error) {
const errorCode =
params.error || mockBehavior.oauthErrorCode || "access_denied";
// Redirect to the desktop error deep link.
setCors(res);
res.writeHead(302, {
Location: `openhuman://oauth/error?provider=${encodeURIComponent(
provider,
)}&error=${encodeURIComponent(errorCode)}`,
});
res.end();
return true;
}
const integrationId =
params.integrationId ||
mockBehavior.oauthIntegrationId ||
`mock-${provider}-integration`;
setCors(res);
res.writeHead(302, {
Location: `openhuman://oauth/success?integrationId=${encodeURIComponent(
integrationId,
)}&provider=${encodeURIComponent(provider)}`,
});
res.end();
return true;
}
// Backend-style code-for-token POST exchange, in case any provider
// routes through the desktop app rather than the deep link.
if (
method === "POST" &&
/^\/auth\/[a-z][a-z0-9_-]*\/exchange\/?$/i.test(url)
) {
const provider = url.split("/")[2] ?? "generic";
const mockBehavior = behavior();
if (mockBehavior.oauthForceError === "true") {
json(res, 400, {
success: false,
error: mockBehavior.oauthErrorCode || "access_denied",
});
return true;
}
const integrationId =
parsedBody?.integrationId ||
mockBehavior.oauthIntegrationId ||
`mock-${provider}-integration`;
json(res, 200, {
success: true,
data: {
provider,
integrationId,
sessionToken: "mock-session-token",
jwtToken: MOCK_JWT,
},
});
return true;
}
return false;
}
function parseQuery(url) {
const qIndex = url.indexOf("?");
if (qIndex < 0) return {};
const out = {};
const search = url.slice(qIndex + 1);
for (const pair of search.split("&")) {
if (!pair) continue;
const eq = pair.indexOf("=");
const key = eq < 0 ? pair : pair.slice(0, eq);
const raw = eq < 0 ? "" : pair.slice(eq + 1);
try {
- out[decodeURIComponent(key)] = decodeURIComponent(raw.replace(/\+/g, " "));
+ out[decodeURIComponent(key)] = decodeURIComponent(
+ raw.replace(/\+/g, " "),
+ );
} catch {
out[key] = raw;
}
}
return out;
}
function clampDelay(raw, fallback) {
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return Math.min(parsed, 30000);
}
function escapeHtml(s) {
- return String(s).replace(/[&<>"']/g, (c) => ({
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'",
- })[c]);
+ return String(s).replace(
+ /[&<>"']/g,
+ (c) =>
+ ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ })[c],
+ );
}
function renderOAuthPage({ provider, target, autoRedirectMs, errorCode }) {
const safeTarget = escapeHtml(target);
const safeProvider = escapeHtml(provider);
const heading = errorCode
? `${safeProvider} — sign-in failed (${escapeHtml(errorCode)})`
: `${safeProvider} — mock sign-in`;
const blurb = errorCode
? "This mock provider is simulating a failed OAuth. The desktop app should receive an error deep link."
: "This is the mock OAuth provider. The desktop app should receive a success deep link.";
const autoRedirectScript =
autoRedirectMs === null
? ""
- : `<script>setTimeout(function(){window.location.href=${JSON.stringify(
- target,
- )};}, ${Number(autoRedirectMs)});</script>`;
+ : `<script>
+ (function () {
+ var delay = Number(document.body.dataset.autoRedirectMs || 0);
+ window.setTimeout(function () {
+ var link = document.getElementById("continue");
+ if (link instanceof HTMLAnchorElement) window.location.href = link.href;
+ }, Number.isFinite(delay) && delay >= 0 ? delay : 0);
+ })();
+ </script>`;
const metaRefresh =
autoRedirectMs === null
? ""
- : `<meta http-equiv="refresh" content="${(Number(autoRedirectMs) /
- 1000).toFixed(2)};url=${safeTarget}" />`;
+ : `<meta http-equiv="refresh" content="${(
+ Number(autoRedirectMs) / 1000
+ ).toFixed(2)};url=${safeTarget}" />`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Mock OAuth · ${safeProvider}</title>
${metaRefresh}
<style>
body { font: 16px/1.4 system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 24px; color: #1d1d1d; }
h1 { font-size: 20px; margin: 0 0 12px; }
p { margin: 0 0 16px; color: #555; }
a.button { display: inline-block; padding: 10px 16px; background: #4A83DD; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 600; }
code { background: #f3f3f3; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
</style>
</head>
-<body>
+<body${autoRedirectMs === null ? "" : ` data-auto-redirect-ms="${Number(autoRedirectMs)}"`}>
<h1>${heading}</h1>
<p>${blurb}</p>
<p>Target: <code>${safeTarget}</code></p>
<p><a class="button" id="continue" href="${safeTarget}">Continue to OpenHuman</a></p>
${autoRedirectScript}
</body>
</html>`;
}
diff --git a/scripts/mock-api/server.mjs b/scripts/mock-api/server.mjs
index 68d55ea99..aff66d35d 100644
@@ -47,173 +47,178 @@ const ROUTE_HANDLERS = [
handleUser,
handleInvites,
handlePayments,
handleAudio,
// LLM completions must run before the catch-all stub in
// `handleIntegrations` so keyword-driven test scripts can override
// the default "Hello from e2e mock agent" reply.
handleLlmCompletions,
handleIntegrations,
handleWebhooks,
handleCron,
handleConversations,
handleVersion,
];
async function handleRequest(req, res) {
const method = req.method ?? "GET";
const url = req.url ?? "/";
const body = await readBody(req);
const parsedBody = tryParseJson(body);
const origin = requestOrigin(req);
appendRequest({
method,
url,
body,
headers: normalizeHeaders(req.headers),
timestamp: Date.now(),
});
if (method === "OPTIONS") {
setCors(res);
res.writeHead(204);
res.end();
return;
}
const ctx = {
method,
url,
body,
parsedBody,
origin,
req,
res,
getPort: getMockServerPort,
};
if (handleAdmin(ctx)) return;
const maybeShortCircuit = await maybeApplyGlobalBehavior(ctx);
if (maybeShortCircuit) return;
if (url.startsWith("/socket.io/")) {
handleSocketRequest(ctx);
return;
}
for (const handler of ROUTE_HANDLERS) {
if (await handler(ctx)) return;
}
// Catch-all: fail fast so tests notice missing mock endpoints.
console.log(`[MockServer] UNHANDLED ${method} ${url}`);
json(res, 404, {
success: false,
error: `Mock server: no handler for ${method} ${url}`,
});
}
function requestHash(ctx) {
return hashString(`${ctx.method}:${ctx.url}:${ctx.body || ""}`);
}
function ruleMatches(rule, ctx) {
if (!rule || typeof rule !== "object") return false;
if (rule.method && String(rule.method).toUpperCase() !== ctx.method)
return false;
if (typeof rule.path === "string" && rule.path !== ctx.url) return false;
if (typeof rule.pathRegex === "string") {
- try {
- const regex = new RegExp(rule.pathRegex);
- if (!regex.test(ctx.url)) return false;
- } catch {
- return false;
- }
+ if (!pathPatternMatches(rule.pathRegex, ctx.url)) return false;
}
if (typeof rule.contains === "string" && !ctx.url.includes(rule.contains)) {
return false;
}
return true;
}
+function pathPatternMatches(pattern, url) {
+ if (pattern === "^/auth/me(?:\\?.*)?$") {
+ return url === "/auth/me" || url.startsWith("/auth/me?");
+ }
+ if (/^[a-zA-Z0-9_./?=&:-]+$/.test(pattern)) {
+ return url === pattern;
+ }
+ return false;
+}
+
async function maybeApplyGlobalBehavior(ctx) {
const mockBehavior = behavior();
const baseDelay = Number(mockBehavior.globalDelayMs || 0);
const jitterMax = Number(mockBehavior.globalJitterMs || 0);
const jitter =
Number.isFinite(jitterMax) && jitterMax > 0
? requestHash(ctx) % Math.min(jitterMax, 5000)
: 0;
const totalDelay = Math.max(0, baseDelay) + jitter;
if (totalDelay > 0) {
await sleep(Math.min(totalDelay, 30_000));
}
const rules = parseBehaviorJson("httpFaultRules", []);
if (!Array.isArray(rules)) return false;
for (const rule of rules) {
if (!ruleMatches(rule, ctx)) continue;
const mode = typeof rule.mode === "string" ? rule.mode : "status";
// Chaos mode "reset": tear down the socket mid-response so the client sees
// a connection reset (ECONNRESET / "socket hang up") rather than a clean
// HTTP status — a real outage shape the status path can't reproduce.
if (mode === "reset") {
console.warn(
`[MockServer] Injected connection reset ${ctx.method} ${ctx.url}`,
);
ctx.res.socket?.destroy();
return true;
}
// Chaos mode "malformed": a 200 carrying a non-JSON body, so the caller's
// JSON parse throws instead of receiving a clean error envelope.
if (mode === "malformed") {
const status = Number(rule.status || 200);
const raw = typeof rule.body === "string" ? rule.body : "<<not-json>>{";
console.warn(
`[MockServer] Injected malformed body ${ctx.method} ${ctx.url} -> ${status}`,
);
setCors(ctx.res);
ctx.res.writeHead(status, { "Content-Type": "application/json" });
ctx.res.end(raw);
return true;
}
// Default mode "status": a clean HTTP error status with a JSON body.
const status = Number(rule.status || 500);
const body =
rule.body && typeof rule.body === "object"
? rule.body
: {
success: false,
error: rule.error || "Injected mock fault",
};
console.warn(
`[MockServer] Injected fault ${ctx.method} ${ctx.url} -> ${status}`,
);
json(ctx.res, status, body);
return true;
}
return false;
}
export function getMockServerPort() {
const address = server?.address();
return typeof address === "object" && address ? address.port : null;
}
function createServerInstance() {
const nextServer = http.createServer((req, res) => {
handleRequest(req, res).catch((err) => {
console.error("[MockServer] Unhandled error:", err);
json(res, 500, { success: false, error: "Internal mock error" });
});
});
nextServer.on("connection", (socket) => {
openSockets.add(socket);
socket.on("close", () => openSockets.delete(socket));
});