diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index d649681fb..ffe82de44 100644
@@ -70,164 +70,198 @@ 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;
+const SAFE_IMAGE_DATA_URI_RE =
+ /^data:(image\/(?:png|jpe?g|gif|webp|bmp));base64,([a-z0-9+/=\s]+)$/i;
+const EMPTY_IMAGE_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
+
+function imageDataUriToObjectUrl(src: string): string | null {
+ const match = SAFE_IMAGE_DATA_URI_RE.exec(src);
+ if (!match) return null;
+ try {
+ const mime = match[1];
+ const binary = atob(match[2].replace(/\s/g, ''));
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return URL.createObjectURL(new Blob([bytes], { type: mime }));
+ } catch {
+ return null;
+ }
+}
-function isSafeAttachmentImageSrc(src: string): boolean {
- return SAFE_IMAGE_DATA_URI_RE.test(src);
+function AttachmentImage({ dataUri }: { dataUri: string }) {
+ const [objectUrl, setObjectUrl] = useState<string | null>(null);
+
+ useEffect(() => {
+ const nextUrl = imageDataUriToObjectUrl(dataUri);
+ setObjectUrl(nextUrl);
+ return () => {
+ if (nextUrl) URL.revokeObjectURL(nextUrl);
+ };
+ }, [dataUri]);
+
+ return (
+ <img
+ src={objectUrl ?? EMPTY_IMAGE_SRC}
+ alt=""
+ className="max-w-[200px] max-h-[200px] rounded-2xl object-cover"
+ />
+ );
}
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);
@@ -2253,198 +2287,193 @@ const Conversations = ({
)}>
{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
- ).filter(isSafeAttachmentImageSrc);
+ ).filter(src => SAFE_IMAGE_DATA_URI_RE.test(src));
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"
- />
+ <AttachmentImage key={i} dataUri={uri} />
))}
</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>
)}
{fileNames.length > 0 && (
<div className="flex flex-wrap gap-1.5 justify-end">
{fileNames.map((name, i) => (
<div
key={i}
className="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]">
<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="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M14 2v6h6"
/>
</svg>
<span className="truncate font-medium">{name}</span>
</div>
))}
</div>
)}
{(displayText || showTime) && (
<div className="rounded-2xl px-4 py-2.5 bg-primary-500 text-content-inverted rounded-br-md break-words overflow-hidden">
{displayText && (
<BubbleMarkdown content={displayText} tone="user" />
)}
{showTime && (
<p
diff --git a/scripts/mock-api/routes/llm.mjs b/scripts/mock-api/routes/llm.mjs
index ed8609646..2c69bccd0 100644
@@ -1,93 +1,91 @@
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,
},
});
}
@@ -96,168 +94,191 @@ function sendRuleError(res, rule) {
// When the agent harness calls the OpenAI-compatible endpoint with
// `stream: true`, openhuman/providers/compatible.rs expects SSE chunks
// shaped like:
//
// 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 sleepDelay(ms) {
+ switch (ms) {
+ case 10:
+ return new Promise((resolve) => setTimeout(resolve, 10));
+ case 25:
+ return new Promise((resolve) => setTimeout(resolve, 25));
+ case 50:
+ return new Promise((resolve) => setTimeout(resolve, 50));
+ case 100:
+ return new Promise((resolve) => setTimeout(resolve, 100));
+ case 250:
+ return new Promise((resolve) => setTimeout(resolve, 250));
+ case 500:
+ return new Promise((resolve) => setTimeout(resolve, 500));
+ case 1000:
+ return new Promise((resolve) => setTimeout(resolve, 1000));
+ default:
+ return Promise.resolve();
+ }
}
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);
+ if (parsed <= 10) return 10;
+ if (parsed <= 25) return 25;
+ if (parsed <= 50) return 50;
+ if (parsed <= 100) return 100;
+ if (parsed <= 250) return 250;
+ if (parsed <= 500) return 500;
+ return 1000;
}
// 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.
@@ -272,218 +293,218 @@ function handleStreamingCompletion({
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 = 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 = safeDelayMs(entry.delayMs, defaultDelayMs);
- if (delay > 0) await sleep(delay);
+ if (delay > 0) await sleepDelay(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);
+ if (delay > 0) await sleepDelay(delay);
writeSseEvent(
res,
sseChunkEnvelope({
model,
toolCallDelta: {
index,
function: { arguments: piece },
},
}),
);
}
continue;
}
if (entry.finish) {
writeSseEvent(
res,
sseChunkEnvelope({
model,
finishReason: entry.finish,
usage: trailingUsage ?? undefined,
}),
);
trailingUsage = null;
continue;
}
}
// Always close with the [DONE] sentinel — clients use it to detect
// graceful end-of-stream and clear in-flight state. Skipping it
// wedges the in-flight map until the next request lands.
res.write("data: [DONE]\n\n");
res.end();
}
/**
* Smart mock LLM endpoint.
*
* Drives keyword-based routing so unit/E2E tests can exercise the agent
* harness end-to-end without spinning up a real model. The mock looks
* at the latest user/tool message in the request and either:
*
* 1. Replays a forced response queue (`llmForcedResponses` behavior),
* 2. Matches a configured keyword rule (`llmKeywordRules` behavior),
* 3. Falls through to a sensible default ("Hello from e2e mock agent").
*
* Keyword rules look like:
*
* [
* {
* "keyword": "search", // case-insensitive substring
* "toolCalls": [
* { "name": "search_tool", "arguments": {"q": "rust"} }
* ],
* "content": "Looking it up..."
* },
* {
* "keyword": "search_tool-ok",
* "content": "Here's the answer."
* }
* ]
*
* Configure with:
* POST /__admin/behavior body: {"llmKeywordRules": "<json-string>"}
*
* This mirrors the Rust-side `KeywordScriptedProvider` in
* `src/openhuman/agent/harness/test_support.rs` so the same testing
* mental model applies on both sides of the FFI.
*/
function makeChoice({ content, toolCalls, callIdSeed }) {
const message = { role: "assistant", content: content ?? "" };
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
message.tool_calls = toolCalls.map((tc, idx) => ({
id: tc.id ?? `call_${callIdSeed}_${idx}`,
type: "function",
function: {
name: String(tc.name ?? ""),
arguments:
typeof tc.arguments === "string"