Files
openhuman/app/src/services/chatService.ts
T

1474 lines
49 KiB
TypeScript

/**
* Chat Service — RPC-based chat transport.
*
* Chat messages are SENT via core RPC (`openhuman.channel_web_chat`).
* Responses and events stream back over the existing Socket.IO connection
* (tool_call, tool_result, chat_done, chat_error) via the web-channel
* event bridge in the Rust core.
*/
import debug from 'debug';
import type { TaskBoard } from '../types/turnState';
import { callCoreRpc } from './coreRpcClient';
import { socketService } from './socketService';
const chatLog = debug('realtime:chat');
export interface ChatToolCallEvent {
thread_id: string;
request_id?: string;
tool_name: string;
skill_id: string;
args: Record<string, unknown>;
round: number;
/**
* Stable call id (matches the `call_id` on preceding
* {@link ChatToolArgsDeltaEvent}s and the eventual
* {@link ChatToolResultEvent}). Reducers key tool-timeline rows by
* this id for end-to-end reconciliation.
*/
tool_call_id?: string;
/**
* Server-computed human label for this call (e.g. "Reading messages"),
* set by the Rust `Tool::display_label`. Present for dynamic
* Composio/MCP/integration tools the client can't label itself; absent
* for built-ins the client formatter already handles.
*/
tool_display_label?: string;
/** Server-computed contextual detail (e.g. "steven@gmail.com"). */
tool_display_detail?: string;
}
export interface ChatToolResultEvent {
thread_id: string;
request_id?: string;
tool_name: string;
skill_id: string;
output: string;
success: boolean;
round: number;
/** Matches the id on the corresponding {@link ChatToolCallEvent}. */
tool_call_id?: string;
/**
* Optional structured failure explanation, present only when `success` is
* false (#4254). Raw snake_case wire object (`class`, `category`,
* `recoverable`, `cause_plain`, `next_action`); parsed defensively via
* `parseToolFailure` before it reaches the store.
*/
failure?: unknown;
}
/** One sub-agent's token/cost contribution within a turn (hover breakdown). */
export interface SubagentUsageWire {
task_id: string;
agent_id: string;
input_tokens: number;
output_tokens: number;
cost_usd: number;
}
/**
* Holistic token/cost/context totals for a completed turn, carried on
* `chat_done`. Every numeric is a turn total (parent agent + any sub-agents);
* `subagents` breaks the same spend down per child. `context_window` is `0` when
* the core couldn't resolve the model's window.
*/
export interface TurnUsageWire {
input_tokens: number;
output_tokens: number;
cached_input_tokens: number;
cost_usd: number;
context_window: number;
subagents?: SubagentUsageWire[];
}
export interface ChatDoneEvent {
thread_id: string;
request_id?: string;
full_response: string;
rounds_used: number;
/**
* @deprecated Superseded by {@link ChatDoneEvent.usage}. The core no longer
* populates these flat fields; read token totals from `usage` instead.
*/
total_input_tokens?: number;
/** @deprecated See {@link ChatDoneEvent.total_input_tokens}. */
total_output_tokens?: number;
/**
* Holistic token/cost/context usage for the turn (parent + sub-agents).
* Absent on synthetic done events that never ran a real turn.
*/
usage?: TurnUsageWire | null;
/** Emoji reaction decided by the local model (if any). */
reaction_emoji?: string | null;
/** Total segments when the response was split into bubbles by Rust. */
segment_total?: number | null;
/** Memory citations captured during retrieval for this response. */
citations?: ChatCitation[] | null;
}
export interface ChatCitation {
id: string;
key: string;
namespace?: string;
score?: number;
timestamp: string;
snippet: string;
}
/** A single segment of a multi-bubble response, emitted before `chat_done`. */
export interface ChatSegmentEvent {
thread_id: string;
/**
* Wire name is `full_response` for compatibility with {@link WebChannelEvent},
* but this field contains only the **segment text**, not the full response.
* Use {@link segmentText} for clarity in consuming code.
*/
full_response: string;
request_id: string;
segment_index: number;
segment_total: number;
reaction_emoji?: string | null;
citations?: ChatCitation[] | null;
}
/** Return the segment text from a {@link ChatSegmentEvent} (avoids the misleading wire name). */
export function segmentText(event: ChatSegmentEvent): string {
return event.full_response;
}
/**
* The parent agent's leading narration for one round, flushed mid-turn when a
* tool/subagent call closes that round. Emitted (`chat_interim`) so the
* narration persists as its own interleaved chat bubble instead of vanishing
* when the turn settles. `round` is the 1-based iteration it belongs to and
* makes a stable per-turn dedup key (one interim per round).
*/
export interface ChatInterimEvent {
thread_id: string;
request_id: string;
/** Wire name is `full_response`; carries only this round's narration text. */
full_response: string;
round: number;
}
export interface ChatErrorEvent {
thread_id: string;
request_id?: string;
message: string;
error_type:
| 'network'
| 'timeout'
| 'tool_error'
| 'inference'
| 'cancelled'
| 'rate_limited'
| 'auth_error'
| 'session_expired'
| 'provider_error'
| 'context_overflow'
| 'model_unavailable'
| 'payload_too_large'
| 'provider_request_rejected'
| 'budget_exhausted';
round: number | null;
}
/** Proactive assistant message pushed by the Rust event bus (not a chat turn). */
export interface ProactiveMessageEvent {
thread_id: string;
request_id?: string;
full_response: string;
}
/**
* Emitted when the agent turn parks on the ApprovalGate — a `Prompt`-class
* (external-effect) tool call is awaiting the user's decision (only when the
* core runs with `OPENHUMAN_APPROVAL_GATE=1`). The frontend surfaces a
* pending-approval prompt; answering routes to the `openhuman.approval_decide`
* RPC. A typed `yes`/`no` chat reply is also honoured server-side; any other
* text cancels the parked turn and is taken as a fresh message.
*/
export interface ChatApprovalRequestEvent {
thread_id: string;
client_id?: string;
request_id: string;
tool_name: string;
/** Human-readable summary of the action awaiting approval. */
message: string;
/**
* Redacted args of the gated call — e.g. `{ command }` for shell,
* `{ path }` for file writes, `{ url }` for network. The card renders the
* exact command/target from this so the user sees precisely what will run.
*/
args?: Record<string, unknown>;
}
/**
* Interactive plan-review request: the orchestrator parked the live turn on a
* thread-scoped plan the user must review before execution (Codex/Claude plan
* mode). Resolved via the `openhuman.plan_review_decide` RPC. Bridged from the
* Rust `DomainEvent::PlanReviewRequested` by the web channel.
*/
export interface ChatPlanReviewRequestEvent {
thread_id: string;
client_id?: string;
request_id: string;
/** One-line summary of the plan. */
message: string;
/** `{ steps: string[] }` — the ordered plan items shown in the review card. */
args?: { steps?: string[] };
}
/**
* Lowercase variant of the Rust `ArtifactKind` enum surfaced on
* artifact lifecycle socket events. Mirrors the slugs produced by
* `ArtifactKind::as_str()` in `src/openhuman/artifacts/types.rs`.
*/
export type ArtifactKind = 'presentation' | 'document' | 'image' | 'other';
/**
* Emitted when the core `artifacts::store::finalize_artifact` flips an
* artifact's status to `Ready`. The chat runtime upserts the snapshot
* keyed on `artifact_id` so the `ArtifactCard` can render in the
* message timeline with a download button (#2779).
*/
export interface ArtifactReadyEvent {
thread_id: string;
client_id?: string;
/** UUID of the artifact record. Use with `ai_get_artifact`. */
artifact_id: string;
kind: ArtifactKind;
/** Human-readable title; also the on-disk filename stem. */
title: string;
/**
* Absolute workspace root the artifact belongs to. Subscribers must compare
* this to their own workspace binding and silently drop events that don't
* match — `path` is workspace-relative and would otherwise resolve into the
* wrong `<workspace>/artifacts/` tree after a workspace switch.
*/
workspace_dir: string;
/** Relative path under `<workspace>/artifacts/`, e.g. `<uuid>/deck.pptx`. */
path: string;
/** Final on-disk size in bytes. */
size_bytes: number;
}
/**
* Emitted when `artifacts::store::fail_artifact` flips an artifact to
* `Failed` after the producer surfaced a reason. The frontend swaps
* the in-flight card for a retry-hint view.
*/
export interface ArtifactFailedEvent {
thread_id: string;
client_id?: string;
artifact_id: string;
kind: ArtifactKind;
title: string;
/** Absolute workspace root — see {@link ArtifactReadyEvent.workspace_dir}. */
workspace_dir: string;
/** Producer-supplied failure reason, already truncated. */
error: string;
}
/**
* Emitted when `artifacts::store::create_artifact` reserves an artifact
* row (status `Pending`), before the producer has written any bytes
* (#3162). The chat runtime upserts an `in_progress` snapshot keyed on
* `artifact_id` so the `ArtifactCard` shows a spinner the moment the
* tool starts, then swaps in place when the matching `artifact_ready`
* / `artifact_failed` arrives. Backend half shipped in #3277.
*/
export interface ArtifactPendingEvent {
thread_id: string;
client_id?: string;
artifact_id: string;
kind: ArtifactKind;
title: string;
/** Absolute workspace root — see {@link ArtifactReadyEvent.workspace_dir}. */
workspace_dir: string;
/** Relative path under `<workspace>/artifacts/`, e.g. `<uuid>/deck.pptx`. */
path: string;
}
/**
* Reason an external transfer is happening — the "because" clause of the
* disclosure. Mirrors the Rust `EgressReason` serialized (snake_case) on the
* S2 egress spine's `external_transfer_pending` event descriptor. Unknown
* future variants are tolerated by the UI (mapped to a generic fallback label).
*/
export type EgressReason =
| 'inference'
| 'tool_call'
| 'integration'
| 'embedding'
| 'network_fetch';
/**
* A category of user data leaving the device on an external transfer. Mirrors
* the Rust `EgressDataKind` (snake_case). Rendered as friendly labels — never
* the raw enum string.
*/
export type EgressDataKind =
| 'prompt'
| 'tool_arguments'
| 'embedding_input'
| 'file_content'
| 'url'
| 'metadata';
/**
* Risk grade the core assigned to the transfer. Always `"unknown"` today
* (the S2 spine does not classify yet); typed for forward-compatibility so the
* S3 disclosure UI can surface a badge once the core starts grading.
*/
export type EgressRiskLevel = 'unknown' | 'none' | 'low' | 'medium' | 'high';
/**
* Emitted by the S2 egress spine (`external_transfer_pending` socket event)
* immediately before an external transfer that is routed through a chat turn
* (the bridge only emits when BOTH `thread_id` and `client_id` are present —
* background egress never surfaces). The Rust side de-dupes per turn so a
* repeated destination fires once per turn.
*
* This slice is DISCLOSURE ONLY (epic #4256 AC1 / issue #4437 / S3) — the S4
* approve/deny arm (#4438) is a separate follow-up. The descriptor is packed
* into the generic `args` field of the web-channel wire envelope, exactly like
* the artifact-lifecycle events; the `subscribeChatEvents` handler flattens it
* back into this typed shape.
*/
export interface ExternalTransferPendingEvent {
thread_id: string;
client_id?: string;
/** Provider identifier (e.g. `openai`, `composio`). Public, not PII. */
provider_slug: string;
/** Human-facing destination service name (e.g. `OpenAI`, `Gmail`). */
service: string;
/** Whether this transfer actually leaves the device (always true in practice). */
is_external: boolean;
/** Why the transfer is happening — drives the "because …" clause. */
reason: EgressReason;
/** Categories of user data being sent. */
data_kinds: EgressDataKind[];
/** Core-assigned risk grade. `"unknown"` today. */
risk_level: EgressRiskLevel;
/** Named risk categories. Empty today. */
risk_categories: string[];
}
/** Emitted when the agent turn begins (before the first LLM call). */
export interface ChatInferenceStartEvent {
thread_id: string;
request_id: string;
}
/**
* Periodic liveness beat emitted by the core progress bridge for the whole
* duration of an in-flight turn (issue #4270). Carries no payload beyond the
* ids — its sole purpose is to rearm the frontend silence timer so a long
* prefill or a buffered-reasoning phase that streams no other progress event
* does not trip a false "no response after 2 minutes" timeout.
*/
export interface ChatInferenceHeartbeatEvent {
thread_id: string;
request_id: string;
}
/** Emitted at the start of each LLM iteration in the tool loop. */
export interface ChatIterationStartEvent {
thread_id: string;
request_id: string;
/** 1-based iteration index. */
round: number;
message: string;
}
/** Emitted when a sub-agent is spawned during tool execution. */
export interface ChatSubagentSpawnedEvent {
thread_id: string;
request_id: string;
/** Agent definition id (e.g. "researcher"). */
tool_name: string;
/** Per-spawn task id. */
skill_id: string;
message: string;
round: number;
}
/** Emitted when a sub-agent completes or fails. */
export interface ChatSubagentDoneEvent {
thread_id: string;
request_id: string;
tool_name: string;
skill_id: string;
message: string;
success: boolean;
round: number;
/** Per-event subagent detail. Mirrors `SubagentProgressDetail` in core. */
subagent?: SubagentProgressDetail;
}
/**
* Per-event subagent detail attached to live subagent activity events
* (`subagent_spawned`, `subagent_completed`, `subagent_iteration_start`,
* `subagent_tool_call`, `subagent_tool_result`).
*
* Matches the Rust `SubagentProgressDetail` struct in
* `src/core/socketio.rs` — every field is optional so older cores that
* don't emit it stay parseable.
*/
export interface SubagentProgressDetail {
mode?: string;
dedicated_thread?: boolean;
prompt_chars?: number;
child_iteration?: number;
child_max_iterations?: number;
agent_id?: string;
task_id?: string;
elapsed_ms?: number;
iterations?: number;
output_chars?: number;
/** Persistent worker sub-thread id backing the delegation (on `subagent_spawned`). */
worker_thread_id?: string;
/** Human-readable display name from the agent registry. */
display_name?: string;
/**
* Absolute path to the worker's isolated `git worktree` checkout (on
* `subagent_completed`, when the worker ran with `isolation = "worktree"`).
* Drives the inline worktree row's open/diff/remove actions (#3376).
*/
worktree_path?: string;
/** Files (relative to the worktree root) the worker changed (on `subagent_completed`). */
changed_files?: string[];
/** Whether the worker's worktree had uncommitted changes (on `subagent_completed`). */
dirty_status?: boolean;
}
/** Extended payload for `subagent_spawned`. */
export interface ChatSubagentSpawnedEventV2 extends ChatSubagentSpawnedEvent {
subagent?: SubagentProgressDetail;
}
/**
* Emitted at the start of each LLM iteration *inside* a running
* sub-agent. Lets the parent thread surface child progress (which round
* the subagent is on, its iteration cap) without flattening it into the
* parent's own iteration counter.
*/
export interface ChatSubagentIterationStartEvent {
thread_id: string;
request_id: string;
/** Parent's iteration index (inherited from the parent context). */
round: number;
/** Subagent's agent id. Mirrored on the flat `tool_name` field. */
tool_name: string;
/** Subagent's task id (the spawn id). */
skill_id: string;
message: string;
subagent?: SubagentProgressDetail;
}
/** Emitted when a sub-agent starts executing one of its own tools. */
export interface ChatSubagentToolCallEvent {
thread_id: string;
request_id: string;
round: number;
/** Child's tool name (e.g. `composio_execute`, `web_search`). */
tool_name: string;
/** Subagent's task id. */
skill_id: string;
/** Provider-assigned tool call id. */
tool_call_id: string;
/**
* Full arguments the sub-agent invoked the tool with, so the processing
* drawer can show *what exactly* the child did. Absent for tools called
* with no/`null` arguments.
*/
args?: unknown;
/** Server-computed human label for this child call (from `Tool::display_label`). */
tool_display_label?: string;
/** Server-computed contextual detail (path / recipient / query). */
tool_display_detail?: string;
subagent?: SubagentProgressDetail;
}
/** Emitted when a sub-agent's tool execution finishes. */
export interface ChatSubagentToolResultEvent {
thread_id: string;
request_id: string;
round: number;
tool_name: string;
skill_id: string;
tool_call_id: string;
success: boolean;
/**
* The child tool's actual output text, so the drawer can show what came
* back. Size/timing still arrive via `subagent.output_chars` /
* `subagent.elapsed_ms`.
*/
output?: string;
/**
* Optional structured failure explanation for a FAILED child tool call
* (#4459), present only when `success` is false. Parsed via
* `parseToolFailure` and stored on the child row so the "why / next" copy
* survives live (previously it was dropped until a snapshot reload).
*/
failure?: unknown;
subagent?: SubagentProgressDetail;
}
/**
* Emitted for each chunk of a sub-agent's streamed assistant text while
* the child iteration is in flight. Distinct from `text_delta` (which is
* the parent's own output) so the UI attributes the token to the running
* subagent row via `subagent.task_id` / `subagent.agent_id` and renders
* it in that row's live transcript. Concatenating `delta`s in order
* yields the child's visible text for the iteration.
*/
export interface ChatSubagentTextDeltaEvent {
thread_id: string;
request_id: string;
/** Parent iteration index (inherited from the parent context). */
round: number;
/** Text fragment from the sub-agent. */
delta: string;
subagent?: SubagentProgressDetail;
}
/**
* Emitted for each chunk of a sub-agent's streamed reasoning / thinking
* output. Counterpart to `thinking_delta` scoped to a child run — only
* sent by models that expose `reasoning_content`.
*/
export interface ChatSubagentThinkingDeltaEvent {
thread_id: string;
request_id: string;
round: number;
delta: string;
subagent?: SubagentProgressDetail;
}
/**
* Emitted for each chunk of streamed assistant text that arrives from the
* provider during an iteration. Concatenating `delta` values in order yields
* the visible assistant text for that iteration.
*/
export interface ChatTextDeltaEvent {
thread_id: string;
request_id: string;
/** 1-based iteration index the chunk belongs to. */
round: number;
/** Text fragment; may be a single token or a few characters. */
delta: string;
}
/**
* Emitted for each chunk of streamed model reasoning / thinking output.
* Only sent by models that expose `reasoning_content` (see the
* `supportsThinking` flag on the model registry entry). Concatenating
* `delta`s in order yields the full reasoning transcript.
*/
export interface ChatThinkingDeltaEvent {
thread_id: string;
request_id: string;
round: number;
delta: string;
}
/**
* Emitted for each chunk of a native tool call's arguments JSON while the
* model is still composing the call. `tool_call_id` groups fragments for
* the same call, and `tool_name` is populated once the provider sends it
* (may be empty on the very first chunk).
*/
export interface ChatToolArgsDeltaEvent {
thread_id: string;
request_id: string;
round: number;
tool_call_id: string;
tool_name: string;
/** JSON fragment; only valid JSON once concatenated across all chunks. */
delta: string;
}
export interface ChatTaskBoardUpdatedEvent {
thread_id: string;
request_id?: string;
task_board: TaskBoard;
}
export interface ChatEventListeners {
onInferenceStart?: (event: ChatInferenceStartEvent) => void;
onInferenceHeartbeat?: (event: ChatInferenceHeartbeatEvent) => void;
onIterationStart?: (event: ChatIterationStartEvent) => void;
onToolCall?: (event: ChatToolCallEvent) => void;
onToolResult?: (event: ChatToolResultEvent) => void;
onSubagentSpawned?: (event: ChatSubagentSpawnedEventV2) => void;
onSubagentDone?: (event: ChatSubagentDoneEvent) => void;
onSubagentAwaitingUser?: (event: ChatSubagentDoneEvent) => void;
onSubagentIterationStart?: (event: ChatSubagentIterationStartEvent) => void;
onSubagentToolCall?: (event: ChatSubagentToolCallEvent) => void;
onSubagentToolResult?: (event: ChatSubagentToolResultEvent) => void;
onSubagentTextDelta?: (event: ChatSubagentTextDeltaEvent) => void;
onSubagentThinkingDelta?: (event: ChatSubagentThinkingDeltaEvent) => void;
onSegment?: (event: ChatSegmentEvent) => void;
onInterim?: (event: ChatInterimEvent) => void;
onTextDelta?: (event: ChatTextDeltaEvent) => void;
onThinkingDelta?: (event: ChatThinkingDeltaEvent) => void;
onToolArgsDelta?: (event: ChatToolArgsDeltaEvent) => void;
onTaskBoardUpdated?: (event: ChatTaskBoardUpdatedEvent) => void;
onProactiveMessage?: (event: ProactiveMessageEvent) => void;
onApprovalRequest?: (event: ChatApprovalRequestEvent) => void;
onPlanReviewRequest?: (event: ChatPlanReviewRequestEvent) => void;
onArtifactPending?: (event: ArtifactPendingEvent) => void;
onArtifactReady?: (event: ArtifactReadyEvent) => void;
onArtifactFailed?: (event: ArtifactFailedEvent) => void;
onExternalTransferPending?: (event: ExternalTransferPendingEvent) => void;
onDone?: (event: ChatDoneEvent) => void;
onError?: (event: ChatErrorEvent) => void;
}
export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
// Register through the socketService wrapper (not the raw socket instance)
// so chat listeners get the same lifecycle guarantees as every other
// subscription: queued while the socket is still connecting (the raw-socket
// path silently no-opped when `getSocket()` was null, dropping the whole
// chat event stream until the next re-subscribe) and re-attached when the
// service flushes pending listeners on (re)connect.
const socket = {
on: (event: string, cb: (...args: unknown[]) => void) => socketService.on(event, cb),
off: (event: string, cb: (...args: unknown[]) => void) => socketService.off(event, cb),
};
const handlers: Array<[string, (...args: unknown[]) => void]> = [];
// Canonical convention for web-channel events is snake_case.
// The core emits aliases for compatibility, but subscribing once avoids
// processing the same logical event twice.
const EVENTS = {
inferenceStart: 'inference_start',
inferenceHeartbeat: 'inference_heartbeat',
iterationStart: 'iteration_start',
toolCall: 'tool_call',
toolResult: 'tool_result',
subagentSpawned: 'subagent_spawned',
subagentCompleted: 'subagent_completed',
subagentFailed: 'subagent_failed',
subagentAwaitingUser: 'subagent_awaiting_user',
subagentIterationStart: 'subagent_iteration_start',
subagentToolCall: 'subagent_tool_call',
subagentToolResult: 'subagent_tool_result',
subagentTextDelta: 'subagent_text_delta',
subagentThinkingDelta: 'subagent_thinking_delta',
segment: 'chat_segment',
interim: 'chat_interim',
textDelta: 'text_delta',
thinkingDelta: 'thinking_delta',
toolArgsDelta: 'tool_args_delta',
taskBoardUpdated: 'task_board_updated',
proactiveMessage: 'proactive_message',
approvalRequest: 'approval_request',
planReviewRequest: 'plan_review_request',
artifactPending: 'artifact_pending',
artifactReady: 'artifact_ready',
artifactFailed: 'artifact_failed',
externalTransferPending: 'external_transfer_pending',
done: 'chat_done',
error: 'chat_error',
} as const;
if (listeners.onInferenceStart) {
const cb = (payload: unknown) => {
const e = payload as ChatInferenceStartEvent;
chatLog('%s thread_id=%s request_id=%s', EVENTS.inferenceStart, e.thread_id, e.request_id);
listeners.onInferenceStart?.(e);
};
socket.on(EVENTS.inferenceStart, cb);
handlers.push([EVENTS.inferenceStart, cb]);
}
if (listeners.onInferenceHeartbeat) {
const cb = (payload: unknown) => {
const e = payload as ChatInferenceHeartbeatEvent;
chatLog(
'%s thread_id=%s request_id=%s',
EVENTS.inferenceHeartbeat,
e.thread_id,
e.request_id
);
listeners.onInferenceHeartbeat?.(e);
};
socket.on(EVENTS.inferenceHeartbeat, cb);
handlers.push([EVENTS.inferenceHeartbeat, cb]);
}
if (listeners.onIterationStart) {
const cb = (payload: unknown) => {
const e = payload as ChatIterationStartEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d',
EVENTS.iterationStart,
e.thread_id,
e.request_id,
e.round
);
listeners.onIterationStart?.(e);
};
socket.on(EVENTS.iterationStart, cb);
handlers.push([EVENTS.iterationStart, cb]);
}
if (listeners.onToolCall) {
const cb = (payload: unknown) => {
const e = payload as ChatToolCallEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d tool=%s',
EVENTS.toolCall,
e.thread_id,
e.request_id,
e.round,
e.tool_name
);
listeners.onToolCall?.(e);
};
socket.on(EVENTS.toolCall, cb);
handlers.push([EVENTS.toolCall, cb]);
}
if (listeners.onToolResult) {
const cb = (payload: unknown) => {
const e = payload as ChatToolResultEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d tool=%s success=%s',
EVENTS.toolResult,
e.thread_id,
e.request_id,
e.round,
e.tool_name,
e.success
);
listeners.onToolResult?.(e);
};
socket.on(EVENTS.toolResult, cb);
handlers.push([EVENTS.toolResult, cb]);
}
if (listeners.onSubagentSpawned) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentSpawnedEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d agent=%s',
EVENTS.subagentSpawned,
e.thread_id,
e.request_id,
e.round,
e.tool_name
);
listeners.onSubagentSpawned?.(e);
};
socket.on(EVENTS.subagentSpawned, cb);
handlers.push([EVENTS.subagentSpawned, cb]);
}
if (listeners.onSubagentDone) {
const onCompleted = (payload: unknown) => {
const e = payload as ChatSubagentDoneEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d agent=%s success=%s',
EVENTS.subagentCompleted,
e.thread_id,
e.request_id,
e.round,
e.tool_name,
e.success
);
listeners.onSubagentDone?.(e);
};
socket.on(EVENTS.subagentCompleted, onCompleted);
handlers.push([EVENTS.subagentCompleted, onCompleted]);
const onFailed = (payload: unknown) => {
const e = payload as ChatSubagentDoneEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d agent=%s success=%s',
EVENTS.subagentFailed,
e.thread_id,
e.request_id,
e.round,
e.tool_name,
e.success
);
listeners.onSubagentDone?.(e);
};
socket.on(EVENTS.subagentFailed, onFailed);
handlers.push([EVENTS.subagentFailed, onFailed]);
}
if (listeners.onSubagentAwaitingUser) {
const onAwaitingUser = (payload: unknown) => {
const e = payload as ChatSubagentDoneEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d agent=%s',
EVENTS.subagentAwaitingUser,
e.thread_id,
e.request_id,
e.round,
e.tool_name
);
listeners.onSubagentAwaitingUser?.(e);
};
socket.on(EVENTS.subagentAwaitingUser, onAwaitingUser);
handlers.push([EVENTS.subagentAwaitingUser, onAwaitingUser]);
}
if (listeners.onSubagentIterationStart) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentIterationStartEvent;
chatLog(
'%s thread_id=%s task=%s child_round=%s/%s',
EVENTS.subagentIterationStart,
e.thread_id,
e.skill_id,
e.subagent?.child_iteration,
e.subagent?.child_max_iterations
);
listeners.onSubagentIterationStart?.(e);
};
socket.on(EVENTS.subagentIterationStart, cb);
handlers.push([EVENTS.subagentIterationStart, cb]);
}
if (listeners.onSubagentToolCall) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentToolCallEvent;
chatLog(
'%s thread_id=%s task=%s child_tool=%s call_id=%s',
EVENTS.subagentToolCall,
e.thread_id,
e.skill_id,
e.tool_name,
e.tool_call_id
);
listeners.onSubagentToolCall?.(e);
};
socket.on(EVENTS.subagentToolCall, cb);
handlers.push([EVENTS.subagentToolCall, cb]);
}
if (listeners.onSubagentToolResult) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentToolResultEvent;
chatLog(
'%s thread_id=%s task=%s child_tool=%s success=%s',
EVENTS.subagentToolResult,
e.thread_id,
e.skill_id,
e.tool_name,
e.success
);
listeners.onSubagentToolResult?.(e);
};
socket.on(EVENTS.subagentToolResult, cb);
handlers.push([EVENTS.subagentToolResult, cb]);
}
if (listeners.onSubagentTextDelta) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentTextDeltaEvent;
chatLog(
'%s thread_id=%s task=%s child_round=%s chars=%d',
EVENTS.subagentTextDelta,
e.thread_id,
e.subagent?.task_id,
e.subagent?.child_iteration,
e.delta?.length ?? 0
);
listeners.onSubagentTextDelta?.(e);
};
socket.on(EVENTS.subagentTextDelta, cb);
handlers.push([EVENTS.subagentTextDelta, cb]);
}
if (listeners.onSubagentThinkingDelta) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentThinkingDeltaEvent;
chatLog(
'%s thread_id=%s task=%s child_round=%s chars=%d',
EVENTS.subagentThinkingDelta,
e.thread_id,
e.subagent?.task_id,
e.subagent?.child_iteration,
e.delta?.length ?? 0
);
listeners.onSubagentThinkingDelta?.(e);
};
socket.on(EVENTS.subagentThinkingDelta, cb);
handlers.push([EVENTS.subagentThinkingDelta, cb]);
}
if (listeners.onSegment) {
const cb = (payload: unknown) => {
const e = payload as ChatSegmentEvent;
chatLog(
'%s thread_id=%s request_id=%s segment=%d/%d',
EVENTS.segment,
e.thread_id,
e.request_id,
e.segment_index,
e.segment_total
);
listeners.onSegment?.(e);
};
socket.on(EVENTS.segment, cb);
handlers.push([EVENTS.segment, cb]);
}
if (listeners.onInterim) {
const cb = (payload: unknown) => {
const e = payload as ChatInterimEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d',
EVENTS.interim,
e.thread_id,
e.request_id,
e.round
);
listeners.onInterim?.(e);
};
socket.on(EVENTS.interim, cb);
handlers.push([EVENTS.interim, cb]);
}
if (listeners.onTextDelta) {
const cb = (payload: unknown) => {
const e = payload as ChatTextDeltaEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d chars=%d',
EVENTS.textDelta,
e.thread_id,
e.request_id,
e.round,
e.delta?.length ?? 0
);
listeners.onTextDelta?.(e);
};
socket.on(EVENTS.textDelta, cb);
handlers.push([EVENTS.textDelta, cb]);
}
if (listeners.onThinkingDelta) {
const cb = (payload: unknown) => {
const e = payload as ChatThinkingDeltaEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d chars=%d',
EVENTS.thinkingDelta,
e.thread_id,
e.request_id,
e.round,
e.delta?.length ?? 0
);
listeners.onThinkingDelta?.(e);
};
socket.on(EVENTS.thinkingDelta, cb);
handlers.push([EVENTS.thinkingDelta, cb]);
}
if (listeners.onToolArgsDelta) {
const cb = (payload: unknown) => {
const e = payload as ChatToolArgsDeltaEvent;
chatLog(
'%s thread_id=%s request_id=%s round=%d tool_call_id=%s chars=%d',
EVENTS.toolArgsDelta,
e.thread_id,
e.request_id,
e.round,
e.tool_call_id,
e.delta?.length ?? 0
);
listeners.onToolArgsDelta?.(e);
};
socket.on(EVENTS.toolArgsDelta, cb);
handlers.push([EVENTS.toolArgsDelta, cb]);
}
if (listeners.onProactiveMessage) {
const cb = (payload: unknown) => {
const e = payload as ProactiveMessageEvent;
chatLog(
'%s thread_id=%s request_id=%s chars=%d',
EVENTS.proactiveMessage,
e.thread_id,
e.request_id,
e.full_response?.length ?? 0
);
listeners.onProactiveMessage?.(e);
};
socket.on(EVENTS.proactiveMessage, cb);
handlers.push([EVENTS.proactiveMessage, cb]);
}
if (listeners.onApprovalRequest) {
const cb = (payload: unknown) => {
const e = payload as ChatApprovalRequestEvent;
chatLog(
'%s thread_id=%s request_id=%s tool=%s',
EVENTS.approvalRequest,
e.thread_id,
e.request_id,
e.tool_name
);
listeners.onApprovalRequest?.(e);
};
socket.on(EVENTS.approvalRequest, cb);
handlers.push([EVENTS.approvalRequest, cb]);
}
if (listeners.onPlanReviewRequest) {
const cb = (payload: unknown) => {
const e = payload as ChatPlanReviewRequestEvent;
chatLog('%s thread_id=%s request_id=%s', EVENTS.planReviewRequest, e.thread_id, e.request_id);
listeners.onPlanReviewRequest?.(e);
};
socket.on(EVENTS.planReviewRequest, cb);
handlers.push([EVENTS.planReviewRequest, cb]);
}
// Artifact lifecycle events (#2779). The Rust subscriber in
// `channels/providers/web::ArtifactSurfaceSubscriber` packs the
// artifact payload into the generic `args` field of the wire
// envelope (kept the WebChannelEvent struct shape stable to avoid
// touching ~10 existing call sites with `..Default::default()`).
// Flatten back into the typed `ArtifactReadyEvent` /
// `ArtifactFailedEvent` shape so listeners get a clean contract.
const validArtifactKinds: ReadonlySet<ArtifactKind> = new Set([
'presentation',
'document',
'image',
'other',
]);
const isValidArtifactKind = (k: unknown): k is ArtifactKind =>
typeof k === 'string' && validArtifactKinds.has(k as ArtifactKind);
// Type-narrowing guards: previously `!args.title` etc. only checked
// truthiness, so a non-string `title` (number, object, true) would
// pass — and then `.slice(0, 80)` on a non-string `error` crashed
// at L833. Type the payload as `unknown` and narrow each field with
// `typeof` so the runtime contract matches the TS contract.
const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const readEnvelope = (
payload: unknown
): { thread_id: string; client_id?: string; args: Record<string, unknown> } | null => {
if (!payload || typeof payload !== 'object') return null;
const env = payload as { thread_id?: unknown; client_id?: unknown; args?: unknown };
if (!isNonEmptyString(env.thread_id)) return null;
const client_id = typeof env.client_id === 'string' ? env.client_id : undefined;
const args =
env.args && typeof env.args === 'object' && !Array.isArray(env.args)
? (env.args as Record<string, unknown>)
: {};
return { thread_id: env.thread_id, client_id, args };
};
if (listeners.onArtifactPending) {
const cb = (payload: unknown) => {
const env = readEnvelope(payload);
if (!env) {
chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.artifactPending);
return;
}
const { args } = env;
// Pending carries no size/path-on-disk yet — only identity + title.
if (
!isNonEmptyString(args.artifact_id) ||
!isValidArtifactKind(args.kind) ||
!isNonEmptyString(args.title) ||
!isNonEmptyString(args.workspace_dir) ||
!isNonEmptyString(args.path)
) {
chatLog(
'%s thread_id=%s — skipping malformed payload (bad args)',
EVENTS.artifactPending,
env.thread_id
);
return;
}
const event: ArtifactPendingEvent = {
thread_id: env.thread_id,
client_id: env.client_id,
artifact_id: args.artifact_id,
kind: args.kind,
title: args.title,
workspace_dir: args.workspace_dir,
path: args.path,
};
chatLog(
'%s thread_id=%s artifact_id=%s kind=%s',
EVENTS.artifactPending,
event.thread_id,
event.artifact_id,
event.kind
);
listeners.onArtifactPending?.(event);
};
socket.on(EVENTS.artifactPending, cb);
handlers.push([EVENTS.artifactPending, cb]);
}
if (listeners.onArtifactReady) {
const cb = (payload: unknown) => {
const env = readEnvelope(payload);
if (!env) {
chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.artifactReady);
return;
}
const { args } = env;
if (
!isNonEmptyString(args.artifact_id) ||
!isValidArtifactKind(args.kind) ||
!isNonEmptyString(args.title) ||
!isNonEmptyString(args.workspace_dir) ||
!isNonEmptyString(args.path) ||
!isFiniteNumber(args.size_bytes)
) {
chatLog(
'%s thread_id=%s — skipping malformed payload (bad args)',
EVENTS.artifactReady,
env.thread_id
);
return;
}
const event: ArtifactReadyEvent = {
thread_id: env.thread_id,
client_id: env.client_id,
artifact_id: args.artifact_id,
kind: args.kind,
title: args.title,
workspace_dir: args.workspace_dir,
path: args.path,
size_bytes: args.size_bytes,
};
chatLog(
'%s thread_id=%s artifact_id=%s kind=%s size=%d',
EVENTS.artifactReady,
event.thread_id,
event.artifact_id,
event.kind,
event.size_bytes
);
listeners.onArtifactReady?.(event);
};
socket.on(EVENTS.artifactReady, cb);
handlers.push([EVENTS.artifactReady, cb]);
}
if (listeners.onArtifactFailed) {
const cb = (payload: unknown) => {
const env = readEnvelope(payload);
if (!env) {
chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.artifactFailed);
return;
}
const { args } = env;
if (
!isNonEmptyString(args.artifact_id) ||
!isValidArtifactKind(args.kind) ||
!isNonEmptyString(args.title) ||
!isNonEmptyString(args.workspace_dir) ||
!isNonEmptyString(args.error)
) {
chatLog(
'%s thread_id=%s — skipping malformed payload (bad args)',
EVENTS.artifactFailed,
env.thread_id
);
return;
}
const event: ArtifactFailedEvent = {
thread_id: env.thread_id,
client_id: env.client_id,
artifact_id: args.artifact_id,
kind: args.kind,
title: args.title,
workspace_dir: args.workspace_dir,
error: args.error,
};
// Defence-in-depth: producer is expected to pre-truncate, but
// cap the log preview again so a leaky producer cannot blast
// unbounded provider stderr into client telemetry. (`event.error`
// is now guaranteed a string by the guard above — no .slice crash.)
chatLog(
'%s thread_id=%s artifact_id=%s kind=%s err=%s',
EVENTS.artifactFailed,
event.thread_id,
event.artifact_id,
event.kind,
event.error.slice(0, 80)
);
listeners.onArtifactFailed?.(event);
};
socket.on(EVENTS.artifactFailed, cb);
handlers.push([EVENTS.artifactFailed, cb]);
}
// External-transfer disclosure (#4437 / S3). Same envelope shape as the
// artifact events — the S2 egress spine packs the `EgressDescriptor` into
// the generic `args` field. Flatten it back into the typed
// `ExternalTransferPendingEvent` after narrowing every field, so a
// malformed / partial payload can never reach the disclosure card.
const isStringArray = (v: unknown): v is string[] =>
Array.isArray(v) && v.every(item => typeof item === 'string');
const validRiskLevels: ReadonlySet<EgressRiskLevel> = new Set([
'unknown',
'none',
'low',
'medium',
'high',
]);
const readRiskLevel = (v: unknown): EgressRiskLevel =>
typeof v === 'string' && validRiskLevels.has(v as EgressRiskLevel)
? (v as EgressRiskLevel)
: 'unknown';
if (listeners.onExternalTransferPending) {
const cb = (payload: unknown) => {
const env = readEnvelope(payload);
if (!env) {
chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.externalTransferPending);
return;
}
const { args } = env;
// `provider_slug`, `service` and `reason` are the load-bearing display
// fields; without them the card can't say what/where/why, so drop the
// event rather than render blanks. `data_kinds` may legitimately be empty
// for a metadata-only transfer, so only require it to be an array.
if (
!isNonEmptyString(args.provider_slug) ||
!isNonEmptyString(args.service) ||
!isNonEmptyString(args.reason) ||
!isStringArray(args.data_kinds)
) {
chatLog(
'%s thread_id=%s — skipping malformed payload (bad args)',
EVENTS.externalTransferPending,
env.thread_id
);
return;
}
const event: ExternalTransferPendingEvent = {
thread_id: env.thread_id,
client_id: env.client_id,
provider_slug: args.provider_slug,
service: args.service,
// Defaults to true — the spine only emits for genuinely-external
// transfers, but tolerate a missing/non-boolean flag.
is_external: typeof args.is_external === 'boolean' ? args.is_external : true,
reason: args.reason as EgressReason,
data_kinds: args.data_kinds as EgressDataKind[],
risk_level: readRiskLevel(args.risk_level),
risk_categories: isStringArray(args.risk_categories) ? args.risk_categories : [],
};
chatLog(
'%s thread_id=%s provider=%s service=%s reason=%s kinds=%d external=%s',
EVENTS.externalTransferPending,
event.thread_id,
event.provider_slug,
event.service,
event.reason,
event.data_kinds.length,
event.is_external
);
listeners.onExternalTransferPending?.(event);
};
socket.on(EVENTS.externalTransferPending, cb);
handlers.push([EVENTS.externalTransferPending, cb]);
}
if (listeners.onTaskBoardUpdated) {
const cb = (payload: unknown) => {
const e = payload as ChatTaskBoardUpdatedEvent;
chatLog(
'%s thread_id=%s request_id=%s cards=%d',
EVENTS.taskBoardUpdated,
e.thread_id,
e.request_id,
e.task_board?.cards?.length ?? 0
);
listeners.onTaskBoardUpdated?.(e);
};
socket.on(EVENTS.taskBoardUpdated, cb);
handlers.push([EVENTS.taskBoardUpdated, cb]);
}
if (listeners.onDone) {
const cb = (payload: unknown) => {
const e = payload as ChatDoneEvent;
chatLog('%s thread_id=%s request_id=%s', EVENTS.done, e.thread_id, e.request_id);
listeners.onDone?.(e);
};
socket.on(EVENTS.done, cb);
handlers.push([EVENTS.done, cb]);
}
if (listeners.onError) {
const cb = (payload: unknown) => {
const e = payload as ChatErrorEvent;
chatLog(
'%s thread_id=%s request_id=%s error_type=%s',
EVENTS.error,
e.thread_id,
e.request_id,
e.error_type
);
listeners.onError?.(e);
};
socket.on(EVENTS.error, cb);
handlers.push([EVENTS.error, cb]);
}
return () => {
for (const [eventName, handler] of handlers) {
socket.off(eventName, handler);
}
};
}
export type QueueMode = 'interrupt' | 'steer' | 'followup' | 'collect' | 'parallel';
export interface ChatSendParams {
threadId: string;
message: string;
model?: string;
profileId?: string | null;
/**
* BCP-47 UI locale (e.g. `'ar'`, `'zh-CN'`) — drives the core's
* "reply in this language" system-prompt directive. Optional so
* callers that don't have a locale handy (legacy paths, tests) keep
* working unchanged.
*/
locale?: string | null;
/**
* When `true`, the core will synthesize the agent reply via TTS and
* stream audio back (push-to-talk reply flow).
*/
speakReply?: boolean;
/**
* Originating input source — e.g. `'ptt'` for push-to-talk, `'keyboard'`
* for typed input. Forwarded to the core for analytics / routing.
*/
source?: string;
/**
* PTT session ID — ties the chat turn to a specific push-to-talk recording
* session so the core can correlate audio and text events.
*/
sessionId?: number;
/**
* Queue mode for concurrent messages. When a turn is already in
* flight: `steer` injects at the next iteration boundary, `followup`
* queues for after the turn, `collect` adds as context. `interrupt`
* (default) aborts the running turn.
*/
queueMode?: QueueMode | null;
}
/**
* Send a chat message via core RPC.
*
* The Rust core spawns the agent loop asynchronously and streams events
* (tool_call, tool_result, chat_done, chat_error) back over the socket
* connection using the `client_id` (socket ID) for routing.
*
* Returns the turn's `request_id` (from the RPC ack) when the core provides
* one — used by `parallel` sends to register the forked turn's stream lane.
* `undefined` if the ack carried no id.
*/
export async function chatSend(params: ChatSendParams): Promise<string | undefined> {
const socket = socketService.getSocket();
const clientId = socket?.id;
if (!clientId) {
throw new Error('Socket not connected — no client ID for event routing');
}
const result = await callCoreRpc({
method: 'openhuman.channel_web_chat',
params: {
client_id: clientId,
thread_id: params.threadId,
message: params.message,
model_override: params.model ?? undefined,
profile_id: params.profileId ?? undefined,
locale: params.locale ?? undefined,
speak_reply: params.speakReply ?? undefined,
source: params.source ?? undefined,
session_id: params.sessionId ?? undefined,
queue_mode: params.queueMode ?? undefined,
},
});
const requestId = (result as { request_id?: unknown } | null)?.request_id;
return typeof requestId === 'string' ? requestId : undefined;
}
/**
* Cancel an in-flight chat request via core RPC.
*/
export async function chatCancel(threadId: string): Promise<boolean> {
const socket = socketService.getSocket();
const clientId = socket?.id;
if (!clientId) return false;
try {
await callCoreRpc({
method: 'openhuman.channel_web_cancel',
params: { client_id: clientId, thread_id: threadId },
});
return true;
} catch {
return false;
}
}
/**
* Clear the run-queue (steer/followup/collect lanes) for a thread via core RPC.
* Used when the user dismisses queued follow-ups so the backend drops them
* instead of dispatching them after the current turn. Returns the number of
* dropped messages on success, or `null` when the RPC fails — the caller must
* distinguish these: on failure the backend queue is still intact and WILL
* dispatch the follow-ups, so the UI must keep the pills rather than hide them.
*/
export async function chatClearQueue(threadId: string): Promise<number | null> {
try {
const res = await callCoreRpc<{ dropped?: number }>({
method: 'openhuman.channel_web_queue_clear',
params: { thread_id: threadId },
});
return res?.dropped ?? 0;
} catch {
return null;
}
}
/**
* Re-dispatch the producing tool for a failed artifact, reusing the same
* artifact id so the card swaps in place (#3162). Drives the failed-card
* Retry button: the core reloads the persisted creation args and re-runs
* the producer, routing the fresh pending/ready/failed events back to
* this thread + socket. Returns `true` when the RPC was accepted; the
* card's live state is then driven by the socket events, not this call.
*/
export async function aiRegenerate(artifactId: string, threadId: string): Promise<boolean> {
const socket = socketService.getSocket();
const clientId = socket?.id;
if (!clientId) {
throw new Error('Socket not connected — no client ID for event routing');
}
await callCoreRpc({
method: 'openhuman.ai_regenerate',
params: { artifact_id: artifactId, thread_id: threadId, client_id: clientId },
});
return true;
}
export function useRustChat(): boolean {
// Legacy name kept for compatibility with existing call sites.
return true;
}