966 lines
39 KiB
TypeScript
966 lines
39 KiB
TypeScript
import type { UIMessage } from "@ai-sdk/react";
|
|
import { ChatSnapshotV1Schema, SSEStreamSubscription } from "@trigger.dev/core/v3";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Paragraph } from "~/components/primitives/Paragraph";
|
|
import { Spinner } from "~/components/primitives/Spinner";
|
|
import { AgentMessageView } from "~/components/runs/v3/agent/AgentMessageView";
|
|
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
|
|
export type AgentViewAuth = {
|
|
publicAccessToken: string;
|
|
apiOrigin: string;
|
|
/**
|
|
* Session identifier the AgentView uses to address the backing
|
|
* {@link Session} when subscribing to `.in` / `.out`. Accepts either
|
|
* a `session_*` friendlyId or the transport-supplied externalId
|
|
* (typically the browser's `chatId`) — the dashboard resource route
|
|
* resolves either form via `resolveSessionByIdOrExternalId`.
|
|
*/
|
|
sessionId: string;
|
|
/**
|
|
* User messages extracted from the run's task payload at load time.
|
|
* Empty array for runs started with `trigger: "preload"` — in that
|
|
* case the first user message arrives over the session's `.in`
|
|
* channel and is merged in by the AgentView subscription.
|
|
*/
|
|
initialMessages: UIMessage[];
|
|
/**
|
|
* Presigned GET URL for the session's chat-snapshot S3 blob (written
|
|
* by the agent after each turn-complete; see `ChatSnapshotV1`).
|
|
* Optional — sessions that registered a `hydrateMessages` hook skip
|
|
* snapshot writes and the URL fetch will 404. In that case the
|
|
* dashboard falls back to seq=0 SSE (which, post-trim, shows only the
|
|
* most recent turn). Generated server-side by `SessionPresenter`.
|
|
*/
|
|
snapshotPresignedUrl?: string;
|
|
};
|
|
|
|
/**
|
|
* Max state-update interval while assistant chunks are streaming. Matches
|
|
* the `experimental_throttle: 100` we previously passed to `useChat`.
|
|
* Chunks mutate a staging ref synchronously; a throttled flush copies the
|
|
* ref into React state at most ~10x/sec so tool-call Prism highlighting
|
|
* etc. doesn't re-run on every single text-delta.
|
|
*/
|
|
const STATE_FLUSH_THROTTLE_MS = 100;
|
|
|
|
/**
|
|
* Sentinel timestamp for messages that came from the run's initial task
|
|
* payload — they predate any stream activity, so 0 guarantees they sort
|
|
* first regardless of stream race order.
|
|
*/
|
|
const INITIAL_PAYLOAD_TIMESTAMP = 0;
|
|
|
|
/**
|
|
* Renders a Session's chat conversation as it unfolds.
|
|
*
|
|
* Subscribes to both channels of the {@link Session}:
|
|
* - **`.out`** delivers assistant `UIMessageChunk`s (text deltas, tool
|
|
* calls, reasoning, etc.) produced by the agent's
|
|
* `chatStream.writer(...)` calls — objects, already parsed by the S2
|
|
* SSE reader.
|
|
* - **`.in`** delivers {@link ChatInputChunk}s sent by
|
|
* {@link TriggerChatTransport} (or any other session writer). Each
|
|
* chunk is a tagged union (`{kind: "message", payload}` for user
|
|
* turns, `{kind: "stop"}` for stop signals) — the AgentView only
|
|
* cares about `kind: "message"` and pulls `.payload.messages`.
|
|
*
|
|
* Both streams are read directly via `SSEStreamSubscription` through the
|
|
* dashboard's session-authed resource routes — not through `useChat` or
|
|
* `TriggerChatTransport`. This gives us per-chunk server-side timestamps
|
|
* (S2 sequence numbers) from both streams, which we use to produce a
|
|
* chronologically correct merged message list that works for replays,
|
|
* multi-message turns, cross-run session resumes, and steering messages.
|
|
*
|
|
* Intended to be mounted inside a scrollable container — the component
|
|
* does not own its own scrollbar.
|
|
*/
|
|
export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
|
|
const messages = useAgentSessionMessages({
|
|
sessionId: agentView.sessionId,
|
|
apiOrigin: agentView.apiOrigin,
|
|
orgSlug: organization.slug,
|
|
projectSlug: project.slug,
|
|
envSlug: environment.slug,
|
|
initialMessages: agentView.initialMessages,
|
|
snapshotPresignedUrl: agentView.snapshotPresignedUrl,
|
|
});
|
|
|
|
// Sticky-bottom auto-scroll: walks up to find the inspector's scroll
|
|
// container, then scrolls to bottom whenever `messages` changes — but
|
|
// only if the user was at (or near) the bottom at the time. Scrolling
|
|
// away pauses auto-scroll; scrolling back resumes it.
|
|
const rootRef = useAutoScrollToBottom([messages]);
|
|
|
|
return (
|
|
<div ref={rootRef} className="flex min-h-full flex-col py-3">
|
|
{messages.length === 0 ? (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<Spinner className="size-5" color="blue" />
|
|
<Paragraph variant="small" className="text-text-dimmed">
|
|
Loading conversation…
|
|
</Paragraph>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<AgentMessageView messages={messages} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// useAgentSessionMessages — reads both realtime streams for a session and
|
|
// maintains a chronologically ordered, merged message list.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Shape of each chunk on the session's `.in` channel. Mirrors the
|
|
* `ChatInputChunk` tagged union produced by {@link TriggerChatTransport}:
|
|
* - `kind: "message"` carries a `ChatTaskWirePayload` in `.payload`
|
|
* (user-submitted messages or regenerate calls); we dedupe by id.
|
|
* - `kind: "stop"` is a stop signal — no messages, nothing to render
|
|
* here, so it's filtered.
|
|
*
|
|
* Wire payloads are slim-wire (one new UIMessage per record, on
|
|
* `payload.message`). The legacy `payload.messages` array shape is kept
|
|
* here as a fallback so any historical records on a long-lived session
|
|
* still render.
|
|
*
|
|
* The server wraps records in `{data, id}` and writes `data` as a JSON
|
|
* string; SSE v2 delivers the parsed string back. {@link parseChunkPayload}
|
|
* re-parses to recover the object.
|
|
*/
|
|
type InputStreamChunk = {
|
|
kind?: "message" | "stop";
|
|
payload?: {
|
|
message?: { id?: string; role?: string; parts?: unknown[] };
|
|
messages?: Array<{ id?: string; role?: string; parts?: unknown[] }>;
|
|
trigger?: string;
|
|
};
|
|
message?: string;
|
|
};
|
|
|
|
/**
|
|
* Minimal typing for the chunks we care about on the chat output stream.
|
|
* Covers the AI SDK `UIMessageChunk` variants that `renderPart` actually
|
|
* knows how to display, plus the Trigger.dev control chunks that we filter.
|
|
*/
|
|
type OutputChunk = { type: string; [key: string]: unknown };
|
|
|
|
/**
|
|
* Per-message orchestration state for the output stream accumulator. Mirrors
|
|
* the active-part tracking that AI SDK's `processUIMessageStream` keeps in
|
|
* its `state` object: a registry of streaming text/reasoning parts so deltas
|
|
* can be matched to the right part by id, plus a way to clear them at step
|
|
* boundaries (`finish-step`) so the next step's `text-start`/`reasoning-start`
|
|
* with the same id starts a fresh part instead of appending to the previous
|
|
* step's part.
|
|
*/
|
|
/**
|
|
* Per-message orchestration state — index-based active-part tracking.
|
|
*
|
|
* Each map points from a part id (text or reasoning) to **the index of the
|
|
* currently-streaming part with that id in `message.parts`**. We need
|
|
* indexes (not just a `Set` of "active ids") because part ids are *only
|
|
* unique within a step*: the SDK happily reuses `text-start id="0"` after
|
|
* a `finish-step` boundary. Without index tracking, a `text-delta` for the
|
|
* reused id would have to find the right part by id alone — and a search
|
|
* would match BOTH the previous step's frozen part and the current step's
|
|
* fresh one, which produces a duplication where the previous text gets
|
|
* the new content appended to it AND a fresh part with the same content
|
|
* also appears.
|
|
*
|
|
* Mirrors AI SDK's `processUIMessageStream`'s `state.activeTextParts` /
|
|
* `state.activeReasoningParts` (which hold direct references in the
|
|
* mutating canonical impl). We use indexes here because we do immutable
|
|
* updates and need indices that survive `parts.map()` rewrites — adding
|
|
* new parts and updating existing ones never reorders, so an index is
|
|
* stable for the lifetime of the part.
|
|
*/
|
|
type MessageOrchestrationState = {
|
|
activeTextPartIndexes: Map<string, number>;
|
|
activeReasoningPartIndexes: Map<string, number>;
|
|
};
|
|
|
|
/**
|
|
* `SSEStreamSubscription`'s v2 batch path delivers `parsedBody.data` as-is
|
|
* — but session channels diverge by direction:
|
|
*
|
|
* - `.in`: {@link TriggerChatTransport.serializeInputChunk} writes the
|
|
* `ChatInputChunk` as a JSON **string**, so `data` is a string that
|
|
* needs a second `JSON.parse` to recover the tagged union.
|
|
* - `.out`: the agent's `chatStream.writer(...)` writes
|
|
* {@link UIMessageChunk} **objects** directly; `data` arrives
|
|
* already-parsed.
|
|
*
|
|
* This helper accepts both shapes defensively: a string is parsed; an
|
|
* object is returned as-is. Returns `null` for unparseable payloads.
|
|
*/
|
|
function parseChunkPayload(raw: unknown): Record<string, unknown> | null {
|
|
if (raw == null) return null;
|
|
if (typeof raw === "string") {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (typeof raw === "object") return raw as Record<string, unknown>;
|
|
return null;
|
|
}
|
|
|
|
function createOrchestrationState(): MessageOrchestrationState {
|
|
return {
|
|
activeTextPartIndexes: new Map(),
|
|
activeReasoningPartIndexes: new Map(),
|
|
};
|
|
}
|
|
|
|
function useAgentSessionMessages({
|
|
sessionId,
|
|
apiOrigin,
|
|
orgSlug,
|
|
projectSlug,
|
|
envSlug,
|
|
initialMessages,
|
|
snapshotPresignedUrl,
|
|
}: {
|
|
sessionId: string;
|
|
apiOrigin: string;
|
|
orgSlug: string;
|
|
projectSlug: string;
|
|
envSlug: string;
|
|
initialMessages: UIMessage[];
|
|
snapshotPresignedUrl?: string;
|
|
}): UIMessage[] {
|
|
// Seed with the user messages from the run's task payload.
|
|
const seedMessages = useMemo(
|
|
() => initialMessages.filter((m) => m.role === "user"),
|
|
[initialMessages]
|
|
);
|
|
|
|
// The snapshot URL is re-signed by the loader on every navigation
|
|
// (tab switches in the inspector pane re-run the session loader),
|
|
// which would otherwise re-trigger the subscription effect below
|
|
// and replay post-snapshot `.out` chunks on top of the messages we
|
|
// already accumulated — duplicating any assistant content that
|
|
// lives past `snapshot.lastOutEventId` (e.g., a canceled run whose
|
|
// turn never completed). Hold the URL behind a ref and keep it
|
|
// out of the effect's deps so the effect runs exactly once per
|
|
// mount.
|
|
const snapshotUrlRef = useRef(snapshotPresignedUrl);
|
|
useEffect(() => {
|
|
snapshotUrlRef.current = snapshotPresignedUrl;
|
|
}, [snapshotPresignedUrl]);
|
|
|
|
// `pendingRef` is the authoritative, eagerly-updated message state:
|
|
// chunks mutate this synchronously as they arrive. A throttled flush
|
|
// copies it into React state so UI updates are capped at ~10x/sec.
|
|
const pendingRef = useRef<Map<string, UIMessage>>(new Map(seedMessages.map((m) => [m.id, m])));
|
|
const timestampsRef = useRef<Map<string, number>>(
|
|
new Map(seedMessages.map((m) => [m.id, INITIAL_PAYLOAD_TIMESTAMP]))
|
|
);
|
|
// Side-table of orchestration state, keyed by assistant message id. Lives
|
|
// outside the UIMessage so React doesn't see it as a renderable prop.
|
|
const orchestrationRef = useRef<Map<string, MessageOrchestrationState>>(new Map());
|
|
|
|
// Buffered HITL resolutions keyed by toolCallId. `addToolApprovalResponse` /
|
|
// `addToolOutput` send a slim assistant message on the `.in` channel carrying
|
|
// just the resolved tool part; the agent never echoes these on `.out`. We
|
|
// stash them here and overlay onto the matching tool part once it exists, so
|
|
// a denial/approval lands regardless of which stream arrives first.
|
|
const pendingResolutionsRef = useRef<Map<string, Record<string, unknown>>>(new Map());
|
|
|
|
// React state snapshot of pendingRef. Only updated via the throttled
|
|
// `scheduleFlush`. The Map *reference* changes on every flush so React
|
|
// detects the state update and the downstream `useMemo` recomputes.
|
|
const [messagesById, setMessagesById] = useState<Map<string, UIMessage>>(
|
|
() => new Map(pendingRef.current)
|
|
);
|
|
|
|
// Throttled flush scheduler — leading edge within a single throttle
|
|
// window: the first chunk after a quiet period flushes immediately, then
|
|
// subsequent chunks coalesce until the next window opens.
|
|
const lastFlushAtRef = useRef<number>(0);
|
|
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const scheduleFlush = useRef<() => void>(() => {});
|
|
scheduleFlush.current = () => {
|
|
if (pendingTimerRef.current !== null) return; // already scheduled
|
|
const now = Date.now();
|
|
const sinceLast = now - lastFlushAtRef.current;
|
|
const delay = Math.max(0, STATE_FLUSH_THROTTLE_MS - sinceLast);
|
|
pendingTimerRef.current = setTimeout(() => {
|
|
pendingTimerRef.current = null;
|
|
lastFlushAtRef.current = Date.now();
|
|
setMessagesById(new Map(pendingRef.current));
|
|
}, delay);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const abort = new AbortController();
|
|
|
|
// Overlay a buffered HITL resolution (approval/output delivered on `.in`)
|
|
// onto the matching tool part. Returns true if a part changed. Safe to call
|
|
// repeatedly — after each `.out` tool chunk and whenever a `.in` resolution
|
|
// arrives — so the resolution lands regardless of cross-stream ordering.
|
|
// Never downgrades a part that already reached a terminal output state (so
|
|
// an approved-then-executed tool keeps its `output-available` + output).
|
|
const applyToolResolution = (toolCallId: string): boolean => {
|
|
const res = pendingResolutionsRef.current.get(toolCallId);
|
|
if (!res) return false;
|
|
for (const [mid, msg] of pendingRef.current) {
|
|
const parts = (msg.parts ?? []) as Array<Record<string, unknown>>;
|
|
const idx = parts.findIndex(
|
|
(p) => (p as { toolCallId?: string }).toolCallId === toolCallId
|
|
);
|
|
if (idx < 0) continue;
|
|
const cur = parts[idx]!;
|
|
const terminal =
|
|
cur.state === "output-available" ||
|
|
cur.state === "output-error" ||
|
|
cur.state === "output-denied";
|
|
const nextState = res.state != null && !terminal ? res.state : cur.state;
|
|
const sameApproval = JSON.stringify(cur.approval) === JSON.stringify(res.approval);
|
|
if (
|
|
nextState === cur.state &&
|
|
sameApproval &&
|
|
res.output === undefined &&
|
|
res.errorText === undefined
|
|
) {
|
|
// Already applied. If the part has reached a terminal state, drop
|
|
// the buffered resolution so the Map doesn't grow unbounded on
|
|
// long-lived sessions with many tool calls.
|
|
if (terminal) pendingResolutionsRef.current.delete(toolCallId);
|
|
return false;
|
|
}
|
|
const next = parts.slice();
|
|
next[idx] = {
|
|
...cur,
|
|
...(res.approval != null ? { approval: res.approval } : {}),
|
|
...(res.output !== undefined ? { output: res.output } : {}),
|
|
...(res.errorText !== undefined ? { errorText: res.errorText } : {}),
|
|
state: nextState,
|
|
};
|
|
pendingRef.current.set(mid, { ...msg, parts: next } as UIMessage);
|
|
// Drop the buffered entry once the part has landed at a terminal
|
|
// state — no future `.out` chunk will need this resolution.
|
|
const reachedTerminal =
|
|
nextState === "output-available" ||
|
|
nextState === "output-error" ||
|
|
nextState === "output-denied";
|
|
if (reachedTerminal) pendingResolutionsRef.current.delete(toolCallId);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const encodedSession = encodeURIComponent(sessionId);
|
|
// Always use the page's own origin to avoid CORS preflight failures
|
|
// when the configured `apiOrigin` (e.g. `localhost`) differs from the
|
|
// origin the dashboard was loaded from (e.g. `127.0.0.1`). The dashboard
|
|
// resource route is same-origin by construction.
|
|
const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
|
|
const sessionBase =
|
|
`${origin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
|
|
`/sessions/${encodedSession}/realtime/v1`;
|
|
|
|
const outputUrl = `${sessionBase}/out`;
|
|
const inputUrl = `${sessionBase}/in`;
|
|
|
|
/**
|
|
* Try to seed `pendingRef` from the agent's S3 snapshot blob and return
|
|
* the snapshot's `lastOutEventId` so the `.out` SSE subscription resumes
|
|
* just past the snapshot. Returns undefined for sessions that don't
|
|
* have a snapshot (e.g. `hydrateMessages` customers, or sessions that
|
|
* have never completed a turn).
|
|
*/
|
|
const loadSnapshot = async (): Promise<string | undefined> => {
|
|
const url = snapshotUrlRef.current;
|
|
if (!url) return undefined;
|
|
try {
|
|
const resp = await fetch(url, { signal: abort.signal });
|
|
if (!resp.ok) return undefined;
|
|
const json = (await resp.json()) as unknown;
|
|
const parsed = ChatSnapshotV1Schema.safeParse(json);
|
|
if (!parsed.success) return undefined;
|
|
const snapshot = parsed.data;
|
|
// Preserve the snapshot's array order in the final render by
|
|
// giving each message a unique, monotonically increasing
|
|
// timestamp from `(savedAt - count + index)`. Real chunk
|
|
// timestamps from the SSE path use S2 arrival ms (positive
|
|
// numbers in the present), so anything below `savedAt` sorts
|
|
// before live chunks while preserving snapshot order among
|
|
// themselves.
|
|
const count = snapshot.messages.length;
|
|
snapshot.messages.forEach((raw, i) => {
|
|
const message = raw as UIMessage;
|
|
if (!message?.id) return;
|
|
// The snapshot's seed wins over the task-payload seed for any
|
|
// overlapping ids (the snapshot represents the agent's
|
|
// canonical accumulator, post-turn).
|
|
pendingRef.current.set(message.id, message);
|
|
if (!timestampsRef.current.has(message.id)) {
|
|
timestampsRef.current.set(message.id, snapshot.savedAt - count + i);
|
|
}
|
|
});
|
|
scheduleFlush.current();
|
|
return snapshot.lastOutEventId;
|
|
} catch {
|
|
// 404 / network / parse / abort — fall back to seq=0 SSE
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const outputSubOptions = (lastEventId: string | undefined) =>
|
|
({
|
|
signal: abort.signal,
|
|
timeoutInSeconds: 120,
|
|
...(lastEventId !== undefined ? { lastEventId } : {}),
|
|
}) as const;
|
|
|
|
const commonSubOptions = {
|
|
signal: abort.signal,
|
|
timeoutInSeconds: 120,
|
|
} as const;
|
|
|
|
// ---- Output stream: assistant messages ---------------------------------
|
|
//
|
|
// The output stream delivers data records (UIMessageChunks) interleaved
|
|
// with Trigger control records (`turn-complete`, `upgrade-required`) and
|
|
// S2 command records (`trim`). Control + command records ride on
|
|
// `record.headers` with empty bodies; the SSE parser strips S2 command
|
|
// records entirely, and control records arrive with `value.chunk ===
|
|
// undefined`, which `parseChunkPayload` drops below.
|
|
//
|
|
// We fold everything else into an assistant `UIMessage` via our own
|
|
// `applyOutputChunk` accumulator — the AI SDK's `readUIMessageStream`
|
|
// helper is only available in `ai@6`, and the webapp is pinned to
|
|
// `ai@4`, so we re-implement just the chunk types that `renderPart`
|
|
// actually displays.
|
|
//
|
|
// We capture the **server timestamp of each assistant message's first
|
|
// `start` chunk** so later sort-by-timestamp merges with the input
|
|
// stream correctly.
|
|
const runOutput = async () => {
|
|
try {
|
|
// Seed messages from the snapshot first (if available), then
|
|
// resume the SSE from the snapshot's last event id so we don't
|
|
// re-stream chunks already represented in the snapshot. If no
|
|
// snapshot exists (no URL, 404, parse failure), the SSE opens
|
|
// at seq=0 — which, post-trim, contains roughly one turn of
|
|
// records (acceptable fallback for `hydrateMessages` sessions
|
|
// and fresh sessions).
|
|
const snapshotLastEventId = await loadSnapshot();
|
|
if (abort.signal.aborted) return;
|
|
|
|
const sub = new SSEStreamSubscription(outputUrl, outputSubOptions(snapshotLastEventId));
|
|
const raw = await sub.subscribe();
|
|
const reader = raw.getReader();
|
|
|
|
let currentMessageId: string | null = null;
|
|
|
|
try {
|
|
while (!abort.signal.aborted) {
|
|
const { done, value } = await reader.read();
|
|
if (done) return;
|
|
|
|
const chunk = parseChunkPayload(value.chunk) as OutputChunk | null;
|
|
if (!chunk || typeof chunk.type !== "string") continue;
|
|
// Legacy belt-and-suspenders: prior versions of the SDK
|
|
// emitted `trigger:turn-complete` / `trigger:upgrade-required`
|
|
// as data records (`type` field). Current versions use
|
|
// header-form control records, which `parseChunkPayload`
|
|
// drops above. Keep this filter to handle any in-flight
|
|
// sessions whose `.out` was populated by the older SDK.
|
|
if (chunk.type.startsWith("trigger:")) continue;
|
|
|
|
if (chunk.type === "start") {
|
|
const messageId =
|
|
typeof chunk.messageId === "string" && chunk.messageId.length > 0
|
|
? chunk.messageId
|
|
: `asst-${crypto.randomUUID()}`;
|
|
currentMessageId = messageId;
|
|
|
|
if (!timestampsRef.current.has(messageId)) {
|
|
timestampsRef.current.set(messageId, value.timestamp);
|
|
}
|
|
|
|
const existing = pendingRef.current.get(messageId);
|
|
if (existing) {
|
|
// Same message id seen again — merge metadata only, keep
|
|
// existing parts (canonical `processUIMessageStream` does
|
|
// the same on a repeated `start`).
|
|
if (chunk.messageMetadata != null) {
|
|
pendingRef.current.set(messageId, {
|
|
...existing,
|
|
metadata: {
|
|
...((existing as { metadata?: Record<string, unknown> }).metadata ?? {}),
|
|
...(chunk.messageMetadata as Record<string, unknown>),
|
|
},
|
|
} as UIMessage);
|
|
scheduleFlush.current();
|
|
}
|
|
} else {
|
|
const message: UIMessage = {
|
|
id: messageId,
|
|
role: "assistant",
|
|
parts: [],
|
|
...(chunk.messageMetadata != null
|
|
? { metadata: chunk.messageMetadata as UIMessage["metadata"] }
|
|
: {}),
|
|
} as UIMessage;
|
|
pendingRef.current.set(messageId, message);
|
|
orchestrationRef.current.set(messageId, createOrchestrationState());
|
|
scheduleFlush.current();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (currentMessageId === null) continue;
|
|
const existing = pendingRef.current.get(currentMessageId);
|
|
if (!existing) continue;
|
|
let orchestration = orchestrationRef.current.get(currentMessageId);
|
|
if (!orchestration) {
|
|
// Defensive: a chunk arrived for a message we never saw a
|
|
// `start` for. Lazily create orchestration state so we can
|
|
// still display the parts.
|
|
orchestration = createOrchestrationState();
|
|
orchestrationRef.current.set(currentMessageId, orchestration);
|
|
}
|
|
|
|
const updated = applyOutputChunk(existing, chunk, orchestration);
|
|
if (updated !== existing) {
|
|
pendingRef.current.set(currentMessageId, updated);
|
|
scheduleFlush.current();
|
|
}
|
|
|
|
// A `.out` chunk just established/updated a tool part — (re)apply any
|
|
// buffered `.in` resolution for it. Covers the `.in`-before-`.out`
|
|
// order and corrects a `.out` chunk that downgraded the state (e.g.
|
|
// a replayed `tool-approval-request` arriving after the denial).
|
|
const outToolCallId = (chunk as { toolCallId?: string }).toolCallId;
|
|
if (typeof outToolCallId === "string" && applyToolResolution(outToolCallId)) {
|
|
scheduleFlush.current();
|
|
}
|
|
}
|
|
} finally {
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
// Lock may already be released.
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (abort.signal.aborted) return;
|
|
// eslint-disable-next-line no-console
|
|
console.debug("[AgentView] output stream subscription failed", err);
|
|
}
|
|
};
|
|
|
|
// ---- Input channel: user messages (`ChatInputChunk`) -------------------
|
|
//
|
|
// The transport appends a `{kind: "message", payload}` ChatInputChunk
|
|
// for every user turn (and `{kind: "stop"}` for stop signals). We pull
|
|
// user messages out of `payload.messages` for `kind: "message"` chunks
|
|
// and ignore the rest.
|
|
const runInput = async () => {
|
|
try {
|
|
const sub = new SSEStreamSubscription(inputUrl, commonSubOptions);
|
|
const raw = await sub.subscribe();
|
|
const reader = raw.getReader();
|
|
try {
|
|
while (!abort.signal.aborted) {
|
|
const { done, value } = await reader.read();
|
|
if (done) return;
|
|
|
|
const chunk = parseChunkPayload(value.chunk) as InputStreamChunk | null;
|
|
if (!chunk || chunk.kind !== "message") continue;
|
|
const payload = chunk.payload;
|
|
if (!payload) continue;
|
|
|
|
// Slim-wire is one UIMessage on `payload.message`; legacy
|
|
// payloads carried an array on `payload.messages`. Accept
|
|
// either so historical records on a long-lived session still
|
|
// render.
|
|
const candidates = Array.isArray(payload.messages)
|
|
? payload.messages
|
|
: payload.message
|
|
? [payload.message]
|
|
: [];
|
|
|
|
let changed = false;
|
|
|
|
// New user turns — merge in (dedupe by id).
|
|
for (const m of candidates) {
|
|
if (
|
|
m == null ||
|
|
(m as { role?: string }).role !== "user" ||
|
|
typeof m.id !== "string"
|
|
) {
|
|
continue;
|
|
}
|
|
if (pendingRef.current.has(m.id)) continue;
|
|
pendingRef.current.set(m.id, m as UIMessage);
|
|
timestampsRef.current.set(m.id, value.timestamp);
|
|
changed = true;
|
|
}
|
|
|
|
// HITL resolutions ride on `.in` as a slim *assistant* message
|
|
// carrying just the resolved tool part (state + approval/output).
|
|
// Buffer each by toolCallId and overlay onto the matching tool part
|
|
// (which usually arrived on `.out` as `tool-approval-request`).
|
|
for (const m of candidates) {
|
|
if (m == null || (m as { role?: string }).role !== "assistant") continue;
|
|
const parts = (m as { parts?: unknown[] }).parts;
|
|
if (!Array.isArray(parts)) continue;
|
|
for (const sp of parts) {
|
|
const part = sp as Record<string, unknown>;
|
|
if (typeof part.type !== "string" || !part.type.startsWith("tool-")) continue;
|
|
const tcId = (part as { toolCallId?: string }).toolCallId;
|
|
if (typeof tcId !== "string") continue;
|
|
pendingResolutionsRef.current.set(tcId, part);
|
|
if (applyToolResolution(tcId)) changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) scheduleFlush.current();
|
|
}
|
|
} finally {
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
// Lock may already be released.
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (abort.signal.aborted) return;
|
|
// eslint-disable-next-line no-console
|
|
console.debug("[AgentView] input stream subscription failed", err);
|
|
}
|
|
};
|
|
|
|
void runOutput();
|
|
void runInput();
|
|
|
|
return () => {
|
|
abort.abort();
|
|
if (pendingTimerRef.current !== null) {
|
|
clearTimeout(pendingTimerRef.current);
|
|
pendingTimerRef.current = null;
|
|
}
|
|
};
|
|
// `snapshotPresignedUrl` is intentionally NOT in this dep list — see
|
|
// `snapshotUrlRef` above for the reasoning. Including it caused the
|
|
// subscription to tear down + replay on every inspector tab click,
|
|
// which appended duplicate parts to any assistant message whose
|
|
// chunks lived past `snapshot.lastOutEventId`.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
|
|
|
|
return useMemo(() => {
|
|
const timestamps = timestampsRef.current;
|
|
const arr = Array.from(messagesById.values());
|
|
arr.sort((a, b) => {
|
|
const ta = timestamps.get(a.id) ?? 0;
|
|
const tb = timestamps.get(b.id) ?? 0;
|
|
if (ta !== tb) return ta - tb;
|
|
// Tie-breaker for messages sharing a stream ID bucket (rare): fall
|
|
// back to message id string order so the output is deterministic.
|
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
});
|
|
return arr;
|
|
}, [messagesById]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// applyOutputChunk — minimal UIMessageChunk → UIMessage accumulator.
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// A pared-down re-implementation of AI SDK's `processUIMessageStream` (in
|
|
// `ai@6`'s `index.mjs`). The webapp is pinned to `ai@4`, which doesn't ship
|
|
// the v5+ chunk-stream helpers, so we vendor the bits we actually use.
|
|
//
|
|
// Scope vs. canonical:
|
|
// - We render only the chunk shapes that `AgentMessageView`/`renderPart`
|
|
// actually display: text, reasoning, tool-* (input-{start,delta,available}
|
|
// + output-{available,error}), source-url, source-document, file,
|
|
// step-start/finish-step, data-*, plus metadata/finish lifecycle.
|
|
// - Unknown chunk types fall through as no-ops — defensive on purpose for a
|
|
// read-only viewer.
|
|
// - We **do not parse partial JSON for streaming tool inputs.** Canonical
|
|
// uses `parsePartialJson` (which depends on a 300-line `fixJson` state
|
|
// machine to repair incomplete JSON) so users see the input growing
|
|
// character-by-character. We skip it: tool inputs stay `undefined`
|
|
// throughout streaming and snap to the final value when
|
|
// `tool-input-available` lands. Acceptable for a viewer; can be added
|
|
// later by vendoring `fixJson` if the UX warrants it.
|
|
//
|
|
// `orchestration` carries per-message active-part trackers that mirror
|
|
// canonical's `state.activeTextParts` / `state.activeReasoningParts`. They
|
|
// let `text-delta` find the right text part by id and let `finish-step`
|
|
// clear them so a new step can re-use the same id without colliding.
|
|
//
|
|
// Returns the same object reference when nothing changes so the caller can
|
|
// skip unnecessary state flushes + React re-renders.
|
|
|
|
type AnyPart = { [key: string]: unknown; type: string };
|
|
|
|
function applyOutputChunk(
|
|
msg: UIMessage,
|
|
chunk: OutputChunk,
|
|
orchestration: MessageOrchestrationState
|
|
): UIMessage {
|
|
const type = chunk.type;
|
|
|
|
// Text parts ---------------------------------------------------------------
|
|
//
|
|
// Track each streaming text part by its index in `msg.parts`. Part ids
|
|
// are only unique *within a step* — the SDK happily reuses `text-start
|
|
// id="0"` after a `finish-step` boundary — so a delta arriving for a
|
|
// reused id needs to land on the *current* part, not every prior part
|
|
// that ever shared that id. The index map gives us O(1) "which slot is
|
|
// currently streaming this id" without any id-based search.
|
|
if (type === "text-start") {
|
|
const id = chunk.id as string;
|
|
const newIndex = (msg.parts ?? []).length; // index AFTER push
|
|
orchestration.activeTextPartIndexes.set(id, newIndex);
|
|
return withNewPart(msg, {
|
|
type: "text",
|
|
id,
|
|
text: "",
|
|
state: "streaming",
|
|
});
|
|
}
|
|
if (type === "text-delta") {
|
|
const id = chunk.id as string;
|
|
const index = orchestration.activeTextPartIndexes.get(id);
|
|
if (index === undefined) return msg; // delta with no start — drop.
|
|
return updatePartAt(msg, index, (p) => ({
|
|
...p,
|
|
text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""),
|
|
}));
|
|
}
|
|
if (type === "text-end") {
|
|
const id = chunk.id as string;
|
|
const index = orchestration.activeTextPartIndexes.get(id);
|
|
if (index === undefined) return msg;
|
|
orchestration.activeTextPartIndexes.delete(id);
|
|
return updatePartAt(msg, index, (p) => ({ ...p, state: "done" }));
|
|
}
|
|
|
|
// Reasoning parts ----------------------------------------------------------
|
|
if (type === "reasoning-start") {
|
|
const id = chunk.id as string;
|
|
const newIndex = (msg.parts ?? []).length;
|
|
orchestration.activeReasoningPartIndexes.set(id, newIndex);
|
|
return withNewPart(msg, {
|
|
type: "reasoning",
|
|
id,
|
|
text: "",
|
|
state: "streaming",
|
|
});
|
|
}
|
|
if (type === "reasoning-delta") {
|
|
const id = chunk.id as string;
|
|
const index = orchestration.activeReasoningPartIndexes.get(id);
|
|
if (index === undefined) return msg;
|
|
return updatePartAt(msg, index, (p) => ({
|
|
...p,
|
|
text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""),
|
|
}));
|
|
}
|
|
if (type === "reasoning-end") {
|
|
const id = chunk.id as string;
|
|
const index = orchestration.activeReasoningPartIndexes.get(id);
|
|
if (index === undefined) return msg;
|
|
orchestration.activeReasoningPartIndexes.delete(id);
|
|
return updatePartAt(msg, index, (p) => ({ ...p, state: "done" }));
|
|
}
|
|
|
|
// Tool call parts ----------------------------------------------------------
|
|
if (type === "tool-input-start") {
|
|
const toolName = String(chunk.toolName ?? "");
|
|
return withNewPart(msg, {
|
|
type: `tool-${toolName}`,
|
|
toolCallId: chunk.toolCallId,
|
|
toolName,
|
|
state: "input-streaming",
|
|
input: undefined,
|
|
});
|
|
}
|
|
if (type === "tool-input-delta") {
|
|
// We don't parse partial JSON, so streaming tool input deltas are a
|
|
// no-op. The full input snaps in when `tool-input-available` arrives.
|
|
return msg;
|
|
}
|
|
if (type === "tool-input-available") {
|
|
const toolName = String(chunk.toolName ?? "");
|
|
const existingIdx = indexOfPart(
|
|
msg,
|
|
(p) => (p as { toolCallId?: string }).toolCallId === chunk.toolCallId
|
|
);
|
|
if (existingIdx >= 0) {
|
|
return updatePartAt(msg, existingIdx, (p) => ({
|
|
...p,
|
|
state: "input-available",
|
|
input: chunk.input,
|
|
}));
|
|
}
|
|
// Tool input arrived without a preceding tool-input-start (some
|
|
// providers do this for fast tools) — synthesize a new part.
|
|
return withNewPart(msg, {
|
|
type: `tool-${toolName}`,
|
|
toolCallId: chunk.toolCallId,
|
|
toolName,
|
|
state: "input-available",
|
|
input: chunk.input,
|
|
});
|
|
}
|
|
if (type === "tool-output-available") {
|
|
return updatePart(msg, (p) =>
|
|
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
|
|
? {
|
|
...p,
|
|
state: "output-available",
|
|
output: chunk.output,
|
|
...(chunk.preliminary === true ? { preliminary: true } : {}),
|
|
}
|
|
: null
|
|
);
|
|
}
|
|
if (type === "tool-output-error") {
|
|
return updatePart(msg, (p) =>
|
|
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
|
|
? { ...p, state: "output-error", errorText: chunk.errorText }
|
|
: null
|
|
);
|
|
}
|
|
|
|
// HITL approval (AI SDK 7) -------------------------------------------------
|
|
//
|
|
// v7 added human-in-the-loop tool approval. A `needsApproval` tool emits a
|
|
// `tool-approval-request` after its input is available; the tool then waits
|
|
// for a `tool-approval-response` (approve/deny) before executing. Mirror AI
|
|
// SDK 7's `processUIMessageStream`: the request marks the matching part
|
|
// `approval-requested` and records `approval.id`; the response (matched by
|
|
// that id) marks it `approval-responded` with the verdict. An approved tool
|
|
// then proceeds to `tool-output-available` as usual.
|
|
if (type === "tool-approval-request") {
|
|
return updatePart(msg, (p) =>
|
|
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
|
|
? {
|
|
...p,
|
|
state: "approval-requested",
|
|
approval: {
|
|
id: chunk.approvalId,
|
|
...(chunk.isAutomatic === true ? { isAutomatic: true } : {}),
|
|
},
|
|
}
|
|
: null
|
|
);
|
|
}
|
|
if (type === "tool-approval-response") {
|
|
return updatePart(msg, (p) => {
|
|
const approval = (p as { approval?: { id?: string; isAutomatic?: boolean } }).approval;
|
|
if (!approval || approval.id !== chunk.approvalId) return null;
|
|
return {
|
|
...p,
|
|
state: "approval-responded",
|
|
approval: {
|
|
...approval,
|
|
id: chunk.approvalId,
|
|
approved: chunk.approved,
|
|
...(chunk.reason != null ? { reason: chunk.reason } : {}),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
// Source / file / step / data parts — pass through as a whole -------------
|
|
if (type === "source-url" || type === "source-document" || type === "file") {
|
|
return withNewPart(msg, chunk as unknown as AnyPart);
|
|
}
|
|
if (type === "start-step") {
|
|
return withNewPart(msg, { type: "step-start" });
|
|
}
|
|
if (type === "finish-step") {
|
|
// Step boundary — canonical clears the active part trackers so a new
|
|
// step can re-use the same text/reasoning part IDs cleanly. The
|
|
// message itself doesn't structurally change; the previous step's
|
|
// parts stay frozen at their indexes in `msg.parts`.
|
|
orchestration.activeTextPartIndexes.clear();
|
|
orchestration.activeReasoningPartIndexes.clear();
|
|
return msg;
|
|
}
|
|
if (type.startsWith("data-")) {
|
|
return withNewPart(msg, chunk as unknown as AnyPart);
|
|
}
|
|
|
|
// Metadata / lifecycle -----------------------------------------------------
|
|
if (type === "finish" || type === "message-metadata") {
|
|
if (chunk.messageMetadata == null) return msg;
|
|
return {
|
|
...msg,
|
|
metadata: {
|
|
...((msg as { metadata?: Record<string, unknown> }).metadata ?? {}),
|
|
...(chunk.messageMetadata as Record<string, unknown>),
|
|
},
|
|
} as UIMessage;
|
|
}
|
|
|
|
// Abort / error / unknown — no structural change. (`start` is handled at
|
|
// the orchestration level in the output reader, not here.)
|
|
return msg;
|
|
}
|
|
|
|
// --- Small immutable helpers for UIMessage.parts mutation -------------------
|
|
|
|
function withNewPart(msg: UIMessage, part: AnyPart): UIMessage {
|
|
return {
|
|
...msg,
|
|
parts: [...((msg.parts ?? []) as AnyPart[]), part],
|
|
} as UIMessage;
|
|
}
|
|
|
|
function updatePart(msg: UIMessage, updater: (part: AnyPart) => AnyPart | null): UIMessage {
|
|
const parts = (msg.parts ?? []) as AnyPart[];
|
|
let changed = false;
|
|
const next = parts.map((p) => {
|
|
const updated = updater(p);
|
|
if (updated === null) return p;
|
|
changed = true;
|
|
return updated;
|
|
});
|
|
return changed ? ({ ...msg, parts: next } as UIMessage) : msg;
|
|
}
|
|
|
|
function indexOfPart(msg: UIMessage, predicate: (part: AnyPart) => boolean): number {
|
|
const parts = (msg.parts ?? []) as AnyPart[];
|
|
for (let i = 0; i < parts.length; i++) {
|
|
if (predicate(parts[i]!)) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function updatePartAt(
|
|
msg: UIMessage,
|
|
index: number,
|
|
updater: (part: AnyPart) => AnyPart
|
|
): UIMessage {
|
|
const parts = (msg.parts ?? []) as AnyPart[];
|
|
if (index < 0 || index >= parts.length) return msg;
|
|
const next = parts.slice();
|
|
next[index] = updater(parts[index]!);
|
|
return { ...msg, parts: next } as UIMessage;
|
|
}
|