diff --git a/web/ee/package.json b/web/ee/package.json index f816bbeb45..80dfa31ea9 100644 --- a/web/ee/package.json +++ b/web/ee/package.json @@ -21,6 +21,7 @@ "dependencies": { "@agenta/annotation": "workspace:../packages/agenta-annotation", "@agenta/annotation-ui": "workspace:../packages/agenta-annotation-ui", + "@agenta/chat": "workspace:../packages/agenta-chat", "@agenta/entities": "workspace:../packages/agenta-entities", "@agenta/entity-ui": "workspace:../packages/agenta-entity-ui", "@agenta/oss": "workspace:../oss", diff --git a/web/oss/next.config.ts b/web/oss/next.config.ts index 6da0db89ea..b29b20379f 100644 --- a/web/oss/next.config.ts +++ b/web/oss/next.config.ts @@ -93,6 +93,7 @@ const COMMON_CONFIG: NextConfig = { "@agentaai/api-client", "@agenta/shared", "@agenta/ui", + "@agenta/chat", "@agenta/entities", "@agenta/entity-ui", "@agenta/playground", diff --git a/web/oss/package.json b/web/oss/package.json index 1b9e0fd918..7ff9eec206 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -25,6 +25,7 @@ "dependencies": { "@agenta/annotation": "workspace:../packages/agenta-annotation", "@agenta/annotation-ui": "workspace:../packages/agenta-annotation-ui", + "@agenta/chat": "workspace:../packages/agenta-chat", "@agenta/entities": "workspace:../packages/agenta-entities", "@agenta/entity-ui": "workspace:../packages/agenta-entity-ui", "@agenta/playground": "workspace:../packages/agenta-playground", diff --git a/web/packages/agenta-chat/.gitignore b/web/packages/agenta-chat/.gitignore new file mode 100644 index 0000000000..96d253c48e --- /dev/null +++ b/web/packages/agenta-chat/.gitignore @@ -0,0 +1,3 @@ +# Generated by Vitest — do not commit +test-results/ +coverage/ diff --git a/web/packages/agenta-chat/package.json b/web/packages/agenta-chat/package.json new file mode 100644 index 0000000000..1f735cb633 --- /dev/null +++ b/web/packages/agenta-chat/package.json @@ -0,0 +1,52 @@ +{ + "name": "@agenta/chat", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc --noEmit", + "types:check": "tsc --noEmit", + "lint": "eslint --config ../eslint.config.mjs src/", + "test": "pnpm run test:unit", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "pnpm run types:check && pnpm run lint" + }, + "exports": { + ".": "./src/index.ts", + "./model": "./src/model/index.ts", + "./assets": "./src/assets/index.ts", + "./transport": "./src/transport/index.ts", + "./state": "./src/state/index.ts", + "./hooks": "./src/hooks/index.ts", + "./skin": "./src/skin/index.ts" + }, + "dependencies": { + "@agenta/entities": "workspace:../agenta-entities", + "@agenta/playground": "workspace:../agenta-playground", + "@agenta/shared": "workspace:../agenta-shared" + }, + "peerDependencies": { + "@ai-sdk/react": ">=3.0.0-beta.0", + "ai": ">=6.0.0-beta.0", + "jotai": ">=2.0.0", + "react": ">=18.0.0" + }, + "devDependencies": { + "@ai-sdk/react": "3.0.0-beta.153", + "@testing-library/react": "^16.3.0", + "@types/node": "^20.19.20", + "@types/react": "^19.0.10", + "@vitest/coverage-v8": "^4.1.4", + "ai": "6.0.0-beta.150", + "jsdom": "^26.1.0", + "jotai": "^2.15.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + } +} diff --git a/web/packages/agenta-chat/src/assets/attachmentRules.ts b/web/packages/agenta-chat/src/assets/attachmentRules.ts new file mode 100644 index 0000000000..cd29393d51 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/attachmentRules.ts @@ -0,0 +1,102 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/attachments.ts (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// Keep byte-parity if either side changes. +// Adaptations: renamed to attachmentRules.ts on the package side — src/model/attachments.ts +// already holds the package's `PendingAttachment` staged-upload type, so this file gets a +// distinct name for the validation/limits concerns copied here. +/** + * Attachment guardrails for the agent composer. Files are sent inline as base64 `data:` URLs + * (see `files.ts`), so an unbounded picker puts arbitrary bytes straight into the request body. + * These limits cap the count, per-file size, and types. + * + * The limits are a single value object, not scattered constants, so they can later be derived + * from the selected model / harness capabilities (e.g. an image-only model, a larger context + * window) and passed down in place of `DEFAULT_ATTACHMENT_LIMITS`. That wiring is out of scope + * here; today everything reads the default. + */ + +export interface AttachmentLimits { + /** Max files per message. */ + maxCount: number + /** Max bytes per file (before base64 inflation, which adds ~33% on the wire). */ + maxBytes: number + /** Accepted media types: exact types (`application/pdf`) or `type/` prefixes (`image/`). */ + accept: string[] + /** `accept` attribute for the native file picker (a hint; drag/paste is validated too). */ + acceptAttr: string + /** Human label for the kinds accepted, e.g. "Images and documents". */ + label: string +} + +export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = { + maxCount: 5, + maxBytes: 5 * 1024 * 1024, + accept: ["image/", "application/pdf", "text/", "application/json"], + acceptAttr: + "image/*,application/pdf,text/plain,text/markdown,text/csv,.md,.csv,application/json", + label: "Images and documents", +} + +/** Whether a media type is allowed under the limits (prefix or exact match). */ +export const isAcceptedType = (mediaType: string, limits: AttachmentLimits): boolean => + limits.accept.some((a) => (a.endsWith("/") ? mediaType.startsWith(a) : mediaType === a)) + +/** Compact human size: `820 KB`, `4.2 MB`. */ +export const formatBytes = (n: number): string => { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB` + return `${(n / (1024 * 1024)).toFixed(1)} MB` +} + +export interface AttachmentRejection { + /** The file's name, for the inline message. */ + name: string + /** Why it was rejected (verb phrase): "is too large (8.2 MB) · max 5 MB". */ + reason: string +} + +export interface AttachmentValidation { + accepted: File[] + rejections: AttachmentRejection[] +} + +/** + * Validate a batch of incoming files against the limits, given how many are already attached. + * Returns the files to add (in order, capped to the remaining slots) and a rejection per file + * that didn't make it. Pure: callers own state and messaging. + */ +export const validateIncoming = ( + incoming: File[], + currentCount: number, + limits: AttachmentLimits = DEFAULT_ATTACHMENT_LIMITS, +): AttachmentValidation => { + const accepted: File[] = [] + const rejections: AttachmentRejection[] = [] + let remaining = limits.maxCount - currentCount + + for (const file of incoming) { + const type = file.type || "application/octet-stream" + if (!isAcceptedType(type, limits)) { + rejections.push({name: file.name, reason: `isn't a supported file type`}) + continue + } + if (file.size > limits.maxBytes) { + rejections.push({ + name: file.name, + reason: `is too large (${formatBytes(file.size)}) · max ${formatBytes(limits.maxBytes)} per file`, + }) + continue + } + if (remaining <= 0) { + rejections.push({ + name: file.name, + reason: `exceeds the ${limits.maxCount}-file limit`, + }) + continue + } + accepted.push(file) + remaining -= 1 + } + + return {accepted, rejections} +} diff --git a/web/packages/agenta-chat/src/assets/files.ts b/web/packages/agenta-chat/src/assets/files.ts new file mode 100644 index 0000000000..69f53ed495 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/files.ts @@ -0,0 +1,82 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/files.ts (2026-07-25); the +// OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. Keep +// byte-parity if either side changes. +import type {FileUIPart, UIMessage} from "ai" + +import type {AttachmentRejection} from "./attachmentRules" + +/** + * Multi-modality helpers for the agent chat slice. Attachments are kept entirely on the + * client: there is no upload server, so a selected file is read into a `data:` URL and + * sent inline as an AI SDK v6 `file` part (`{type, mediaType, filename, url}`). The service + * receives the bytes in the request body — same channel as the text. + */ + +export type FileKind = "image" | "audio" | "video" | "file" + +/** Map an IANA media type to the `FileCard` `type` / a render branch. */ +export const fileKind = (mediaType: string): FileKind => { + if (mediaType.startsWith("image/")) return "image" + if (mediaType.startsWith("audio/")) return "audio" + if (mediaType.startsWith("video/")) return "video" + return "file" +} + +/** Read one `File` into a `data:` URL `file` part. */ +const fileToPart = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error) + reader.onload = () => + resolve({ + type: "file", + mediaType: file.type || "application/octet-stream", + filename: file.name, + url: reader.result as string, // data:;base64,<...> + }) + reader.readAsDataURL(file) + }) + +export interface FilesToPartsResult { + /** The files that encoded, in the order they were given. */ + parts: FileUIPart[] + /** The ones that did not, in the same shape the guardrails use. */ + rejections: AttachmentRejection[] +} + +/** + * Convert picked `File`s into `file` parts for `sendMessage({text, files})`. + * + * Settles each file on its own rather than `Promise.all`: a file that became unreadable between + * staging and submit (moved, permission revoked, a disconnected drive) used to reject the whole + * conversion, which lost the message text and every readable attachment with it. A read failure + * is reported like any other rejection, so the caller can send what it has and tell the user + * which file did not make it. + */ +export const filesToParts = async (files: File[]): Promise => { + const settled = await Promise.allSettled(files.map(fileToPart)) + const parts: FileUIPart[] = [] + const rejections: AttachmentRejection[] = [] + settled.forEach((outcome, i) => { + if (outcome.status === "fulfilled") parts.push(outcome.value) + else rejections.push({name: files[i]?.name ?? "attachment", reason: "could not be read"}) + }) + return {parts, rejections} +} + +/** The `file` parts of a message, in order. */ +export const fileParts = (message: UIMessage): FileUIPart[] => + message.parts.filter((p) => p.type === "file") as FileUIPart[] + +/** + * A readable label for a file part: the filename, else the tail of its URL. + * + * The URL fallback skips `data:` URLs. `fileToPart` emits `data:;base64,<...>`, whose tail + * is the payload itself, so an unnamed inline file would be labelled with ~70 characters of + * base64 instead of a name. + */ +export const filePartName = (part: FileUIPart): string => { + if (part.filename) return part.filename + if (part.url.startsWith("data:")) return "attachment" + return part.url.split("/").pop()?.split("?")[0] || "file" +} diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts new file mode 100644 index 0000000000..b40d0a6187 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -0,0 +1,7 @@ +export * from "./toolFormat" +export * from "./trace" +export * from "./attachmentRules" +export * from "./files" +export * from "./rewind" +export * from "./transcriptToMessages" +export * from "./loadSession" diff --git a/web/packages/agenta-chat/src/assets/loadSession.ts b/web/packages/agenta-chat/src/assets/loadSession.ts new file mode 100644 index 0000000000..ef624a5288 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -0,0 +1,74 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/loadSession.ts (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// Keep byte-parity if either side changes. +// Adaptations: none — `fetchSessionRecordsAtom` already reads `projectIdAtom` internally from +// `@agenta/entities/session` (an allowed package dep), so no OSS-app-only import is involved and +// no signature change was needed. +import {fetchSessionRecordsAtom} from "@agenta/entities/session" +import type {UIMessage} from "ai" +import {getDefaultStore} from "jotai" + +import {transcriptToMessages} from "./transcriptToMessages" + +/** + * Server-side hydration seam for a session's conversation. + * + * The durable Sessions API (PR #4916) persists every ACP `AgentEvent` to an append-only + * record log; `queryRecords` is the replay source. This maps those events to v6 `UIMessage[]` + * (see `transcriptToMessages`) so opening a session from a deep link / observability trace + * renders a conversation this browser never ran. + * + * Returns `null` when there is no server history (project scope missing, request failed, or + * the record log is empty — e.g. the ingest worker isn't running locally). The caller then + * falls back to whatever is already in localStorage. + * + * The records query is disk-persisted (IndexedDB): a warm reload resolves instantly from the + * restored log, and the entities layer guarantees one background revalidation (disk is never + * authoritative). Because this return is a one-shot copy, `onRefreshed` re-delivers the + * transcript when that revalidation lands — callers apply it behind their own adoption guards. + */ +export interface SessionTranscript { + messages: UIMessage[] + /** + * How many durable records this transcript was built from. The log is append-only and ordered, + * so this is an EXACT "has the server moved on?" watermark — unlike a message count, which + * `transcriptToMessages` deliberately holds flat while a turn grows (issue #5530). + */ + recordCount: number +} + +export const loadSessionMessages = async ( + sessionId: string, + onRefreshed?: (transcript: SessionTranscript) => void, +): Promise => { + // Fetch through the shared records query cache (same key as `sessionRecordsQueryFamily`) so + // hydration, revalidation, and the Inspector's atom subscribers share ONE network flight per + // stale window instead of each issuing a raw duplicate request. A failure resolves to `null` + // (the documented "request failed" contract) so the caller shows the history-unavailable + // notice instead of leaking an unhandled rejection. + try { + const {records, refreshed} = await getDefaultStore().set(fetchSessionRecordsAtom, sessionId) + if (refreshed && onRefreshed) { + void refreshed + .then((fresh) => { + if (!fresh || fresh.length === 0) return + const freshMsgs = transcriptToMessages(fresh) + if (freshMsgs && freshMsgs.length > 0) { + onRefreshed({messages: freshMsgs, recordCount: fresh.length}) + } + }) + // This chain outlives the function, so the try/catch below cannot see it. A + // failed revalidation keeps whatever the cache already restored; without this + // it surfaces as an unhandled rejection. + .catch((err) => { + console.warn("[loadSessionMessages] revalidation failed:", err) + }) + } + if (!records || records.length === 0) return null + const messages = transcriptToMessages(records) + return messages ? {messages, recordCount: records.length} : null + } catch (err) { + console.warn("[loadSessionMessages] hydration fetch failed:", err) + return null + } +} diff --git a/web/packages/agenta-chat/src/assets/rewind.ts b/web/packages/agenta-chat/src/assets/rewind.ts new file mode 100644 index 0000000000..6893af4e24 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/rewind.ts @@ -0,0 +1,37 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/rewind.ts (2026-07-25); the +// OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. Keep +// byte-parity if either side changes. +import type {UIMessage} from "ai" + +/** + * Tools with no external side effect — safe to rewind/retry past silently. v1 hardcodes + * this; the principled source is a `readOnly` flag on the tool spec (see + * `docs/design/agent-workflows/agent-chat-rewind.md`). Everything not listed here is treated + * as potentially side-effecting, so the user is warned before rewinding past it. + */ +export const READ_ONLY_TOOLS = new Set(["search_docs"]) + +/** Concatenated text of a message's text parts. */ +export const messageText = (message: UIMessage): string => + message.parts + .filter((p) => p.type === "text") + .map((p) => (p as {text: string}).text) + .join("") + +/** + * Names of side-effecting tools that ALREADY produced output within `messages` — i.e. real + * actions a rewind cannot undo (e.g. a sent email). Read-only tools are ignored, and tool + * calls that never ran (still awaiting approval, denied, errored) are ignored. + */ +export const sideEffectingToolsInRange = (messages: UIMessage[]): string[] => { + const names = new Set() + for (const message of messages) { + for (const part of message.parts) { + if (!part.type.startsWith("tool-")) continue + const ran = (part as {state?: string}).state === "output-available" + const name = part.type.replace(/^tool-/, "") + if (ran && !READ_ONLY_TOOLS.has(name)) names.add(name) + } + } + return [...names] +} diff --git a/web/packages/agenta-chat/src/assets/toolFormat.ts b/web/packages/agenta-chat/src/assets/toolFormat.ts new file mode 100644 index 0000000000..a3594b8962 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/toolFormat.ts @@ -0,0 +1,45 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/toolFormat.ts (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// Keep byte-parity if either side changes. +// Adaptations: this is the canonical copy of `stripFence` for the package — src/model/toolSummary.ts +// (copied earlier from ToolActivity.tsx, which itself imports stripFence from this file in OSS) now +// re-exports it from here instead of holding a second definition. +/** + * Shared formatting for tool input/output display (inline step log + Turn Inspector), so both + * render a payload identically. + * + * Tool results reach the FE in a few shapes: a plain string, a string wrapped in a markdown code + * fence (the model-facing form), or a JSON *string* (the backend often JSON-encodes structured + * output). Show them cleanly: strip a wrapping fence, and pretty-print anything that is really JSON + * (a JSON string or an object) instead of dumping a single compact line. + */ + +/** Strip a surrounding markdown code fence — backends wrap tool output/errors in ```…```. Only a + * fence that spans the WHOLE string is stripped, so inner fenced blocks are left intact. */ +export const stripFence = (value: string): string => { + const m = value.trim().match(/^```[\w-]*\n?([\s\S]*?)\n?```$/) + return m ? m[1].trim() : value +} + +/** Pretty-print `value` for a monospace block: JSON string → indented JSON, object → indented JSON, + * otherwise the (fence-stripped) string. Never throws. */ +export const formatToolValue = (value: unknown): string => { + if (value == null) return "" + if (typeof value === "string") { + const stripped = stripFence(value) + try { + const parsed = JSON.parse(stripped) + // Only reformat real structured JSON — never turn "42"/"true"/a bare word into a + // primitive or churn a plain sentence that happens to parse. + if (parsed && typeof parsed === "object") return JSON.stringify(parsed, null, 2) + } catch { + // not JSON — show the stripped text as-is (e.g. a line-numbered file read, a message). + } + return stripped + } + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} diff --git a/web/packages/agenta-chat/src/assets/trace.ts b/web/packages/agenta-chat/src/assets/trace.ts new file mode 100644 index 0000000000..9550885b77 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/trace.ts @@ -0,0 +1,79 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/trace.ts (2026-07-25); the +// OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. Keep +// byte-parity if either side changes. +import type {UIMessage} from "ai" + +/** + * The custom `data-trace` part the service emits: `{type: "data-trace", data: {...}}`. + * The service sends both a `traceId` (preferred — `openTraceDrawerAtom` wants an id) and a + * `url` (human link). We parse the id out of the url as a fallback for older emitters that + * only send `{url}` (the original RAG_QA example did). + */ +interface TracePartData { + traceId?: string + url?: string +} + +const parseTraceIdFromUrl = (url?: string): string | undefined => { + if (!url) return undefined + const segments = url.split("?")[0].split("/").filter(Boolean) + return segments[segments.length - 1] || undefined +} + +/** + * Extract the trace id for a message. Prefers `message.metadata.traceId` (the RFC-aligned + * channel — the service sets it via `messageMetadata` on the `start`/`finish` parts), and + * falls back to the custom `data-trace` part for emitters that only send that. + */ +export const getMessageTraceId = (message: UIMessage): string | undefined => { + const metaTraceId = (message.metadata as {traceId?: string} | undefined)?.traceId + if (metaTraceId) return metaTraceId + + const tracePart = message.parts.find((p) => p.type === "data-trace") as + | {type: "data-trace"; data?: TracePartData} + | undefined + if (!tracePart?.data) return undefined + return tracePart.data.traceId || parseTraceIdFromUrl(tracePart.data.url) +} + +/** + * A run failure stamped onto an assistant turn's metadata (FE-side, when the stream errors — + * see AgentChatPanel). The backend doesn't always record the error on the trace, but useChat + * surfaces it; persisting it here lets the failed turn render the real reason inline (a red + * error bubble) instead of a generic "no response", and survives a reload with the session. + */ +export const getMessageRunError = (message: UIMessage): string | undefined => { + const runError = (message.metadata as {runError?: {message?: string}} | undefined)?.runError + const msg = runError?.message + return typeof msg === "string" && msg.trim() ? msg : undefined +} + +/** Token/cost fields in `ExecutionMetricsDisplay`'s shape. */ +export interface MessageUsageMetrics { + promptTokens?: number + completionTokens?: number + totalTokens?: number + totalCost?: number +} + +/** + * Usage (tokens + cost) the service stamps onto `message.metadata.usage` via the + * `finish` part's messageMetadata (`{input, output, total, cost}`), mapped to the + * metrics-display field names. The trace supplies latency; this supplies tokens/cost + * (the agent-run trace summary doesn't surface them on the Pi/local path). + */ +export const getMessageUsage = (message: UIMessage): MessageUsageMetrics | undefined => { + const usage = (message.metadata as {usage?: Record} | undefined)?.usage + if (!usage || typeof usage !== "object") return undefined + const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined) + const out: MessageUsageMetrics = {} + const input = num(usage.input) + const output = num(usage.output) + const total = num(usage.total) + const cost = num(usage.cost) + if (input !== undefined) out.promptTokens = input + if (output !== undefined) out.completionTokens = output + if (total !== undefined) out.totalTokens = total + if (cost !== undefined) out.totalCost = cost + return Object.keys(out).length > 0 ? out : undefined +} diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts new file mode 100644 index 0000000000..505498e230 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -0,0 +1,409 @@ +// Copied from web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// +// Re-synced 2026-08-03 to full parity with the original: the approval-resume handling (pause +// folding, the resumed settle pass, tool-call dedup, re-raise under a new toolCallId) — without +// which a resumed turn replays as still parked and the reload keeps the approval dock up — and +// user-attachment parts + `filename` on file parts, without which a message that carried files +// replays as bare text. +// +// `attachmentContentUrl` is the package's own copy of the original's `attachmentMedia.ts` +// builder, on `@agenta/shared/api` rather than the OSS app layer. +import type {SessionRecord} from "@agenta/entities/session" +import {getAgentaApiUrl} from "@agenta/shared/api" +import type {UIMessage} from "ai" + +/** + * Replay adapter — durable session-record `AgentEvent`s → v6 `UIMessage[]`. + * + * The runner persists each ACP `AgentEvent` as one record row (the backend's append-only + * "records" log, formerly "transcripts"). The live path streams those same events as a Vercel + * UI Message Stream (`sdk/agents/adapters/vercel/stream.py`) which `useChat` assembles into + * `UIMessage[]`; this rebuilds the assembled messages directly so replayed history renders + * identically to a turn this browser streamed live. + * + * Grouping: rows arrive ordered (uuid7 `id`). A contiguous run of non-user rows folds into + * one assistant message; each user row opens a user message. Within an assistant message, + * tool parts are keyed by `toolCallId` so a later `tool_result` settles the earlier + * `tool_call`, and a `interaction_request` (permission) marks it awaiting approval. + */ + +type Part = Record + +/** Content URL for one durable attachment. Mirrors the OSS original's `attachmentMedia.ts`. */ +export function attachmentContentUrl(sessionId: string, attachmentId: string): string { + const params = new URLSearchParams({session_id: sessionId}) + return `${getAgentaApiUrl()}/sessions/attachments/${encodeURIComponent(attachmentId)}/content?${params.toString()}` +} + +// Mirrors services/runner/src/tracing/otel.ts; park sentinels report skipped or unobserved work, not final results. +export const DEFERRED_NOT_EXECUTED_PREFIX = "DEFERRED_NOT_EXECUTED" +export const APPROVED_EXECUTION_RESULT_UNKNOWN = + "APPROVED_EXECUTION_RESULT_UNKNOWN: the approved call started but its result was not observed before the pause ended the turn; do not assume it failed and do not retry a side-effecting call." +export const APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX = APPROVED_EXECUTION_RESULT_UNKNOWN.slice( + 0, + APPROVED_EXECUTION_RESULT_UNKNOWN.indexOf(":"), +) + +interface DraftMessage { + id: string + role: "user" | "assistant" + parts: Part[] + /** Open streamed text/reasoning parts keyed by event id, for delta accumulation. */ + text: Map + reasoning: Map + /** The turn's observability trace id, if the durable record carries one (see below). */ + traceId?: string + /** Token/cost totals from the turn's persisted `usage` event, in the raw stream shape. */ + usage?: {input?: number; output?: number; total?: number; cost?: number} + /** The turn's terminal `done` carried `stopReason:"paused"` — it ended mid-approval, not at a + * real boundary. Surfaced on the message so a cold reload's adoption heuristic can compare state. */ + paused?: boolean + /** The turn paused for approval and then RESUMED to completion (a second, non-paused `done`). */ + resumed?: boolean +} + +interface TranscriptIndex { + tools: Map + approvals: Map +} + +const roleOf = (sender?: string | null): "user" | "assistant" => + sender === "user" ? "user" : "assistant" + +/** + * Best-effort trace id for a replayed turn. The durable session records DON'T carry a trace link + * today, so on reload the trace-hover actions stay dark (the id only exists on the live stream via + * `message.metadata.traceId`). This reads the shapes the backend is most likely to add it in — a + * `trace_id` column on the record row, a `trace_id`/`traceId` on the event payload, or a + * `data-trace` part — so the moment the runner starts stamping one, replayed turns light up with + * the SAME `metadata.traceId` `getMessageTraceId` already reads. A pure no-op until then. + */ +function extractTraceId(row: SessionRecord, p: Record): string | undefined { + const asStr = (v: unknown): string | undefined => + typeof v === "string" && v.trim() ? v : undefined + + const rowLike = row as {trace_id?: unknown; traceId?: unknown} + const rowLevel = asStr(rowLike.trace_id) ?? asStr(rowLike.traceId) + if (rowLevel) return rowLevel + + const payloadLevel = asStr(p.trace_id) ?? asStr(p.traceId) + if (payloadLevel) return payloadLevel + + if (p.type === "data-trace") { + const data = (p.data ?? {}) as {traceId?: unknown; url?: unknown} + const fromData = asStr(data.traceId) + if (fromData) return fromData + const url = asStr(data.url) + if (url) { + const tail = url.split("?")[0].split("/").filter(Boolean).pop() + if (tail) return tail + } + } + return undefined +} + +const newDraft = (id: string, role: "user" | "assistant"): DraftMessage => ({ + id, + role, + parts: [], + text: new Map(), + reasoning: new Map(), +}) + +const toolPartType = (name?: string | null): string => (name ? `tool-${name}` : "dynamic-tool") + +const isRunnerSentinelError = (part: Part): boolean => { + const errorText = typeof part.errorText === "string" ? part.errorText : "" + return ( + errorText.startsWith(DEFERRED_NOT_EXECUTED_PREFIX) || + // Prefix, not equality: the code is the contract and the explanation after the colon + // is prose. `toolSummary` and the desktop's ToolActivity already match this way, so an + // exact compare here is the odd one out and would silently stop reopening the approval + // gate if the runner ever appended context. + errorText.startsWith(APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX) + ) +} + +/** Apply one transcript event's payload onto the current assistant/user draft message. */ +function applyEvent( + draft: DraftMessage, + payload: Record, + index: TranscriptIndex, + sessionId: string, +): void { + const type = payload.type as string | undefined + const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)) + + switch (type) { + case "message": { + draft.parts.push({type: "text", text: str(payload.text)}) + const attachments = Array.isArray(payload.attachments) ? payload.attachments : [] + for (const raw of attachments) { + if (!raw || typeof raw !== "object") continue + const attachment = raw as Record + const attachmentId = str(attachment.attachmentId) + if (!attachmentId) continue + draft.parts.push({ + type: "file", + url: attachmentContentUrl(sessionId, attachmentId), + mediaType: str(attachment.mediaType) || "application/octet-stream", + filename: str(attachment.filename) || undefined, + providerMetadata: { + agenta: { + attachmentId, + size: typeof attachment.size === "number" ? attachment.size : undefined, + }, + }, + }) + } + return + } + case "message_start": { + const part: Part = {type: "text", text: ""} + draft.parts.push(part) + draft.text.set(str(payload.id), part) + return + } + case "message_delta": { + const part = draft.text.get(str(payload.id)) + if (part) part.text = str(part.text) + str(payload.delta) + return + } + case "thought": { + draft.parts.push({type: "reasoning", text: str(payload.text)}) + return + } + case "thought_start": { + const part: Part = {type: "reasoning", text: ""} + draft.parts.push(part) + draft.reasoning.set(str(payload.id), part) + return + } + case "thought_delta": { + const part = draft.reasoning.get(str(payload.id)) + if (part) part.text = str(part.text) + str(payload.delta) + return + } + case "tool_call": { + const toolCallId = str(payload.id) + const existing = index.tools.get(toolCallId) + if (existing) { + // A resume re-emits the approved call with the same toolCallId. Update the existing + // part (kept across the pause boundary) in place instead of rendering a duplicate; + // its tool_result then settles that one part to a single ✓. + if (payload.input !== undefined) existing.input = payload.input + return + } + const part: Part = { + type: toolPartType(payload.name as string), + toolCallId, + state: "input-available", + input: payload.input, + } + draft.parts.push(part) + index.tools.set(toolCallId, part) + return + } + case "tool_result": { + const part = index.tools.get(str(payload.id)) + if (!part) return + if (payload.denied) { + part.state = "output-denied" + } else if (payload.isError) { + part.state = "output-error" + part.errorText = str(payload.output) + } else { + part.state = "output-available" + part.output = payload.data !== undefined ? payload.data : payload.output + } + return + } + case "interaction_request": { + // v1 scope: HITL approvals only. The runner emits `kind` `user_approval` for the + // Approve/Deny gate; `user_input`/`client_tool` are left to their tool_call/result + // parts (a client tool isn't approve/deny) until those are wired. + if (payload.kind !== "user_approval") return + const reqPayload = (payload.payload ?? {}) as Record + const toolCall = (reqPayload.toolCall ?? {}) as Record + const toolCallId = str( + reqPayload.toolCallId ?? toolCall.id ?? toolCall.toolCallId ?? payload.id, + ) + let part = index.tools.get(toolCallId) + if (!part) { + // The runner parked without first surfacing the tool call — synthesize one. + part = { + type: toolPartType( + (toolCall.name as string) || + (toolCall.title as string) || + (toolCall.kind as string), + ), + toolCallId, + state: "input-available", + input: toolCall.rawInput ?? toolCall.input, + } + draft.parts.push(part) + index.tools.set(toolCallId, part) + } + index.approvals.set(str(payload.id), part) + const canRequestApproval = + part.state === "input-available" || + (part.state === "output-error" && isRunnerSentinelError(part)) + if (canRequestApproval) { + delete part.errorText + delete part.output + part.state = "approval-requested" + part.approval = {id: str(payload.id)} + } + return + } + case "interaction_response": { + if (payload.kind !== "user_approval") return + const responsePayload = (payload.payload ?? {}) as Record + const responseId = str(payload.id) + const toolCallId = str(responsePayload.toolCallId) + // A cold resume re-raises the approved call under a NEW toolCallId, so the interaction + // id (identical on request and response by contract) is the reliable key to the gate. + const part = + index.approvals.get(responseId) ?? + (toolCallId ? index.tools.get(toolCallId) : undefined) + if (!part || typeof responsePayload.approved !== "boolean") return + if (part.state === "approval-requested") { + part.state = "approval-responded" + part.approval = {id: responseId, approved: responsePayload.approved} + } + if (!toolCallId || toolCallId === str(part.toolCallId)) return + // Re-raised under a new id: point that id at the gated part and fold in the duplicate + // it created — an executed result supersedes the approval-responded state. + const duplicate = index.tools.get(toolCallId) + index.tools.set(toolCallId, part) + if (!duplicate || duplicate === part) return + const at = draft.parts.indexOf(duplicate) + if (at >= 0) draft.parts.splice(at, 1) + if (duplicate.input !== undefined) part.input = duplicate.input + if (typeof duplicate.state === "string" && duplicate.state.startsWith("output-")) { + part.state = duplicate.state + if (duplicate.output !== undefined) part.output = duplicate.output + if (duplicate.errorText !== undefined) part.errorText = duplicate.errorText + } + return + } + case "file": { + draft.parts.push({ + type: "file", + url: str(payload.url), + mediaType: str(payload.mediaType), + filename: str(payload.filename) || undefined, + }) + return + } + case "error": { + // No error part in the renderer; surface the text so the failure stays visible. + draft.parts.push({type: "text", text: str(payload.message)}) + return + } + case "usage": { + // No renderable part, but the token/cost totals feed the turn's metrics bar. The + // runner may persist a partial `usage_update` then a final full-split `usage`; merge + // field-by-field so the last defined value wins (final setUsage carries input/output). + const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined) + const next = draft.usage ?? {} + const input = num(payload.input) + const output = num(payload.output) + const total = num(payload.total) + const cost = num(payload.cost) + if (input !== undefined) next.input = input + if (output !== undefined) next.output = output + if (total !== undefined) next.total = total + if (cost !== undefined) next.cost = cost + draft.usage = next + return + } + // done / data / render-hints carry no renderable message part — drop. + default: + return + } +} + +/** + * Convert a session's ordered transcript rows into v6 `UIMessage[]`. Returns `null` when + * there is nothing renderable (empty transcript or only metadata events) so the caller can + * fall back to local history. + */ +export function transcriptToMessages(records: SessionRecord[]): UIMessage[] | null { + const drafts: DraftMessage[] = [] + let current: DraftMessage | null = null + // Paused resumes close the draft, but later answers and results still target its tool part. + const index: TranscriptIndex = {tools: new Map(), approvals: new Map()} + + for (const row of records) { + const payload = row.payload + if (!payload || typeof payload !== "object") continue + const p = payload as Record + // Speculative trace link (no-op until the backend stamps one) — the id can ride the `done` + // row too, so read it before the turn closes. + const traceId = extractTraceId(row, p) + // `done` terminates a turn. Records are runner-output-only (no user rows), so without + // this every turn folds into one assistant bubble; closing the draft here starts a + // fresh message per turn. + if (row.session_update === "done" || p.type === "done") { + // Last-wins: a paused turn folds into its resume (below), and that turn has two `done`s + // with two traceIds — prefer the RESUME trace, where the approved tool actually executed. + // A normal turn has a single `done`, so this is unchanged for it. + if (current && traceId) current.traceId = traceId + if (current && p.stopReason === "paused") { + // Paused mid-approval: the resume turn's records (the re-emitted call, its result, + // the follow-up text) belong to the SAME assistant turn the user saw live, so keep + // the draft OPEN and let them fold into it instead of splitting into a dangling + // "awaiting approval" bubble + a resumed bubble. A paused turn blocks the session, + // so it's always followed by its own resume or is the last (abandoned) turn. Mark it + // paused for the adoption heuristic; the normal `done` below clears it on resume. + current.paused = true + continue + } + // A resumed-then-completed turn is no longer paused. + if (current?.paused) current.resumed = true + if (current) current.paused = false + current = null + continue + } + const role = roleOf(row.sender) + if (!current || current.role !== role) { + current = newDraft(row.id, role) + drafts.push(current) + } + if (traceId && !current.traceId) current.traceId = traceId + applyEvent(current, p, index, row.session_id) + } + + // A RESUMED turn's gate was answered by definition — the runner only emits post-pause records + // once the user responded (a deny settles its own part via `tool_result denied`). The durable + // log doesn't always persist the `interaction_response`, so settle whatever is left awaiting: + // otherwise a completed turn replays as still parked and the reload keeps the approval dock up. + for (const d of drafts) { + if (!d.resumed) continue + for (const part of d.parts) { + if (part.state === "approval-requested") part.state = "approval-responded" + } + } + + const messages = drafts + .filter((d) => d.parts.length > 0) + .map((d) => { + // `getMessageTraceId`/`getMessageUsage` read exactly these, so the hover trace actions + // and metrics bar light up on reload. traceId stays absent until the backend stamps one; + // usage is present whenever the turn persisted a `usage` event. + const metadata: Record = {} + if (d.traceId) metadata.traceId = d.traceId + if (d.usage) metadata.usage = d.usage + if (d.paused) metadata.paused = true + return { + id: d.id, + role: d.role, + parts: d.parts, + ...(Object.keys(metadata).length > 0 ? {metadata} : {}), + } as unknown as UIMessage + }) + + return messages.length > 0 ? messages : null +} diff --git a/web/packages/agenta-chat/src/hooks/index.ts b/web/packages/agenta-chat/src/hooks/index.ts new file mode 100644 index 0000000000..93a1545a5b --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useAgentChatQueue" +export * from "./useAgentModelKeyStatus" +export * from "./useComposerAttachments" +export * from "./useApprovalDock" +export * from "./useAgentConversation" diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts new file mode 100644 index 0000000000..3aad0d10cf --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -0,0 +1,134 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +// Adaptations: none — every import already resolves to an allowed package dep. +import {useCallback, useEffect, useRef, useState} from "react" + +import {canReleaseQueuedMessage, isHitlPending} from "@agenta/playground" +import {generateId} from "@agenta/shared/utils" +import type {FileUIPart, UIMessage} from "ai" + +export interface QueuedMessage { + id: string + text: string + fileParts?: FileUIPart[] +} + +interface UseAgentChatQueueArgs { + status: string + messages: UIMessage[] + /** The last turn was user-stopped (cancelled). A stop voids any pending approval / imminent + * auto-resume, so the aborted turn's tool parts still reading as mid-HITL must NOT hold a new + * send — a stopped-and-settled conversation is releasable. */ + stopped: boolean + /** The tail's "resume imminent" shape is an orphan: it was RESTORED from storage (page + * reload / pane remount killed the run mid-approval-resume) and no interaction in this + * mount can fire the auto-resume. Holding for it would freeze the queue forever with no + * dock and no stop (AGE-3937), so it voids the hold exactly like a user stop. */ + resumeOrphaned?: boolean + /** Send one released message into the conversation (wraps `useChat`'s `sendMessage`). Must be + * referentially stable so the release effect doesn't churn on every streamed token. */ + sendQueued: (item: QueuedMessage) => void + /** Persist held messages under this key across pane remounts (route re-entry, tab + * close/reopen) — a restored queue releases normally once the conversation settles. */ + sessionId?: string +} + +// In-memory, page-session lifetime — same as the composer drafts it accompanies. +const queuedBySession = new Map() + +/** + * Holds user messages typed while a turn is in flight and releases them ONE AT A TIME once the + * stream truly settles. It never releases mid human-in-the-loop (a tool-approval gate) — that + * decision lives in `canReleaseQueuedMessage`. Releasing one message flips the conversation back + * to busy, so the next stays queued until that turn settles too. + * + * Exception: a user STOP. Stopping aborts the run, which cancels any pending approval or the tick + * before an auto-resume — but the aborted turn's tool parts keep their `approval-requested` / + * `approval-responded` / client-tool-result shape, so `canReleaseQueuedMessage` would keep holding. + * When `stopped`, a settled conversation is releasable so a fresh send goes immediately. + */ +export const useAgentChatQueue = ({ + status, + messages, + stopped, + resumeOrphaned = false, + sendQueued, + sessionId, +}: UseAgentChatQueueArgs) => { + const [queued, setQueued] = useState( + () => (sessionId && queuedBySession.get(sessionId)) || [], + ) + + // Mirror every queue change into the per-session store so a remount restores it. + useEffect(() => { + if (!sessionId) return + if (queued.length > 0) queuedBySession.set(sessionId, queued) + else queuedBySession.delete(sessionId) + }, [queued, sessionId]) + + // Settled = the stream is over (done or failed). A stop lands here (abort → "ready"). + const settled = status === "ready" || status === "error" + // Releasable now: the normal gate, OR a settled turn whose hold was voided — by a user stop, + // or by an orphaned restored resume shape that nothing in this mount can ever fire. + const canReleaseNow = + canReleaseQueuedMessage(status, messages) || ((stopped || resumeOrphaned) && settled) + + // A stop voids the gate for release (above), so it must void it for reporting too — else the + // aborted turn's lingering `approval-requested` part still reads as "awaiting" while `submit` + // sends immediately. Keep `hitlPending` in lockstep with the release decision. + const hitlPending = !stopped && isHitlPending(messages) + + // One latch shared by both send paths caps releases to one per settle and preserves FIFO. + const releasingRef = useRef(false) + const queuedRef = useRef(queued) + useEffect(() => { + queuedRef.current = queued + }, [queued]) + + // Send now only if idle, unlatched, and the queue is empty; otherwise append (FIFO). + const submit = useCallback( + (item: {text: string; fileParts?: FileUIPart[]}) => { + const message: QueuedMessage = {...item, id: generateId()} + if (!releasingRef.current && queuedRef.current.length === 0 && canReleaseNow) { + releasingRef.current = true + sendQueued(message) + } else { + setQueued((q) => [...q, message]) + } + }, + [canReleaseNow, sendQueued], + ) + + const removeQueued = useCallback((id: string) => { + setQueued((q) => q.filter((m) => m.id !== id)) + }, []) + + const clearQueue = useCallback(() => setQueued([]), []) + + // Release the queue head once the stream settles; the latch caps it at one per settle. Both + // "ready" and "error" are settled — releasing on "error" retries the failed turn with the + // queued message (which clears the error) instead of stranding the queue. "submitted"/ + // "streaming" are in-flight: reset the latch and hold. + useEffect(() => { + if (!settled) { + releasingRef.current = false + return + } + if (releasingRef.current || queued.length === 0) return + if (!canReleaseNow) return + releasingRef.current = true + const [head, ...rest] = queued + setQueued(rest) + sendQueued(head) + }, [settled, canReleaseNow, queued, sendQueued]) + + return { + queued, + submit, + removeQueued, + clearQueue, + /** The conversation is paused on a HITL approval — typed messages should queue, not send. */ + hitlPending, + } +} diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts new file mode 100644 index 0000000000..78cf6f1ad3 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -0,0 +1,584 @@ +// Assembled from web/oss/src/components/AgentChatSlice/AgentConversation.tsx (2026-07-25) — +// the headless conversation host for one agent session. Copy-faithful on the shared engine +// behavior: transport wiring, resume predicate, hydration + revalidate-on-open, queue release, +// approvals, run-status publish, error stamping, persist-on-settle + expand-prune, stop/rewind. +// The desktop host keeps its own inline implementation until the re-plumb. +// +// Deliberately omitted (desktop-only): onboarding, template strips, and the IDE hand-off — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): the committed-revision switch and playground-controller self-commit handling — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): the run-in-playground seam (trigger drawer pending runs) — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): turn-capture and the Turn/Session Inspector wiring — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): scroll engineering (stick-to-bottom, anchors, jump pill) and list windowing — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): the kill-session-on-stop env flag and its query invalidations — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): drive surfaces and mid-stream file-activity detection — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): first-seen timestamp stamping (display metadata for the desktop rows) — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): session auto-titling and the first-run seed auto-send — the desktop host keeps its own implementation until the re-plumb. +// Deliberately omitted (desktop-only): the model-key composer gate — compose `useAgentModelKeyStatus` in the skin instead. +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import {revalidateSessionMountsAtom, revalidateSessionRecordsAtom} from "@agenta/entities/session" +import {markTraceAsFresh} from "@agenta/entities/trace" +import { + agentShouldResumeAfterApproval, + buildAgentRequest, + type LiveAgentInteraction, +} from "@agenta/playground" +import {generateId} from "@agenta/shared/utils" +import {useChat} from "@ai-sdk/react" +import type {UIMessage} from "ai" +import {useSetAtom, useStore} from "jotai" + +import {filesToParts} from "../assets/files" +import {loadSessionMessages, type SessionTranscript} from "../assets/loadSession" +import {messageText, sideEffectingToolsInRange} from "../assets/rewind" +import {getMessageTraceId} from "../assets/trace" +import {parseAgentRunError, type ParsedRunError} from "../model/error" +import {deriveSessionRunStatus, type SessionRunStatus} from "../model/sessionStatus" +import { + buildTurnViewModels, + createExecutedToolIdentityCache, + type ClientToolPartPredicate, + type TurnViewModel, +} from "../model/turnViewModel" +import {expandedKeysForMessages, pruneExpandedAtom} from "../state/expandState" +import {clearSessionFresh, composerDraftBySession, isSessionFresh} from "../state/sessionEphemera" +import { + persistSessionMessagesAtom, + sessionMessagesAtom, + setSessionStatusAtom, +} from "../state/sessionMessages" +import {AgentChatTransport} from "../transport/AgentChatTransport" + +import {useAgentChatQueue, type QueuedMessage} from "./useAgentChatQueue" +import {useApprovalDock, type ApprovalDock} from "./useApprovalDock" + +/** A stream error/abort is already surfaced via `useChat`'s `onError` + the stamped in-chat + * error; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to a + * dev runtime-error overlay (F-033). */ +const ignoreStreamRejection = () => {} + +export interface SendInput { + text: string + files?: File[] +} + +/** The pure scan result of a rewind request; the skin renders any confirm UI and then calls + * `confirm()` — the hook never opens dialogs. */ +export interface RewindPlan { + /** Side-effecting tools that already ran at/after this message — a rewind cannot undo them; + * when non-empty the skin should confirm before calling `confirm()`. */ + sideEffects: string[] + /** For a user-message rewind: the message text to put back into the composer. */ + restoreText?: string + /** Execute the rewind: truncate before a user message, regenerate an assistant one. */ + confirm: () => void +} + +/** Settle a parked client tool (#4920). The hook maps this onto `addToolOutput` (success or + * error) and marks the resume live so the auto-resend fires. */ +export interface ToolOutputSettleInput { + toolName: string + toolCallId: string + output?: Record + errorText?: string +} + +export interface UseAgentConversationArgs { + entityId: string + sessionId: string + /** Registry-backed client-tool predicate for the turn render model; without it no part is + * treated as a client tool (they fold into the regular tool groups). */ + isClientToolPart?: ClientToolPartPredicate +} + +export interface AgentConversation { + messages: UIMessage[] + /** Raw stream status from `useChat`. */ + status: "ready" | "submitted" | "streaming" | "error" + /** Session-level run state (error > awaiting > running > idle) — also published to + * `sessionStatusAtomFamily` for session-list status dots. */ + runStatus: SessionRunStatus + /** Parsed reason of the current stream failure, when there is one. */ + error?: ParsedRunError + /** Pre-grouped per-turn view models (render items, status, empty-collapse, active turn). */ + turns: TurnViewModel[] + /** Send a user message (routes through the queue: sends now, or holds while busy/paused). */ + send: (input: SendInput) => Promise + /** Abort the in-flight stream and tag the last assistant turn as user-stopped. */ + stop: () => void + /** Re-run an assistant turn by message id (also the "Resend" action after a stop). */ + regenerate: (id: string) => void + /** Scan a rewind target; null while busy or for an unknown message. */ + rewind: (message: UIMessage) => RewindPlan | null + /** Server hydration for an uncached session is in flight — show a transcript skeleton. */ + isHydrating: boolean + /** No messages at all (skins combine with `isHydrating`/`historyUnavailable` for the hero). */ + isEmpty: boolean + /** A known session hydrated EMPTY from the server — its durable history was pruned or never + * persisted; show a notice rather than the new-chat hero. */ + historyUnavailable: boolean + /** The last assistant turn was user-stopped (cleared on the next send/regenerate). */ + stopped: boolean + /** Messages held while a turn is in flight, in FIFO order. */ + queued: QueuedMessage[] + removeQueued: (id: string) => void + clearQueue: () => void + /** Headless approval-dock state wired to the live-gate-aware response path. */ + approvals: ApprovalDock + /** Settle a parked client tool part (widgets call this; the resume predicate auto-resends). */ + sendToolOutput: (args: ToolOutputSettleInput) => void +} + +/** + * One agent conversation for a single session. A `useChat` whose transport is fed by the + * playground request builder (`buildAgentRequest`) — the entity supplies the config/auth/ + * references, the session id travels to the backend as `session_id`. Messages persist to + * localStorage (seeded on mount, written when the stream settles) so the session survives a + * reload / revision swap. Headless: every surface (bubbles, composer, dock, notices) is the + * skin's job; this hook owns the engine behavior only. + */ +export const useAgentConversation = ({ + entityId, + sessionId, + isClientToolPart, +}: UseAgentConversationArgs): AgentConversation => { + const store = useStore() + const persistMessages = useSetAtom(persistSessionMessagesAtom) + const setSessionStatus = useSetAtom(setSessionStatusAtom) + const revalidateSessionMounts = useSetAtom(revalidateSessionMountsAtom) + const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) + const pruneExpanded = useSetAtom(pruneExpandedAtom) + + // Whether the LAST assistant turn was user-stopped. You can only cancel the in-flight (last) + // turn, so this is a single boolean gated on position at render time. Cleared on the next + // send/resend. + const [stopped, setStopped] = useState(false) + // Seed once from the persisted store (read imperatively so our own writes don't feed back). + const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) + // Restored (not live-streamed) message ids — the orphaned-resume detection reads this, and a + // skin can use it to skip entrance animations for restored rows. + const restoredIdsRef = useRef>(new Set(initialMessages.map((m) => m.id))) + + // `useChat` pins its `Chat` (and thus this transport) for the life of the session `id`; it is + // NOT recreated when `entityId` changes. So the request builder must read the CURRENT entity + // through a ref — capturing `entityId` by value would send every turn with the revision that + // was displayed when the session first mounted. + const entityIdRef = useRef(entityId) + entityIdRef.current = entityId + + // Transport feeds the v6 stream request from the playground pipeline. `api` here is a + // placeholder that `prepareSendMessagesRequest` overrides per request. + const transport = useMemo( + () => + new AgentChatTransport({ + api: "", + prepareSendMessagesRequest: async ({messages, id}) => { + const req = await buildAgentRequest(entityIdRef.current, messages, { + sessionId: id ?? sessionId, + }) + if (!req) { + throw new Error( + "This agent workflow has no invocation URL — it can’t be run yet.", + ) + } + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, + }), + [sessionId], + ) + + // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. + const liveGateInteractionRef = useRef(null) + + const { + messages, + sendMessage, + status, + stop, + regenerate, + setMessages, + addToolApprovalResponse, + addToolOutput, + error, + } = useChat({ + id: sessionId, + messages: initialMessages, + transport, + // Coalesce stream deltas to ~1 UI commit / 50ms so a fast token stream doesn't drive a + // render per token; caps commit frequency independently of the per-commit memo win. + experimental_throttle: 50, + // Approve AND deny both resume — a deny-only decision must re-send so the runner + // gets the denial round-trip and the model continues (no `approval-responded` limbo). + sendAutomaticallyWhen: ({messages}) => { + const shouldDispatch = agentShouldResumeAfterApproval({ + messages, + liveInteraction: liveGateInteractionRef.current, + }) + if (shouldDispatch) liveGateInteractionRef.current = null + return shouldDispatch + }, + // The turn's trace may not be ingested yet when a row asks for its summary — marking it + // fresh lets the trace queries retry through the ingestion lag. A finished turn may also + // have written files: mark the session's drive data stale so every mount surface refetches. + onFinish: ({message}) => { + markTraceAsFresh(getMessageTraceId(message)) + revalidateSessionMounts(sessionId) + revalidateSessionRecords(sessionId) + }, + onError: (err) => { + // The error is stamped in-chat (effect below); swallow it here so an aborted/errored + // stream doesn't bubble unhandled to a dev overlay (F-033). + console.warn("[useAgentConversation] useChat error (rendered in-chat):", err) + }, + }) + + const busy = status === "submitted" || status === "streaming" + + // `messages`/`busy` change every commit; consumers that must stay referentially stable + // (`rewind`, the hydration/revalidation adoption guards) read them through refs instead. + const messagesRef = useRef(messages) + messagesRef.current = messages + const busyRef = useRef(busy) + busyRef.current = busy + + // Hybrid history: localStorage holds the cached conversation; the durable content lives in + // the backend record log. Cache-first — when this session opens with no locally-cached + // messages (never ran here, or after a storage clear), hydrate once from the server and seed. + // A to-be-hydrated session (empty local cache, not brand-new) reports `isHydrating` so the + // skin shows a transcript skeleton instead of the empty-state hero. + const [isHydrating, setIsHydrating] = useState( + () => initialMessages.length === 0 && !isSessionFresh(sessionId), + ) + // Set when server hydration for a KNOWN (non-fresh, uncached) session returns no records — + // its durable history was pruned by retention or never persisted. + const [historyUnavailable, setHistoryUnavailable] = useState(false) + useEffect(() => { + // A session created brand-new in this browser and not yet run has no backend records — + // skip the guaranteed-empty query (cleared on first send; after a reload it re-hydrates). + if (initialMessages.length > 0 || isSessionFresh(sessionId)) { + setIsHydrating(false) + return + } + // No persistent "already-hydrated" ref: the `cancelled` flag is the whole guard, so React + // StrictMode's mount→unmount→mount cycle re-runs the fetch (the first run is cancelled) + // instead of latching a ref that leaves the transcript blank. + let cancelled = false + // Post-restore revalidation: the first result may be the disk-restored log (paints + // instantly); when the guaranteed background refetch lands, adopt it under the same + // guards as the revalidate-on-open effect below — never mid-stream, only when ahead. + const adoptRefreshed = ({messages: freshMsgs, recordCount}: SessionTranscript) => { + if (cancelled || busyRef.current) return + if (freshMsgs.length <= messagesRef.current.length) return + freshMsgs.forEach((m) => restoredIdsRef.current.add(m.id)) + // The restore said "no records" but the server has some — clear the notice. + setHistoryUnavailable(false) + setMessages(freshMsgs) + persistMessages({id: sessionId, messages: freshMsgs, recordCount}) + } + loadSessionMessages(sessionId, adoptRefreshed) + .then((transcript) => { + if (cancelled) return + if (!transcript || transcript.messages.length === 0) { + // Known session, but the server has no records for it → history was pruned or + // never persisted. Flag it so the skin shows the "unavailable" notice. + setHistoryUnavailable(true) + return + } + transcript.messages.forEach((m) => restoredIdsRef.current.add(m.id)) + setMessages(transcript.messages) + persistMessages({ + id: sessionId, + messages: transcript.messages, + recordCount: transcript.recordCount, + }) + }) + .finally(() => { + if (!cancelled) setIsHydrating(false) + }) + return () => { + cancelled = true + } + // Seed once per mounted session; `sessionId` is stable for this instance. + }, [sessionId]) + + // Revalidate-on-open: a cached session paints instantly from localStorage; in the background + // we refetch the durable records ONCE and adopt the server transcript ONLY IF it's strictly + // ahead of what we're showing (a turn finished on another device). We never clobber a + // transcript that's live (`busyRef`), or that the server isn't strictly ahead of — so a local + // optimistic/unsent tail is safe. Reconciliation is by message COUNT, not content. + useEffect(() => { + if (initialMessages.length === 0 || isSessionFresh(sessionId)) return + // As above: no persistent ref, so StrictMode's double-mount re-runs the revalidation. + let cancelled = false + const adopt = (transcript: SessionTranscript | null) => { + if (cancelled || !transcript || transcript.messages.length === 0) return + const serverMsgs = transcript.messages + const prev = messagesRef.current + if (busyRef.current || serverMsgs.length <= prev.length) return + serverMsgs.forEach((m) => restoredIdsRef.current.add(m.id)) + setMessages(serverMsgs) + persistMessages({ + id: sessionId, + messages: serverMsgs, + recordCount: transcript.recordCount, + }) + } + // The first result may itself be the disk-restored records log; the callback re-applies + // the same guarded adoption when the guaranteed background revalidation lands. + loadSessionMessages(sessionId, adopt).then(adopt) + return () => { + cancelled = true + } + // Once per mounted session; `sessionId` is stable for this instance. + }, [sessionId]) + + // Send one released queued message. Stable (only depends on `sendMessage`) so the queue's + // release effect doesn't churn on every token. + const sendQueued = useCallback( + (item: QueuedMessage) => { + // A real send means this session has run — drop the never-run marker so a later + // cache-cleared reopen hydrates from the server. + clearSessionFresh(sessionId) + // Any actual send supersedes a prior user-stop, so clear the marker here (covers the + // queue-release path; the manual path also clears it in `send`). + setStopped(false) + sendMessage( + item.fileParts && item.fileParts.length + ? item.text + ? {text: item.text, files: item.fileParts} + : {files: item.fileParts} + : {text: item.text}, + ).catch(ignoreStreamRejection) + }, + [sendMessage, sessionId], + ) + + // Orphan detection for the queue's pre-resume hold: the tail is a RESTORED message (this + // mount never streamed it) shaped like "auto-resume imminent", and no gate was settled live + // in this mount. The SDK only evaluates `sendAutomaticallyWhen` on live events — never on + // mount — so this resume can't fire and must not hold the queue (AGE-3937). + const lastMessage = messages[messages.length - 1] + const resumeOrphaned = + !liveGateInteractionRef.current && + !!lastMessage && + restoredIdsRef.current.has(lastMessage.id) && + agentShouldResumeAfterApproval({messages}) + + // Queue messages typed while a turn is streaming or paused on a HITL approval; released + // one-by-one once the turn truly settles (never mid-approval). + const {queued, submit, removeQueued, clearQueue, hitlPending} = useAgentChatQueue({ + status, + messages, + stopped, + resumeOrphaned, + sendQueued, + sessionId, + }) + + // Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision + // made in THIS mount marks the resume as live — a restored approval-requested tail the user + // answers after a reload genuinely auto-resumes, so the queue's pre-resume hold applies. + const handleApprovalResponse = useCallback( + (args: {id: string; approved: boolean}) => { + liveGateInteractionRef.current = {kind: "approval", id: args.id} + addToolApprovalResponse(args) + }, + [addToolApprovalResponse], + ) + + const approvals = useApprovalDock({messages, respond: handleApprovalResponse}) + + // Settle a parked client tool (#4920). A widget calls this with the structured reference; + // `addToolOutput` matches the part by `toolCallId` on the last turn and the resume predicate + // auto-resends. `tool` is only the typed-tools key — matching is by id — so a cast onto the + // untyped UIMessage tool map is safe. + const sendToolOutput = useCallback( + ({toolName, toolCallId, output, errorText}: ToolOutputSettleInput) => { + liveGateInteractionRef.current = {kind: "client_tool", id: toolCallId} + if (errorText !== undefined) { + addToolOutput({ + state: "output-error", + tool: toolName as never, + toolCallId, + errorText, + }).catch(ignoreStreamRejection) + } else { + addToolOutput({ + tool: toolName as never, + toolCallId, + output: (output ?? {}) as never, + }).catch(ignoreStreamRejection) + } + }, + [addToolOutput], + ) + + // Publish this session's run state (single source of truth for session-list status dots). + // Precedence error > awaiting approval > running > idle. Reset to idle on unmount so a + // closed session keeps no stale dot. + const runStatus = deriveSessionRunStatus({error: !!error, hitlPending, busy}) + useEffect(() => { + setSessionStatus({id: sessionId, status: runStatus}) + }, [runStatus, sessionId, setSessionStatus]) + useEffect( + () => () => setSessionStatus({id: sessionId, status: "idle"}), + [sessionId, setSessionStatus], + ) + + // Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so + // it renders as an error bubble with the real reason (and persists with the session via the + // effect below), instead of a transient banner + a generic "no response". + useEffect(() => { + if (!error) return + const parsed = parseAgentRunError(error) + setMessages((prev) => { + const last = prev.length > 0 ? prev[prev.length - 1] : undefined + const existing = (last?.metadata as {runError?: {message?: string}} | undefined) + ?.runError + if (last?.role === "assistant") { + if (existing?.message === parsed.message) return prev // already stamped + const next = [...prev] + next[next.length - 1] = { + ...last, + metadata: {...(last.metadata as object | undefined), runError: parsed}, + } + return next + } + // No trailing assistant turn (failed before one existed) — add a minimal carrier. + return [ + ...prev, + { + id: `run-error-${generateId()}`, + role: "assistant", + parts: [], + metadata: {runError: parsed}, + } as (typeof prev)[number], + ] + }) + }, [error, setMessages]) + + // Persist the conversation whenever its stream settles (skip mid-stream). + useEffect(() => { + if (status === "streaming") return + persistMessages({id: sessionId, messages}) + }, [messages, status, sessionId, persistMessages]) + + // Bound the in-message expand-state store: on settle, drop entries whose owning message is + // gone (rewound / evicted / closed). Live = every persisted session's messages ∪ this active + // one. `store.get` reads without subscribing, so this adds no re-renders mid-stream. + useEffect(() => { + if (status === "streaming") return + const persisted = store.get(sessionMessagesAtom) + const live = new Set() + for (const sid in persisted) + for (const key of expandedKeysForMessages(persisted[sid])) live.add(key) + for (const key of expandedKeysForMessages(messages)) live.add(key) + pruneExpanded(live) + }, [messages, status, store, pruneExpanded]) + + // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── + const handleStop = useCallback(() => { + const last = messagesRef.current[messagesRef.current.length - 1] + if (last && last.role === "assistant") setStopped(true) + stop() + }, [stop]) + + // ── D9 teardown: abort the in-flight stream on unmount (session close / swap) ── + useEffect(() => { + return () => { + stop() + } + }, [sessionId, stop]) + + const send = useCallback( + async ({text, files}: SendInput) => { + const trimmed = text.trim() + const fileObjs = files ?? [] + if (!trimmed && fileObjs.length === 0) return + // Send what encoded. A file that cannot be read no longer takes the text and the + // other attachments down with it (`filesToParts` settles each file separately). + const encoded = fileObjs.length ? await filesToParts(fileObjs) : undefined + const fileParts = encoded?.parts.length ? encoded.parts : undefined + if (encoded?.rejections.length) { + console.warn("[useAgentConversation] attachments could not be read:", { + files: encoded.rejections.map((r) => r.name), + }) + } + // Clear any prior "stopped" marker — it's resolved by asking again. + setStopped(false) + // One path: `submit` sends now or queues behind held messages via the release gate. + submit({text: trimmed, fileParts}) + // The message left the composer — drop its persisted draft (per-session store). + composerDraftBySession.delete(sessionId) + }, + [submit, sessionId], + ) + + const regenerateTurn = useCallback( + (id: string) => { + setStopped(false) + regenerate({messageId: id}).catch(ignoreStreamRejection) + }, + [regenerate], + ) + + // Rewind scan: pure side-effect detection + a deferred `confirm()`. The skin owns the + // warning dialog (when `sideEffects` is non-empty) and the composer refill (`restoreText`). + const rewind = useCallback( + (message: UIMessage): RewindPlan | null => { + const msgs = messagesRef.current + if (busyRef.current) return null + const idx = msgs.findIndex((m) => m.id === message.id) + if (idx < 0) return null + const isUser = message.role === "user" + const sideEffects = sideEffectingToolsInRange(msgs.slice(idx)) + const confirm = () => { + if (isUser) { + // The skin calls this after its warning dialog, so `msgs`/`idx` are a + // snapshot from scan time. A revalidation adopted in that window would be + // thrown away by writing the stale array, so re-resolve against the live + // transcript and bail if the message is no longer in it. + const current = messagesRef.current + const at = current.findIndex((m) => m.id === message.id) + if (at < 0) return + setMessages(current.slice(0, at)) + } else { + regenerate({messageId: message.id}).catch(ignoreStreamRejection) + } + } + return {sideEffects, restoreText: isUser ? messageText(message) : undefined, confirm} + }, + [regenerate, setMessages], + ) + + // Per-mount executed-identity cache — the desktop's per-message toolSignature memo, + // recreated hook-side so the identity JSON.stringify doesn't re-run per streamed token. + const [executedFor] = useState(() => createExecutedToolIdentityCache()) + const turns = useMemo( + () => buildTurnViewModels(messages, {busy, executedFor, isClientToolPart}), + [messages, busy, executedFor, isClientToolPart], + ) + + const parsedError = useMemo(() => (error ? parseAgentRunError(error) : undefined), [error]) + + return { + messages, + status, + runStatus, + error: parsedError, + turns, + send, + stop: handleStop, + regenerate: regenerateTurn, + rewind, + isHydrating, + isEmpty: messages.length === 0, + historyUnavailable, + stopped, + queued, + removeQueued, + clearQueue, + approvals, + sendToolOutput, + } +} diff --git a/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts b/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts new file mode 100644 index 0000000000..563dd284a7 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts @@ -0,0 +1,115 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/hooks/useAgentModelKeyStatus.ts +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +// Adaptations: none — every import already resolves to an allowed package dep. +import {useMemo} from "react" + +import { + providerKeySetupDoneAtom, + standardSecretsAtom, + vaultSecretsQueryAtom, +} from "@agenta/entities/secret" +import {workflowMolecule} from "@agenta/entities/workflow" +import type {LlmProvider} from "@agenta/shared/types" +import {normalizeProviderFamily} from "@agenta/shared/utils" +import {useAtomValue} from "jotai" + +export interface AgentModelKeyStatus { + /** The model's provider family (e.g. "openai"), from the config's `agent.llm` ModelRef. */ + provider: string | null + /** The selected model id (display). */ + model: string | null + /** The selected harness type (e.g. "pi_core" / "claude"), from `agent.harness.kind`. */ + harness: string | null + /** Whether the project's vault holds a key for that provider. */ + hasKey: boolean + /** The canonical vault provider entry for the model's provider (to open the configure drawer). */ + providerEntry: LlmProvider | null + /** + * The project vault hasn't resolved yet (query pending or errored). `standardSecretsAtom` returns + * the static provider catalog with EMPTY keys until the vault query lands, so a reload would report + * every provider as keyless. Callers must NOT assert a missing key (block the composer / show the + * connect banner) while this is true — otherwise the gate flashes a false error on every reload. + */ + loading: boolean + /** + * The connect-a-model gate: resolved provider, vault loaded, vault holds NO secret of any kind + * (project-wide — not just this provider's), the agent isn't self-managed, and the user has never + * completed key setup before. Banner and composer-block consumers should both key off this. + */ + gateActive: boolean +} + +interface LlmRef { + provider?: unknown + model?: unknown + connection?: {mode?: unknown} | null +} + +interface HarnessRef { + kind?: unknown +} + +/** + * Model → provider → vault-key detection for an agent. The `agent.llm` value is a structured ModelRef + * carrying its `provider`; we check the project's vault (`standardSecretsAtom`) for a key for that + * provider. Harness (Pi/Claude) is a separate axis and NOT part of this check. + * + * The connect-model gate (`gateActive`) is project-wide, not per-provider: once the project has ANY + * vault secret, or the user has connected a key once before, or the agent is self-managed, the gate + * never fires again — "it's not our problem anymore". + */ +export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus { + const config = useAtomValue( + useMemo(() => workflowMolecule.selectors.configuration(entityId), [entityId]), + ) + const standardSecrets = useAtomValue(standardSecretsAtom) + // "Loaded" = the vault query produced an array (successful fetch). Pending/errored → `data` is + // undefined, so we treat the vault as unresolved and never assert a missing key from empty slots. + const vaultQuery = useAtomValue(vaultSecretsQueryAtom) + const loading = !Array.isArray(vaultQuery.data) + // Raw listSecrets rows (standard + custom provider + named), NOT the static standardSecrets + // catalog — that always has one row per known provider regardless of vault state. + const vaultEmpty = !loading && (vaultQuery.data as unknown[]).length === 0 + const keySetupDone = useAtomValue(providerKeySetupDoneAtom) + + return useMemo(() => { + const agent = (config as {agent?: {llm?: LlmRef; harness?: HarnessRef}} | null)?.agent + const llm = agent?.llm + const model = typeof llm?.model === "string" && llm.model ? llm.model : null + const harness = + typeof agent?.harness?.kind === "string" && agent.harness.kind + ? agent.harness.kind + : null + // Provider is stored on the ModelRef; fall back to a `provider/id` model prefix (Pi naming). + const provider = + typeof llm?.provider === "string" && llm.provider + ? llm.provider + : model?.includes("/") + ? model.split("/")[0] + : null + const selfManaged = llm?.connection?.mode === "self_managed" + + const p = normalizeProviderFamily(provider) + const providerEntry = p + ? (standardSecrets.find( + (secret) => + normalizeProviderFamily((secret.name ?? "").replace(/_api_key$/i, "")) === + p || normalizeProviderFamily(secret.title) === p, + ) ?? null) + : null + + const gateActive = + !loading && vaultEmpty && !selfManaged && !keySetupDone && !!providerEntry + + return { + provider, + model, + harness, + hasKey: !!providerEntry?.key, + providerEntry, + loading, + gateActive, + } + }, [config, standardSecrets, loading, vaultEmpty, keySetupDone]) +} diff --git a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts new file mode 100644 index 0000000000..7b23c92372 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts @@ -0,0 +1,101 @@ +// Assembled from the behavior half of +// web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx (2026-07-25): the +// `getPendingApprovals` extraction, the shown-set latch (`resolvingIds` freeze so a +// multi-gate resolve doesn't step through the batch), the `responding` reset on gate change, +// and the respond / approve-all fan-out. The desktop dock keeps its own chrome on top. +// Deliberately omitted (desktop-only): the "always allow this tool" grant (an app-layer +// config write-through), the friendly per-tool body registry, and the trace link — the skin +// supplies those around this hook's state. +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import type {UIMessage} from "ai" + +import {getPendingApprovals, type PendingApproval} from "../model/approvals" + +export interface UseApprovalDockArgs { + messages: UIMessage[] + /** Answer one gate — the host's approval-response path (which marks the resume live). */ + respond: (args: {id: string; approved: boolean}) => void +} + +export interface ApprovalDock { + /** The run is paused on at least one gate — the dock should be visible. */ + open: boolean + /** The gate to act on now (index 0 of the latched shown set); null when nothing is pending. */ + current: PendingApproval | null + /** How many gates the paused turn holds (the "1 of N" figure). */ + count: number + /** A fired decision hasn't settled yet — disable the action buttons. */ + responding: boolean + /** Answer the current gate. */ + respond: (approved: boolean) => void + /** Approve every pending gate in one step (the shown set is frozen while they settle). */ + approveAll: () => void +} + +/** + * Headless human-in-the-loop dock state: which gate is current, how many are pending, and the + * one-at-a-time / approve-all response fan-out. A turn can request several gates at once; we act + * on the first and let the SDK flip its state, which re-renders us onto the next — so + * `responding` resets whenever the current id changes. + */ +export const useApprovalDock = ({ + messages, + respond: onRespond, +}: UseApprovalDockArgs): ApprovalDock => { + const approvals = useMemo(() => getPendingApprovals(messages), [messages]) + const open = approvals.length > 0 + + // A resolve can answer SEVERAL gates at once ("Approve all"). Each response settles + // asynchronously (the SDK's serial job queue), so the pending set shrinks across renders; + // without a latch the dock would step through the batch ("1 of 3 → 1 of 2"). `resolvingIds` + // holds the gates we fired responses for; while any is still pending we FREEZE the shown set + // so the card holds steady and the dock closes in one step (or, if only some gates were + // covered, then steps to the uncovered remainder). + const [resolvingIds, setResolvingIds] = useState(null) + const resolving = + resolvingIds !== null && approvals.some((a) => resolvingIds.includes(a.approvalId)) + // Latch the last non-empty set so the card stays visible while the dock animates closed AND + // so a multi-gate resolve doesn't step through the batch. + const shownRef = useRef(approvals) + if (open && !resolving) shownRef.current = approvals + const shown = shownRef.current + const current = shown[0] ?? null + const count = shown.length + + const [responding, setResponding] = useState(false) + + // The current gate changed (we answered one, the next slid in) — re-enable. Held during a + // resolve (current is frozen), so it fires only on a real step or a new batch. + useEffect(() => { + setResponding(false) + }, [current?.approvalId]) + + // Once every gate we fired has settled (left the pending set), drop the latch — the dock then + // closes if nothing remains, or re-latches onto the uncovered gates (a mixed batch). + useEffect(() => { + if (resolvingIds !== null && !approvals.some((a) => resolvingIds.includes(a.approvalId))) { + setResolvingIds(null) + } + }, [approvals, resolvingIds]) + + const respond = useCallback( + (approved: boolean) => { + if (responding || !current) return + setResponding(true) + onRespond({id: current.approvalId, approved}) + }, + [responding, current, onRespond], + ) + + const approveAll = useCallback(() => { + if (responding || shown.length === 0) return + setResponding(true) + // Freeze the card so the dock doesn't step through the batch as each response settles — + // it holds "1 of N" and closes once all are answered (see `resolvingIds`). + setResolvingIds(shown.map((a) => a.approvalId)) + shown.forEach((a) => onRespond({id: a.approvalId, approved: true})) + }, [responding, shown, onRespond]) + + return {open, current, count, responding, respond, approveAll} +} diff --git a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts new file mode 100644 index 0000000000..dba4519773 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts @@ -0,0 +1,125 @@ +// Assembled from the attachment-state block of +// web/oss/src/components/AgentChatSlice/AgentConversation.tsx (2026-07-25): the per-session +// restore/mirror effect, `addFiles` (validateIncoming), `removeFile`, and the submit-time +// clear + `filesToParts` conversion. The desktop host keeps its own upload-item file type; +// this hook mirrors the same LOGIC over the package's neutral `PendingAttachment`. +// Deliberately omitted (desktop-only): the drag/drop DOM handlers and the tray open/close +// flag — the skin calls `add()` from its own drop/paste surfaces and derives visibility. +import {useCallback, useEffect, useRef, useState} from "react" + +import type {FileUIPart} from "ai" + +import { + type AttachmentLimits, + type AttachmentRejection, + DEFAULT_ATTACHMENT_LIMITS, + validateIncoming, +} from "../assets/attachmentRules" +import {filesToParts} from "../assets/files" +import {type PendingAttachment, toPendingAttachment} from "../model/attachments" +import {attachmentsBySession} from "../state/sessionEphemera" + +export interface UseComposerAttachmentsArgs { + /** Persist pending files under this session across pane remounts (route re-entry). */ + sessionId?: string + /** Guardrails; defaults to the shared limits (count / size / accepted types). */ + limits?: AttachmentLimits +} + +export interface ComposerAttachments { + /** Files staged for the next send, in add order. */ + files: PendingAttachment[] + /** Files turned away by the guardrails on the LAST add (too big, wrong type, over the count). */ + rejections: AttachmentRejection[] + /** The active guardrails, for tray copy ("up to N files, X MB each"). */ + limits: AttachmentLimits + /** The staged set is at the count cap — pickers should disable. */ + atMax: boolean + /** Run incoming files (picker / paste / drop) through the guardrails and stage the accepted. */ + add: (incoming: File[]) => void + /** Unstage one file by its dedup uid. */ + remove: (uid: string) => void + /** Drop everything staged (send consumed them, or the user reset the composer). */ + clear: () => void + /** Dismiss the inline rejection notices without touching the staged files. */ + dismissRejections: () => void + /** Encode the staged files as inline `file` parts for `sendMessage({text, files})`. */ + toParts: () => Promise +} + +/** + * Headless composer-attachment state for one session: staging, validation, per-session + * persistence, and the send-time encoding. The skin owns every surface (tray, drop overlay, + * paste) and calls `add`/`remove`/`clear`; on submit it awaits `toParts()` then `clear()`s. + */ +export const useComposerAttachments = ({ + sessionId, + limits = DEFAULT_ATTACHMENT_LIMITS, +}: UseComposerAttachmentsArgs = {}): ComposerAttachments => { + // Restored from the per-session store on remount (route re-entry, tab close/reopen) — + // pending attachments survive alongside the composer draft. Rejections stay transient. + const [files, setFiles] = useState(() => + sessionId ? (attachmentsBySession.get(sessionId) ?? []) : [], + ) + // `sessionId` is a prop. Today's only caller mounts one instance per session, but a caller + // that swapped it in place would carry the old session's staged files into the new one, and + // the mirror effect below would then write them under the new key. Re-seed during render so + // that effect never sees a mismatched pair. + const [seededFor, setSeededFor] = useState(sessionId) + if (sessionId !== seededFor) { + setSeededFor(sessionId) + setFiles(sessionId ? (attachmentsBySession.get(sessionId) ?? []) : []) + } + useEffect(() => { + if (!sessionId) return + if (files.length > 0) attachmentsBySession.set(sessionId, files) + else attachmentsBySession.delete(sessionId) + }, [files, sessionId]) + // Files turned away by the guardrails (too big, wrong type, over the count), shown inline. + const [rejections, setRejections] = useState([]) + + const atMax = files.length >= limits.maxCount + + // The staged count as of the last accepted batch, not the render closure. Two `add` calls + // in one tick (a paste and a drop, or a duplicated drop handler) both read the same + // `files.length`, so both compute the same remaining capacity and the staged set can pass + // `limits.maxCount`. Re-synced on every render so state remains the source of truth. + const countRef = useRef(files.length) + countRef.current = files.length + + /** Add files from picker / paste / programmatic sources through the guardrails. */ + const add = useCallback( + (incoming: File[]) => { + const {accepted, rejections: rej} = validateIncoming(incoming, countRef.current, limits) + if (accepted.length) { + countRef.current += accepted.length + setFiles((prev) => [...prev, ...accepted.map(toPendingAttachment)]) + } + setRejections(rej) + }, + [limits], + ) + + const remove = useCallback((uid: string) => { + setFiles((prev) => prev.filter((f) => f.uid !== uid)) + }, []) + + const clear = useCallback(() => { + setFiles([]) + setRejections([]) + }, []) + + const dismissRejections = useCallback(() => setRejections([]), []) + + // A file that cannot be read at submit time is surfaced through the same inline notices the + // guardrails use, and the readable ones still go out — losing the whole message because one + // attachment went missing is worse than sending without it. + const toParts = useCallback(async () => { + if (!files.length) return [] + const {parts, rejections: unreadable} = await filesToParts(files.map((f) => f.file)) + if (unreadable.length) setRejections((prev) => [...prev, ...unreadable]) + return parts + }, [files]) + + return {files, rejections, limits, atMax, add, remove, clear, dismissRejections, toParts} +} diff --git a/web/packages/agenta-chat/src/index.ts b/web/packages/agenta-chat/src/index.ts new file mode 100644 index 0000000000..0b940b89c7 --- /dev/null +++ b/web/packages/agenta-chat/src/index.ts @@ -0,0 +1,6 @@ +export * from "./model" +export * from "./assets" +export * from "./transport" +export * from "./state" +export * from "./hooks" +export * from "./skin" diff --git a/web/packages/agenta-chat/src/model/actions.ts b/web/packages/agenta-chat/src/model/actions.ts new file mode 100644 index 0000000000..c70fedc22d --- /dev/null +++ b/web/packages/agenta-chat/src/model/actions.ts @@ -0,0 +1,10 @@ +import type {ReactNode} from "react" + +/** A neutral toolbar action for a message row (copy, retry, rewind, ...). Skins map this to + * their own markup — this type carries no rendering assumptions. */ +export interface MessageAction { + key: string + label: string + icon?: ReactNode + onClick: () => void +} diff --git a/web/packages/agenta-chat/src/model/approvals.ts b/web/packages/agenta-chat/src/model/approvals.ts new file mode 100644 index 0000000000..28de88a8d5 --- /dev/null +++ b/web/packages/agenta-chat/src/model/approvals.ts @@ -0,0 +1,41 @@ +import type {ToolUIPart, UIMessage} from "ai" + +import {isToolPart, partToolName} from "./parts" + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +export interface PendingApproval { + approvalId: string + toolName: string + input: unknown +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +interface ApprovalRef { + id: string +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +/** + * Approvals the run is currently blocked on. HITL only ever pauses the LAST assistant turn (see + * `isHitlPending`), so we read pending tool gates off that turn — a turn can request several at + * once (parallel tool calls), so this returns all of them in order. + */ +export const getPendingApprovals = (messages: UIMessage[]): PendingApproval[] => { + const last = messages[messages.length - 1] + if (!last || last.role !== "assistant") return [] + const out: PendingApproval[] = [] + for (const part of last.parts ?? []) { + const p = part as ToolUIPart + const approval = (p as {approval?: ApprovalRef}).approval + if (isToolPart(p.type as string) && p.state === "approval-requested" && approval?.id) { + out.push({approvalId: approval.id, toolName: partToolName(p), input: p.input}) + } + } + return out +} diff --git a/web/packages/agenta-chat/src/model/attachments.ts b/web/packages/agenta-chat/src/model/attachments.ts new file mode 100644 index 0000000000..14dcd26fab --- /dev/null +++ b/web/packages/agenta-chat/src/model/attachments.ts @@ -0,0 +1,17 @@ +/** A file staged for upload before it's sent — neutral shape, independent of the desktop + * upload-item type. `uid` mirrors the desktop composer's dedup key so callers can migrate + * without changing behavior. */ +export interface PendingAttachment { + file: File + uid: string + name: string +} + +// uid formula mirrored from `toUploadFile` in +// web/oss/src/components/AgentChatSlice/AgentConversation.tsx (2026-07-25); the desktop +// composer keeps its own upload-item type, so only the dedup key is shared here. +export const toPendingAttachment = (file: File): PendingAttachment => ({ + file, + uid: `${file.name}-${file.lastModified}-${file.size}`, + name: file.name, +}) diff --git a/web/packages/agenta-chat/src/model/error.ts b/web/packages/agenta-chat/src/model/error.ts new file mode 100644 index 0000000000..ef60bbc629 --- /dev/null +++ b/web/packages/agenta-chat/src/model/error.ts @@ -0,0 +1,40 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/AgentConversation.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the +// re-plumb PR deletes it. Keep byte-parity if either side changes. +export interface ParsedRunError { + message: string + code?: number +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/AgentConversation.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the +// re-plumb PR deletes it. Keep byte-parity if either side changes. +/** + * Best-effort human reason from a useChat stream error. The server may hand us a clean string + * ("Agent run failed: …") or a JSON envelope (`{status:{code,message,…}}` / `{message}`) — pull + * the message out of either and drop the stacktrace / docs-url noise so it reads cleanly inline. + */ +export const parseAgentRunError = (err: unknown): ParsedRunError => { + const raw = + err instanceof Error ? err.message : typeof err === "string" ? err : String(err ?? "") + const fallback = raw.trim() || "The agent run failed." + try { + const obj = JSON.parse(raw) as Record + const status = (obj?.status && typeof obj.status === "object" ? obj.status : obj) as Record< + string, + unknown + > + const message = + typeof status?.message === "string" + ? status.message + : typeof obj?.message === "string" + ? (obj.message as string) + : null + if (message) { + return {message, code: typeof status?.code === "number" ? status.code : undefined} + } + } catch { + // raw isn't JSON — it's already the human message. + } + return {message: fallback} +} diff --git a/web/packages/agenta-chat/src/model/grouping.ts b/web/packages/agenta-chat/src/model/grouping.ts new file mode 100644 index 0000000000..81b8a2eaef --- /dev/null +++ b/web/packages/agenta-chat/src/model/grouping.ts @@ -0,0 +1,28 @@ +import type {UIMessage} from "ai" + +export interface TurnGrouping { + lastUserIndex: number + activeStart: number + reserveActive: boolean +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/AgentConversation.tsx (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// Keep byte-parity if either side changes. Adapted only to take `messages` as a parameter and +// return the three values instead of assigning them to component-scope consts. +// Group the ACTIVE turn (the last user message + its response) into one wrapper that carries the +// fill. Keeping the fill on a STABLE element — not hopping it from the user bubble to the assistant +// bubble when the answer arrives — avoids the mid-stream layout jump. +export const getTurnGrouping = (messages: UIMessage[]): TurnGrouping => { + const lastUserIndex = (() => { + for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return i + return -1 + })() + const activeStart = lastUserIndex >= 0 ? lastUserIndex : messages.length + // The fill = min-h-full on the active turn whenever there's PRIOR conversation above it (so the + // question can sit at the top). Derived from layout, NOT from `busy` — so it persists when the turn + // settles instead of being yanked away (which clamped the scroll and jumped the view). + const reserveActive = activeStart > 0 + + return {lastUserIndex, activeStart, reserveActive} +} diff --git a/web/packages/agenta-chat/src/model/index.ts b/web/packages/agenta-chat/src/model/index.ts new file mode 100644 index 0000000000..06928b76ba --- /dev/null +++ b/web/packages/agenta-chat/src/model/index.ts @@ -0,0 +1,11 @@ +export * from "./attachments" +export * from "./actions" +export * from "./parts" +export * from "./error" +export * from "./toolSummary" +export * from "./approvals" +export * from "./turnStatus" +export * from "./renderModel" +export * from "./grouping" +export * from "./sessionStatus" +export * from "./turnViewModel" diff --git a/web/packages/agenta-chat/src/model/parts.ts b/web/packages/agenta-chat/src/model/parts.ts new file mode 100644 index 0000000000..60b40443e1 --- /dev/null +++ b/web/packages/agenta-chat/src/model/parts.ts @@ -0,0 +1,56 @@ +import type {ToolUIPart, UIMessage} from "ai" + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the +// re-plumb PR deletes it. Keep byte-parity if either side changes. +export const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynamic-tool" + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the +// re-plumb PR deletes it. Keep byte-parity if either side changes. +/** Dedup key for a tool call. Stringifies its input, which can be large — call it sparingly. */ +export const toolIdentity = (p: ToolUIPart): string => { + let inputKey = "" + try { + inputKey = JSON.stringify((p as {input?: unknown}).input ?? null) + } catch { + inputKey = "" + } + return `${p.type}::${inputKey}` +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/AgentConversation.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the +// re-plumb PR deletes it. Keep byte-parity if either side changes. +/** A part the transcript actually renders — non-empty text/reasoning, files, sources, tools. */ +export const isVisiblePart = (p: UIMessage["parts"][number]): boolean => + (p.type === "text" && Boolean((p as {text?: string}).text?.trim())) || + (p.type === "reasoning" && Boolean((p as {text?: string}).text?.trim())) || + p.type === "file" || + p.type === "source-url" || + p.type.startsWith("tool-") || + p.type === "dynamic-tool" + +// Copied verbatim from web/oss/src/components/AgentChatSlice/AgentConversation.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the +// re-plumb PR deletes it. Keep byte-parity if either side changes. +/** A settled assistant turn with no content at all — no answer, reasoning, tool, file, or + * source part. Mirrors AgentMessage's `!hasContent`; used to collapse a run of "no response" + * bubbles (e.g. repeated failed runs) down to the first one. */ +export const isEmptyAssistantTurn = (m: UIMessage): boolean => + m.role === "assistant" && !m.parts.some(isVisiblePart) + +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/toolDisplay.ts +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the +// re-plumb PR deletes it. Keep byte-parity if either side changes. +/** Wire name of a tool part. `dynamic-tool` carries it on `toolName`; typed parts encode it as + * `tool-`. */ +export const partToolName = (part: ToolUIPart): string => { + // `dynamic-tool` parts reach here via the grouping cast in AgentMessage but sit outside + // ToolUIPart's static union — read `type` as a string. + const type = part.type as string + if (type === "dynamic-tool") { + return (part as {toolName?: string}).toolName || "tool" + } + return type.replace(/^tool-/, "") +} diff --git a/web/packages/agenta-chat/src/model/renderModel.ts b/web/packages/agenta-chat/src/model/renderModel.ts new file mode 100644 index 0000000000..e1145e468f --- /dev/null +++ b/web/packages/agenta-chat/src/model/renderModel.ts @@ -0,0 +1,82 @@ +import type {ToolUIPart, UIMessage} from "ai" + +import {isToolPart, toolIdentity} from "./parts" + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +// Tools can be interleaved with text / reasoning, so fold only *consecutive* tool parts +// into one ToolActivity group (a run of calls reads as a single "Used N tools" line). +export type RenderItem = + | {kind: "part"; part: UIMessage["parts"][number]; index: number} + | {kind: "tools"; parts: ToolUIPart[]; index: number} + | {kind: "clientTool"; part: ToolUIPart; index: number} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. Adapted only to take `parts` as a +// parameter instead of reading them off `useMemo`'s closure. +// Dedup set of executed tool calls (by input identity), memoized on a cheap tool-parts signature +// (id + state) that stays STABLE while text streams — so the tool-input JSON.stringify doesn't +// re-run on every streamed token of a tool-heavy turn. Hoisted above the early returns below to +// keep hook order stable. +export const executedToolIdentities = (parts: UIMessage["parts"]): Set => + new Set( + parts + .filter( + (p) => + isToolPart(p.type) && + ((p as ToolUIPart).state === "output-available" || + (p as ToolUIPart).state === "output-error"), + ) + .map((p) => toolIdentity(p as ToolUIPart)), + ) + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. Adapted only to take the executed set +// as a parameter instead of closing over it. +// A HITL-approved tool's part LINGERS in `approval-responded` (a perpetual spinner, no output): +// the cold-replay runner re-issues the approved call under a FRESH id, so its execution output +// lands on a SEPARATE sibling part. Drop the answered gate once its executed sibling exists (same +// tool + same input), so the turn shows the single completed call with its output — not a stuck +// spinner beside a duplicate. Until the execution settles, the gate stays (it is genuinely +// in-flight). +export const isSupersededGate = (part: ToolUIPart, executed: Set): boolean => + part.state === "approval-responded" && executed.has(toolIdentity(part)) + +export interface BuildTurnRenderItemsOptions { + executed: Set + /** Registry-backed check; the desktop closes over {isStreaming, isLastMessage} + renderMap — see AgentMessage.tsx. */ + isClientToolPart: (part: ToolUIPart) => boolean +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. Adapted to take `parts` and the +// registry-backed client-tool predicate as parameters, so this layer stays registry-free. +export const buildTurnRenderItems = ( + parts: UIMessage["parts"], + {executed, isClientToolPart}: BuildTurnRenderItemsOptions, +): RenderItem[] => { + const renderItems: RenderItem[] = [] + parts.forEach((part, i) => { + if (isToolPart(part.type)) { + // The answered gate whose execution already landed on a sibling part — drop it so the + // turn doesn't show a stuck approval spinner beside the real, completed call. + if (isSupersededGate(part as ToolUIPart, executed)) return + // A browser-fulfilled client tool (#4920) renders as its own widget/chip, NOT folded + // into the "Used N tools" group — so it breaks any current tool run. + if (isClientToolPart(part as ToolUIPart)) { + renderItems.push({kind: "clientTool", part: part as ToolUIPart, index: i}) + return + } + const last = renderItems[renderItems.length - 1] + if (last && last.kind === "tools") last.parts.push(part as ToolUIPart) + else renderItems.push({kind: "tools", parts: [part as ToolUIPart], index: i}) + return + } + renderItems.push({kind: "part", part, index: i}) + }) + return renderItems +} diff --git a/web/packages/agenta-chat/src/model/sessionStatus.ts b/web/packages/agenta-chat/src/model/sessionStatus.ts new file mode 100644 index 0000000000..a3aa22705e --- /dev/null +++ b/web/packages/agenta-chat/src/model/sessionStatus.ts @@ -0,0 +1,22 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/state/sessions.ts (2026-07-25); the +// OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. Keep +// byte-parity if either side changes. +export type SessionRunStatus = "idle" | "running" | "awaiting" | "error" + +export interface SessionRunStatusInputs { + error: boolean + hitlPending: boolean + busy: boolean +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/AgentConversation.tsx (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// Keep byte-parity if either side changes. Adapted only to take {error, hitlPending, busy} as a +// parameter object instead of reading them off component-scope hook state. +// Precedence error > awaiting approval > running > idle. +export const deriveSessionRunStatus = ({ + error, + hitlPending, + busy, +}: SessionRunStatusInputs): SessionRunStatus => + error ? "error" : hitlPending ? "awaiting" : busy ? "running" : "idle" diff --git a/web/packages/agenta-chat/src/model/toolSummary.ts b/web/packages/agenta-chat/src/model/toolSummary.ts new file mode 100644 index 0000000000..4db33ea3d3 --- /dev/null +++ b/web/packages/agenta-chat/src/model/toolSummary.ts @@ -0,0 +1,100 @@ +import type {ToolUIPart} from "ai" + +// The OSS original imports these rather than redefining them; the extraction introduced a +// second copy. They must match services/runner/src/tracing/otel.ts exactly, and a drift here +// turns every "skipped, not failed" tool row back into a plain error. +import {stripFence} from "../assets/toolFormat" +import { + APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX, + DEFERRED_NOT_EXECUTED_PREFIX, +} from "../assets/transcriptToMessages" + +/** Minimal structural shape rowSummary needs off a registered tool display — a normalized human + * summary hook, without pulling in the OSS ToolDisplay registry type. */ +export interface ToolSummaryDisplay { + summary?: (input: unknown, output: unknown) => string | null | undefined +} + +// Adaptation (2026-07-25): stripFence now lives canonically in ../assets/toolFormat (copied from +// the OSS asset of the same name) — re-exported here so existing imports of it from this module +// keep working, without a second definition. +export {stripFence} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +// A tool has finished when it produced output, errored, or was denied. Everything else +// (preparing input, running, awaiting/just-answered an approval) is still in flight. +const SETTLED = new Set(["output-available", "output-error", "output-denied"]) +export const isSettled = (state: string) => SETTLED.has(state) + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +export const isDeferredError = (errorText: string | undefined): boolean => + !!errorText && errorText.startsWith(DEFERRED_NOT_EXECUTED_PREFIX) +export const isUnknownResultError = (errorText: string | undefined): boolean => + !!errorText && errorText.startsWith(APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX) + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +export const isNotHandledOutput = (output: unknown): boolean => + !!output && + typeof output === "object" && + (output as {status?: unknown}).status === "not_handled" + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +/** + * Derive a single human line from a tool's output. Output shape is arbitrary, so this stays + * conservative: it recognises the common shapes and otherwise returns null (the row then shows + * just the tool name + status). Never throws — the full payload lives in the trace drawer. + */ +export const summarizeOutput = (output: unknown): string | null => { + if (output == null) return null + if (Array.isArray(output)) { + return `${output.length} result${output.length === 1 ? "" : "s"}` + } + if (typeof output === "string") { + const s = stripFence(output).trim().replace(/\s+/g, " ") + if (!s) return null + return s.length > 80 ? `${s.slice(0, 80)}…` : s + } + if (typeof output === "object") { + const o = output as Record + for (const k of ["summary", "result", "content", "text", "message", "title"]) { + const v = o[k] + if (typeof v === "string" && v.trim()) return summarizeOutput(v) + } + const keys = Object.keys(o) + if (keys.length === 0) return null + return `${keys.length} field${keys.length === 1 ? "" : "s"}` + } + return String(output) +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +export const rowSummary = (part: ToolUIPart, display?: ToolSummaryDisplay): string | null => { + if (part.state === "output-available") { + if (isNotHandledOutput(part.output)) return "not handled by this client" + // A registered per-tool summary wins; run it through the generic normalizer for the + // same whitespace/length clamp. Falls back to shape heuristics when it returns null. + const custom = display?.summary?.((part as {input?: unknown}).input, part.output) + if (typeof custom === "string" && custom.trim()) { + return summarizeOutput(custom) ?? summarizeOutput(part.output) + } + return summarizeOutput(part.output) + } + if (part.state === "output-error") { + const errorText = (part as {errorText?: string}).errorText + if (isDeferredError(errorText)) return "waiting on another approval" + if (isUnknownResultError(errorText)) return "approved, result unknown" + return "failed" + } + if (part.state === "output-denied") return "denied" + return null +} diff --git a/web/packages/agenta-chat/src/model/turnStatus.ts b/web/packages/agenta-chat/src/model/turnStatus.ts new file mode 100644 index 0000000000..9de69fe67d --- /dev/null +++ b/web/packages/agenta-chat/src/model/turnStatus.ts @@ -0,0 +1,69 @@ +import type {UIMessage} from "ai" + +import {isToolPart} from "./parts" + +export interface TurnStatusContext { + isUser: boolean + isStreaming: boolean + traceError?: string | null + runError?: string | null +} + +export interface TurnStatus { + hasAnswer: boolean + hasReasoning: boolean + hasContent: boolean + noResponse: boolean + errorText: string | null + showError: boolean + isError: boolean +} + +// Copied verbatim from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. Adapted only to take `message` + +// `traceError`/`runError` as parameters instead of reading component props/hooks directly. +/** + * The seven status derivations AgentMessage computes per turn (answer/reasoning/content + * presence, "no response", and the error-surfacing rules), as a pure function. + */ +export const deriveTurnStatus = ( + message: UIMessage, + {isUser, isStreaming, traceError, runError}: TurnStatusContext, +): TurnStatus => { + // "Answer" = anything the user is meant to read as a reply (text / tool / file / source). + // Reasoning alone is NOT an answer — a turn that only thought hasn't responded. + const hasAnswer = message.parts.some( + (p) => + (p.type === "text" && (p as {text?: string}).text) || + isToolPart(p.type) || + p.type === "file" || + p.type === "source-url", + ) + const hasReasoning = message.parts.some( + (p) => p.type === "reasoning" && (p as {text?: string}).text, + ) + const hasContent = hasAnswer || hasReasoning + + // A settled assistant turn (NOT the one being generated) with no answer — only a thought, + // or nothing — means the model ended without responding. Surface it so the bubble doesn't + // read as frozen/broken. Keyed on `isStreaming`, not the conversation-level `busy`, so + // earlier answer-less turns don't all light up while a later turn streams. + const noResponse = !isUser && !isStreaming && !hasAnswer + + // A trace-leaf error means a model/tool call failed. When the turn still produced an answer, + // the agent recovered from it — that failure belongs inline in ToolActivity ("· N failed"), + // NOT as a run failure. So trust `traceError` only on an answer-less turn (the swallowed + // quota/model error it was written for). A stream death (`runError`) is a real run failure + // even with partial output, so it always counts. + const errorText = (noResponse ? traceError || runError : runError) ?? null + // Surface a settled-turn error even when the model emitted partial output before the stream + // died. (`isError` stays answer-less-only so the *whole* bubble only turns red when there's + // nothing else to show.) + const showError = !isStreaming && !!errorText + // A settled no-answer turn whose trace recorded an error → render the bubble itself as a + // failure (red), with the message inline — not a nested alert box. + const isError = noResponse && showError + + return {hasAnswer, hasReasoning, hasContent, noResponse, errorText, showError, isError} +} diff --git a/web/packages/agenta-chat/src/model/turnViewModel.ts b/web/packages/agenta-chat/src/model/turnViewModel.ts new file mode 100644 index 0000000000..70f948da7a --- /dev/null +++ b/web/packages/agenta-chat/src/model/turnViewModel.ts @@ -0,0 +1,131 @@ +// Assembled from web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx and +// AgentConversation.tsx (2026-07-25): the per-turn derivations the desktop computes inline — +// the tool-parts signature memo key, the executed-identity dedup set, the render-item folding, +// the status derivation, the empty-turn collapse gate, and the borrowed turn trace for user +// rows. Pure so the conversation hook can memoize the whole list per commit. +import type {ToolUIPart, UIMessage} from "ai" + +import {getMessageRunError, getMessageTraceId} from "../assets/trace" + +import {getTurnGrouping} from "./grouping" +import {isEmptyAssistantTurn, isToolPart} from "./parts" +import {buildTurnRenderItems, executedToolIdentities, type RenderItem} from "./renderModel" +import {deriveTurnStatus, type TurnStatus} from "./turnStatus" + +/** + * The cheap signature the desktop memoizes `executedToolIdentities` on: tool-call id + state + * per tool part. It stays STABLE while text streams, so the tool-input JSON.stringify behind + * the identity set doesn't re-run on every streamed token of a tool-heavy turn. + */ +export const toolPartsSignature = (parts: UIMessage["parts"]): string => + parts + .filter((p) => isToolPart(p.type)) + .map((p) => `${(p as ToolUIPart).toolCallId ?? ""}:${(p as ToolUIPart).state ?? ""}`) + .join("|") + +/** + * Recreates the desktop's per-message `useMemo(executedToolIdentities, [toolSignature])` + * outside a component: one cache instance per conversation mount, keyed by message id and + * invalidated only when that message's tool signature changes. + */ +export const createExecutedToolIdentityCache = (): ((message: UIMessage) => Set) => { + const cache = new Map}>() + return (message) => { + const sig = toolPartsSignature(message.parts) + const hit = cache.get(message.id) + if (hit && hit.sig === sig) return hit.executed + const executed = executedToolIdentities(message.parts) + cache.set(message.id, {sig, executed}) + return executed + } +} + +/** Registry-backed client-tool predicate, parameterized: the skin closes over its widget + * registry + render map the way the desktop message component does. */ +export type ClientToolPartPredicate = ( + part: ToolUIPart, + ctx: {isStreaming: boolean; isLastMessage: boolean}, +) => boolean + +export interface TurnViewModel { + message: UIMessage + /** Position in the conversation (mirrors the messages array). */ + index: number + isUser: boolean + isLast: boolean + /** Part of the active turn group (the last user message + its response). */ + isActive: boolean + /** This is the turn currently being generated. */ + isStreamingTurn: boolean + /** Stream-side status (answer/reasoning presence, no-response, run-error surfacing). The + * trace-side error refinement stays per-turn in the skin, where the trace summary loads. */ + status: TurnStatus + /** Pre-folded render items (tool grouping, superseded-gate dedup, client-tool split). */ + items: RenderItem[] + /** The previous message is a settled, contentless assistant turn. */ + precededByEmptyAssistant: boolean + /** Collapse this row entirely — a repeated empty "no response" turn after another one. */ + hidden: boolean + /** The turn's own trace id (assistant turns). */ + traceId?: string + /** A user turn borrows the paired (next) assistant turn's trace for its timestamp. */ + turnTraceId?: string +} + +export interface BuildTurnViewModelsContext { + /** The conversation is submitted/streaming (turn-level streaming derives from position). */ + busy: boolean + /** Executed-identity resolver — pass a `createExecutedToolIdentityCache()` instance. */ + executedFor: (message: UIMessage) => Set + /** Defaults to "nothing is a client tool" — parts fold into the regular tool groups. */ + isClientToolPart?: ClientToolPartPredicate +} + +/** Derive the full per-turn view model list for one conversation commit. */ +export const buildTurnViewModels = ( + messages: UIMessage[], + {busy, executedFor, isClientToolPart}: BuildTurnViewModelsContext, +): TurnViewModel[] => { + const {activeStart} = getTurnGrouping(messages) + return messages.map((message, index) => { + const isUser = message.role === "user" + const isLast = index === messages.length - 1 + const isStreamingTurn = busy && isLast + const status = deriveTurnStatus(message, { + isUser, + isStreaming: isStreamingTurn, + runError: getMessageRunError(message) ?? null, + traceError: null, + }) + const precededByEmptyAssistant = index > 0 && isEmptyAssistantTurn(messages[index - 1]) + // The empty-turn collapse: only a truly-empty, non-error turn that follows another + // empty turn is hidden (the first of the run still renders as "no response"). + const hidden = + status.noResponse && !status.showError && !status.hasContent && precededByEmptyAssistant + const executed = executedFor(message) + const items = buildTurnRenderItems(message.parts, { + executed, + isClientToolPart: (part) => + isClientToolPart + ? isClientToolPart(part, {isStreaming: isStreamingTurn, isLastMessage: isLast}) + : false, + }) + const traceId = getMessageTraceId(message) + const turnTraceId = + isUser && messages[index + 1] ? getMessageTraceId(messages[index + 1]) : undefined + return { + message, + index, + isUser, + isLast, + isActive: index >= activeStart, + isStreamingTurn, + status, + items, + precededByEmptyAssistant, + hidden, + traceId, + turnTraceId, + } + }) +} diff --git a/web/packages/agenta-chat/src/skin/index.ts b/web/packages/agenta-chat/src/skin/index.ts new file mode 100644 index 0000000000..b35c59aaa5 --- /dev/null +++ b/web/packages/agenta-chat/src/skin/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./registry" diff --git a/web/packages/agenta-chat/src/skin/registry.ts b/web/packages/agenta-chat/src/skin/registry.ts new file mode 100644 index 0000000000..decaf66dd1 --- /dev/null +++ b/web/packages/agenta-chat/src/skin/registry.ts @@ -0,0 +1,119 @@ +/** + * Skin registration store + resolvers (WP3a-C5). + * + * The store starts EMPTY. The OSS registries (`clientTools/registry.tsx`, `approvals/registry.tsx`, + * `assets/toolDisplay.ts`) remain the desktop chat's own store — byte-untouched, per the plan's + * COPY-mode banner — until the desktop re-plumb PR switches them onto `registerChatSkin`. Until + * then, only a skin that calls `registerChatSkin` (mobile shadcn first, WP3b) populates any entry + * here; nothing in this package calls it. + */ +import {parseGatewayToolName} from "@agenta/entities/workflow/commitDiff" + +import type { + ApprovalBodyEntry, + ChatSkinRegistration, + ClientToolMeta, + ClientToolWidget, + ResolvedToolDisplay, + ToolDisplayEntry, + ToolKind, +} from "./types" + +interface RegistrationStore { + clientTools: { + byRenderKind: Record + byToolName: Record + } + approvals: Record + toolDisplay: Record +} + +const store: RegistrationStore = { + clientTools: {byRenderKind: {}, byToolName: {}}, + approvals: {}, + toolDisplay: {}, +} + +/** + * Merge a skin's contribution into the shared store. Merge semantics: per key, the LATEST + * registration wins (a later call's entry overwrites an earlier call's entry for the same key); + * keys the new registration doesn't mention are left untouched. Registering `{}` or omitting a + * sub-map is a no-op for that sub-map. + */ +export const registerChatSkin = (skin: ChatSkinRegistration): void => { + if (skin.clientTools?.byRenderKind) { + Object.assign(store.clientTools.byRenderKind, skin.clientTools.byRenderKind) + } + if (skin.clientTools?.byToolName) { + Object.assign(store.clientTools.byToolName, skin.clientTools.byToolName) + } + if (skin.approvals) Object.assign(store.approvals, skin.approvals) + if (skin.toolDisplay) Object.assign(store.toolDisplay, skin.toolDisplay) +} + +/** + * Resolve the widget for a client tool, or `undefined` when none is registered. Same precedence as + * OSS `resolveClientToolHandler`: `render.kind` first (the finer dispatch axis), then `toolName`. + */ +export const resolveClientToolWidget = ( + meta: Pick, +): ClientToolWidget | undefined => { + if (meta.renderKind && store.clientTools.byRenderKind[meta.renderKind]) { + return store.clientTools.byRenderKind[meta.renderKind] + } + return store.clientTools.byToolName[meta.toolName] +} + +/** Whether this client tool has a dedicated widget registered (used to route known tools in every + * state, mirroring OSS `hasClientToolHandler`). */ +export const hasClientToolWidget = ( + meta: Pick, +): boolean => resolveClientToolWidget(meta) !== undefined + +/** Resolve the renderer for an approval, or `undefined` for the generic card (mirrors OSS + * `resolveApprovalRenderer`, which returns `null` for the same miss). */ +export const resolveApprovalBody = (toolName: string): ApprovalBodyEntry | undefined => + store.approvals[toolName] + +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/toolDisplay.ts (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// Keep byte-parity if either side changes. (`parseGatewayToolName` itself is NOT copied — it +// already lives in `@agenta/entities/workflow/commitDiff`, an existing package dependency, and is +// imported directly above.) +// Adaptations: `resolveToolDisplay` below also lets a registered entry override `kind` +// (`override?.kind ?? parsed.kind`), which the OSS `ToolDisplayOverride` cannot do — a +// deliberate extension over the OSS chain for skin flexibility. +const parseNameShape = (raw: string): {label: string; source?: string; kind: ToolKind} => { + // mcp__{server}__{tool} → tool from "Server · MCP". + if (raw.startsWith("mcp__")) { + const parts = raw.split("__").filter(Boolean) + const tool = parts[parts.length - 1] + const server = parts.length >= 3 ? parts[1] : undefined + return { + label: parseGatewayToolName(tool).label, + source: server ? `${parseGatewayToolName(server).label} · MCP` : "MCP", + kind: "mcp", + } + } + const parsed = parseGatewayToolName(raw) + return {...parsed, kind: parsed.source ? "gateway" : "platform"} +} + +/** + * Resolve display info for a raw runtime tool name. Pure and total — never throws. Reproduces the + * OSS `resolveToolDisplay` fallback chain: a registered entry overrides label/source/kind/summary + * piecewise; anything it doesn't override falls back to the name-shape heuristics above + * (`mcp__…` server/tool split, gateway `tools__provider__integration__ACTION` parsing, or a + * title-cased raw name). + */ +export const resolveToolDisplay = (raw: string): ResolvedToolDisplay => { + const override = store.toolDisplay[raw] + const parsed = parseNameShape(raw) + return { + raw, + kind: override?.kind ?? parsed.kind, + label: override?.label ?? parsed.label, + source: override?.source ?? parsed.source, + summary: override?.summary, + } +} diff --git a/web/packages/agenta-chat/src/skin/types.ts b/web/packages/agenta-chat/src/skin/types.ts new file mode 100644 index 0000000000..b38800eb0d --- /dev/null +++ b/web/packages/agenta-chat/src/skin/types.ts @@ -0,0 +1,133 @@ +/** + * Skin registration shapes (WP3a-C5). + * + * Generalized from the three OSS chat registries — clientTools, approvals, toolDisplay (see + * `web/oss/src/components/AgentChatSlice/components/clientTools/{registry.tsx,types.ts}`, + * `.../components/approvals/registry.tsx`, `.../assets/toolDisplay.ts`) — with `Handler`/`Renderer` + * renamed to `Widget`/`Entry` and no OSS import (this package never imports from `web/oss`). The + * OSS registries keep running standalone until the desktop re-plumb PR switches them onto this + * store; until then `registerChatSkin` (./registry.ts) is called by nobody and the store stays + * empty — skins (mobile shadcn first) populate it. + */ +import type {ComponentType, ReactNode} from "react" + +import type {ToolUIPart} from "ai" + +/** + * Normalised view of a tool part a client-tool widget reads, mirroring OSS's `ClientToolMeta` + * (`clientTools/types.ts`). Structural (no `ai` dependency beyond the raw part) so a skin widget + * and the resolvers agree on one shape. + */ +export interface ClientToolMeta { + toolCallId: string + toolName: string + /** The `render.kind` hint (from a sibling `data-render` part), checked before `toolName`. */ + renderKind?: string + state: string + input: unknown + output: unknown + /** A result already settled it (`output-available`/`output-error`). */ + settled: boolean + /** The raw part, for widgets that need fields beyond the normalised view. */ + part: ToolUIPart +} + +/** Settle the parked part. Mirrors OSS `SettleClientTool`: exactly one of `output`/`errorText`. */ +export interface SettleClientTool { + (args: {output: Record}): void + (args: {errorText: string}): void +} + +/** Props every client-tool widget receives — mirrors OSS `ClientToolHandlerProps`. */ +export interface ClientToolWidgetProps { + meta: ClientToolMeta + /** Settle the part (resumes the run). No-op once already settled. */ + settle: SettleClientTool + /** An earlier part in this turn already auto-settled as a degradation; the widget should park + * (visible notice, no auto-settle) instead of looping. */ + degradedEarlierInTurn?: boolean +} + +/** + * What the OSS clientTools registry actually stores per entry: a bare component (see + * `BY_RENDER_KIND`/`BY_TOOL_NAME` in `clientTools/registry.tsx`, both + * `Record>` — no separate per-entry metadata; "meta" + * only exists as the `meta: ClientToolMeta` prop the component receives, captured above in + * `ClientToolWidgetProps`). + */ +export type ClientToolWidget = ComponentType + +/** Props an approval body receives — mirrors OSS `ApprovalBodyProps`. */ +export interface ApprovalBodyProps { + /** The exact tool input the user is approving. */ + input: unknown + /** Selected agent revision — specialized bodies diff payloads against its committed config. */ + entityId: string + /** The dock's generic payload block — render it verbatim when the payload can't be previewed. */ + fallback: ReactNode +} + +/** + * One approval registry entry — mirrors OSS `ApprovalRenderer` (`approvals/registry.tsx`) field for + * field: a `Body` plus the two copy overrides the OSS registry actually has. It has no `summary` or + * other fields; do not add any without a corresponding OSS field to mirror. + */ +export interface ApprovalBodyEntry { + Body: ComponentType + /** Replaces "The agent wants to run this tool before it can keep going."; null = Body owns it. */ + headline?: string | null + approveLabel?: string +} + +/** Best-effort tool family, inferred from the wire-name shape only — mirrors OSS `ToolKind`. */ +export type ToolKind = "gateway" | "mcp" | "platform" + +/** + * One toolDisplay registry entry — mirrors the *registration-time* shape OSS actually stores in its + * `BY_TOOL_NAME` map (`toolDisplay.ts`'s unexported `ToolDisplayOverride`: `{label?; source?; + * summary?}`), generalized with an optional `kind` override since a skin registration is not + * required to restate `raw` (it IS the record key in `ChatSkinRegistration.toolDisplay`) or force a + * default's inferred `kind`. All fields are optional: an entry may override just one piece (OSS's + * `commit_revision` entry, for example, overrides only `summary`) and the resolver fills the rest + * from the parsed name shape (see `resolveToolDisplay` in `./registry.ts`). + */ +export interface ToolDisplayEntry { + /** Humanized action label ("Fetch emails"); overrides the parsed default when present. */ + label?: string + /** Where the tool comes from ("Gmail", "Linear · MCP"); overrides the parsed default. */ + source?: string + kind?: ToolKind + /** Friendly one-liner for a settled row; null/absent falls back to the generic summary. */ + summary?: (input: unknown, output: unknown) => string | null +} + +/** + * A resolved toolDisplay — the full shape `resolveToolDisplay` returns, mirroring OSS's public + * `ToolDisplay` interface (`raw`/`kind`/`label` always present; `source`/`summary` still optional). + */ +export interface ResolvedToolDisplay { + label: string + source?: string + raw: string + kind: ToolKind + summary?: (input: unknown, output: unknown) => string | null +} + +/** + * Everything one skin contributes to the shared chat registries. Mirrors the OSS two-level + * clientTools split (a render-kind map checked first, then a tool-name map — see + * `resolveClientToolHandler`'s precedence in `clientTools/registry.tsx`) rather than inventing a + * different nesting. + */ +export interface ChatSkinRegistration { + clientTools?: { + /** Checked first — the finer dispatch axis (mirrors OSS `BY_RENDER_KIND`). */ + byRenderKind?: Record + /** Checked when no render-kind hint matched (mirrors OSS `BY_TOOL_NAME`). */ + byToolName?: Record + } + /** Tool name → approval body renderer + copy overrides (mirrors OSS `BY_TOOL_NAME`). */ + approvals?: Record + /** Raw tool name → display override (mirrors OSS `BY_TOOL_NAME`). */ + toolDisplay?: Record +} diff --git a/web/packages/agenta-chat/src/state/expandState.ts b/web/packages/agenta-chat/src/state/expandState.ts new file mode 100644 index 0000000000..3e5c63b6e1 --- /dev/null +++ b/web/packages/agenta-chat/src/state/expandState.ts @@ -0,0 +1,80 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/state/expandState.ts (2026-07-25); +// the OSS original remains authoritative for the desktop chat until the re-plumb PR deletes it. +// Keep byte-parity if either side changes. +import type {UIMessage} from "ai" +import {atom} from "jotai" +import {atomFamily, selectAtom} from "jotai/utils" + +/** + * Persisted expand/collapse state for in-message widgets (thoughts, tool rows, tool groups, long + * errors), so an expanded widget survives the windowed list unmounting its row when it scrolls out + * of view. + * + * Kept in a plain map (not an `atomFamily` of values) so the key set is enumerable and can be pruned: + * entries are dropped when their owning message is gone (rewind / session eviction / close), which + * keeps this bounded on long-lived sessions without ever resetting a currently-visible widget. + */ + +// ── Key builders: the SINGLE source of truth for the key format, used by BOTH the widgets and the +// pruner below, so the two can never drift out of sync. ── +export const reasoningKey = (messageId: string, partIndex: number) => + `${messageId}::reason::${partIndex}` +export const errorKey = (messageId: string) => `${messageId}::error` +export const toolRowKey = (toolCallId: string) => `tool::row::${toolCallId}` +export const toolGroupKey = (toolCallId: string) => `tool::group::${toolCallId}` + +/** The map IS the source of truth and the enumerable key set. `undefined` = follow the widget default. */ +const expandedMapAtom = atom>({}) + +/** Scoped read: a widget re-renders only when ITS key flips, not on every other toggle. */ +export const expandedValueAtomFamily = atomFamily((key: string) => + selectAtom(expandedMapAtom, (m) => m[key]), +) + +/** Set one widget's expanded state. */ +export const setExpandedAtom = atom( + null, + (get, set, {key, value}: {key: string; value: boolean}) => { + set(expandedMapAtom, {...get(expandedMapAtom), [key]: value}) + }, +) + +const isToolType = (type: string | undefined) => + !!type && (type.startsWith("tool-") || type === "dynamic-tool") + +/** Every expand key a set of messages can produce — same builders the widgets use. */ +export const expandedKeysForMessages = (messages: UIMessage[]): Set => { + const keys = new Set() + for (const m of messages) { + keys.add(errorKey(m.id)) + m.parts.forEach((p, i) => { + const type = (p as {type?: string}).type + if (type === "reasoning") keys.add(reasoningKey(m.id, i)) + if (isToolType(type)) { + const toolCallId = (p as {toolCallId?: string}).toolCallId + if (toolCallId) { + keys.add(toolRowKey(toolCallId)) + keys.add(toolGroupKey(toolCallId)) + } + } + }) + } + return keys +} + +/** Drop entries (and their cached selector atoms) whose key isn't in `liveKeys` — call on settle with + * the union of all open sessions' messages, so evicted/rewound widgets are cleaned up. */ +export const pruneExpandedAtom = atom(null, (get, set, liveKeys: Set) => { + // Prune the family by its OWN key set — a widget's mere read caches a selector, even if never toggled. + for (const key of expandedValueAtomFamily.getParams()) { + if (!liveKeys.has(key)) expandedValueAtomFamily.remove(key) + } + const cur = get(expandedMapAtom) + let changed = false + const next: Record = {} + for (const key in cur) { + if (liveKeys.has(key)) next[key] = cur[key] + else changed = true + } + if (changed) set(expandedMapAtom, next) +}) diff --git a/web/packages/agenta-chat/src/state/index.ts b/web/packages/agenta-chat/src/state/index.ts new file mode 100644 index 0000000000..7c54180abb --- /dev/null +++ b/web/packages/agenta-chat/src/state/index.ts @@ -0,0 +1,3 @@ +export * from "./expandState" +export * from "./sessionEphemera" +export * from "./sessionMessages" diff --git a/web/packages/agenta-chat/src/state/sessionEphemera.ts b/web/packages/agenta-chat/src/state/sessionEphemera.ts new file mode 100644 index 0000000000..5abf137dd2 --- /dev/null +++ b/web/packages/agenta-chat/src/state/sessionEphemera.ts @@ -0,0 +1,48 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/state/sessionEphemera.ts +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +// Adaptations: +// (a) `attachmentsBySession` is typed `Map` (../model/attachments) +// instead of the desktop's upload-widget file type — the package must not depend on that +// desktop UI toolkit. +// (b) the desktop's per-session virtualized-list scroll/row-height snapshot map and its cleanup +// in `clearSessionEphemera` are OMITTED entirely — that state is desktop-only, and the +// package must not depend on the desktop's list-virtualization library either. +import type {PendingAttachment} from "../model/attachments" + +/** + * Per-session in-memory ephemera that must survive pane remounts (route re-entry, tab + * close/reopen) but NOT a session's deletion. Lives outside React and outside the + * persisted session atoms: + * - composer drafts/attachments hold live `File` blobs that can't be serialized. + * + * `deleteSessionAtomFamily` / `resetScopeAtomFamily` call `clearSessionEphemera` alongside + * their `sessionMessagesAtom` cleanup, so deleted sessions don't retain blobs for the rest + * of the page lifetime. + */ + +/** Unsent composer drafts per session — switching back to a session restores its + * in-progress message. */ +export const composerDraftBySession = new Map() + +/** Pending (not yet sent) attachments per session — same lifetime as the drafts. */ +export const attachmentsBySession = new Map() + +/** + * Sessions created brand-new in this browser and not yet run. A never-run local session has no + * backend records, so its open-with-empty-cache hydration would be a guaranteed-empty server query. + * Marked on create, cleared on the first send. In-memory only: after a reload the marker is gone, so + * a never-run session opened post-reload legitimately falls back to hydrating (we can no longer tell + * "never ran" from "ran, cache cleared" without asking the server — which is the point of hydration). + */ +export const freshSessionIds = new Set() +export const markSessionFresh = (sessionId: string) => freshSessionIds.add(sessionId) +export const isSessionFresh = (sessionId: string) => freshSessionIds.has(sessionId) +export const clearSessionFresh = (sessionId: string) => freshSessionIds.delete(sessionId) + +/** Drop every ephemeral trace of a permanently deleted session. */ +export const clearSessionEphemera = (sessionId: string) => { + composerDraftBySession.delete(sessionId) + attachmentsBySession.delete(sessionId) + freshSessionIds.delete(sessionId) +} diff --git a/web/packages/agenta-chat/src/state/sessionMessages.ts b/web/packages/agenta-chat/src/state/sessionMessages.ts new file mode 100644 index 0000000000..5db480f549 --- /dev/null +++ b/web/packages/agenta-chat/src/state/sessionMessages.ts @@ -0,0 +1,111 @@ +// Copied from web/oss/src/components/AgentChatSlice/state/sessions.ts (2026-07-25) — ONLY the +// self-contained message-persistence and run-status pieces the conversation host needs +// (`sessionMessagesAtom`, the quota-guarded persist writer, and the per-session run-status +// store). The OSS original remains authoritative for the desktop chat until the re-plumb PR +// deletes it; keep byte-parity on the copied blocks if either side changes. The rest of that +// file (scope-keyed history/tabs, server reconciliation, archive/delete remotes, timestamps) +// is app-layer session-LIST state and stays out of the package deliberately. +import type {UIMessage} from "ai" +import {atom, type Setter} from "jotai" +import {atomFamily, atomWithStorage} from "jotai/utils" + +import type {SessionRunStatus} from "../model/sessionStatus" + +// `getOnInit: true` — read localStorage synchronously on init. Without it the atom starts as +// the empty default `{}` on every mount and only hydrates afterwards, so a mount-time seed +// read would see an empty store on every reload. +const STORAGE_OPTS = {getOnInit: true} as const + +/** Persisted messages per session id. Written when a conversation's stream settles. Session ids + * are globally unique, so this store has no scope dimension. */ +export const sessionMessagesAtom = atomWithStorage>( + "agenta:agent-chat:messages", + {}, + undefined, + STORAGE_OPTS, +) + +/** A localStorage-full error, across browsers (Chrome/Safari code 22, Firefox 1014). */ +const isQuotaExceeded = (e: unknown): boolean => + e instanceof DOMException && + (e.code === 22 || + e.code === 1014 || + e.name === "QuotaExceededError" || + e.name === "NS_ERROR_DOM_QUOTA_REACHED") + +/** + * Persist the messages store, degrading gracefully when it overflows the ~5MB localStorage quota + * (large inline `data:` attachments make this reachable). On overflow we shed OTHER sessions' + * persisted messages, oldest-first, and retry, so the active conversation (`keepId`) still + * persists and the panel never crashes on a full store. Evicted sessions are closed/history and + * re-hydrate from the server when reopened. + */ +const writeMessagesWithQuotaGuard = ( + set: Setter, + next: Record, + keepId: string, +): void => { + let candidate = next + for (;;) { + try { + set(sessionMessagesAtom, candidate) + return + } catch (e) { + if (!isQuotaExceeded(e)) throw e + // Object keys keep insertion order, so the first non-active id is the oldest. + const oldest = Object.keys(candidate).find((k) => k !== keepId) + if (oldest === undefined) { + // Even the active session alone won't fit — keep it in memory, skip persistence. + console.warn("[agent-chat] message store over quota; skipping persistence") + return + } + candidate = {...candidate} + delete candidate[oldest] + } + } +} + +/** Write a session's messages to the persisted store (called when its stream settles). */ +export const persistSessionMessagesAtom = atom( + null, + (get, set, {id, messages}: {id: string; messages: UIMessage[]}) => { + writeMessagesWithQuotaGuard(set, {...get(sessionMessagesAtom), [id]: messages}, id) + }, +) + +/** + * Canonical per-session run state, keyed by the globally-unique session id (no scope dimension). + * Written by the mounted conversation (from its useChat status / approval / error); everything + * status-related derives from this one record so there's no competing streaming flag to keep in + * sync. In-memory only (not persisted): it describes the current browser tab, not history. + */ +const sessionStatusByIdAtom = atom>({}) + +/** A single session's run state. Defaults to "idle" for sessions with no mounted conversation. + * Backs a session list's status dot; reads repaint only when this session's status changes. */ +export const sessionStatusAtomFamily = atomFamily((id: string) => + atom((get) => get(sessionStatusByIdAtom)[id] ?? "idle"), +) + +/** Is THIS browser currently streaming the given session? Derived from the run state. */ +export const isSessionStreamingAtomFamily = atomFamily((id: string) => + atom((get) => get(sessionStatusByIdAtom)[id] === "running"), +) + +/** Set a session's run state. "idle" is the default, so it's stored as ABSENCE: passing "idle" + * deletes the entry (clear-on-unmount) instead of accumulating idle keys for every closed session. */ +export const setSessionStatusAtom = atom( + null, + (get, set, {id, status}: {id: string; status: SessionRunStatus}) => { + const cur = get(sessionStatusByIdAtom) + if (status === "idle") { + if (!(id in cur)) return + const next = {...cur} + delete next[id] + set(sessionStatusByIdAtom, next) + return + } + if (cur[id] === status) return + set(sessionStatusByIdAtom, {...cur, [id]: status}) + }, +) diff --git a/web/packages/agenta-chat/src/transport/AgentChatTransport.ts b/web/packages/agenta-chat/src/transport/AgentChatTransport.ts new file mode 100644 index 0000000000..6828392131 --- /dev/null +++ b/web/packages/agenta-chat/src/transport/AgentChatTransport.ts @@ -0,0 +1,231 @@ +// Copied verbatim from web/oss/src/components/AgentChatSlice/assets/AgentChatTransport.ts +// (2026-07-25); the OSS original remains authoritative for the desktop chat until the re-plumb +// PR deletes it. Keep byte-parity if either side changes. +// Adaptations: none — `createNegotiatingFetch`/`NegotiatingFetch` come from `@agenta/playground` +// and `generateId` from `@agenta/shared/utils`, both already allowed package deps; no OSS-app +// import was involved. +import {createNegotiatingFetch, type NegotiatingFetch} from "@agenta/playground" +import {generateId} from "@agenta/shared/utils" +import {DefaultChatTransport, type UIMessage, type UIMessageChunk} from "ai" + +/** + * Agent chat transport. + * + * `useChat` only renders a stream of `UIMessageChunk`s — it has no "batch" mode. So when the run + * resolves to a batch (the toggle forced it, or the backend fell back because the handler can't + * stream), the backend returns a single `WorkflowBatchResponse` (JSON) and this transport replays + * it as a ONE-SHOT UIMessage stream — the same chunk sequence the SSE path emits — so the reply + * lands in a single frame. A real stream delegates to the default SSE parser unchanged. + * + * Which channel resolved is decided by the `createNegotiatingFetch` middleware, NOT a fixed + * toggle: it requests the stream, falls back to a batch re-request on a 406 (handler can't + * stream), and passes any other error through so `useChat` surfaces it inline. The transport + * parses the body according to the channel that fetch actually resolved (`resolvedMode`), so the + * request and the response handling can never disagree. + */ +type AnyChunk = UIMessageChunk + +interface BatchPart { + type?: string + text?: string + toolCallId?: string + input?: unknown + output?: unknown +} + +interface BatchMessage { + id?: string + role?: string + /** Vercel UIMessage shape. */ + parts?: BatchPart[] + /** Neutral Message shape: a plain string or a list of content blocks. */ + content?: unknown +} + +/** A neutral content block (`text`, `tool_use`, `tool_result`, `thinking`, …). */ +interface ContentBlock { + type?: string + text?: string + thinking?: string + id?: string + name?: string + input?: unknown + output?: unknown + content?: unknown + tool_use_id?: string +} + +/** + * Normalize a batch message into UIMessage `parts`, accepting BOTH shapes the backend may emit: + * - a Vercel UIMessage that already has `parts`, or + * - a neutral Message `{role, content}` where `content` is a string or a list of content blocks + * (what the agent `/invoke` batch path actually returns today — confirmed in QA). + */ +function normalizeToParts(msg: BatchMessage | undefined): BatchPart[] { + if (!msg) return [] + if (Array.isArray(msg.parts)) return msg.parts + + const content = msg.content + if (typeof content === "string") return content ? [{type: "text", text: content}] : [] + if (Array.isArray(content)) { + const parts: BatchPart[] = [] + for (const raw of content) { + const b = (raw ?? {}) as ContentBlock + if (b.type === "text" && typeof b.text === "string") { + parts.push({type: "text", text: b.text}) + } else if (b.type === "thinking" || b.type === "reasoning") { + parts.push({type: "reasoning", text: b.text ?? b.thinking ?? ""}) + } else if (b.type === "tool_use") { + parts.push({type: `tool-${b.name ?? ""}`, toolCallId: b.id, input: b.input}) + } else if (b.type === "tool_result") { + parts.push({ + type: "tool-", + toolCallId: b.tool_use_id ?? b.id, + output: b.content ?? b.output, + }) + } else if (typeof b.text === "string") { + parts.push({type: "text", text: b.text}) + } + } + return parts + } + return [] +} + +/** + * Pull the assistant message out of a `WorkflowBatchResponse`. `data.outputs` is typed `Any` + * server-side; the agent's canonical output is `outputs.messages` (a `{messages: [...]}` + * envelope), but accept the other plausible shapes too (a single `{role, content}`, a bare + * list, a UIMessage with `parts`, or a bare string). Falls back to stringifying whatever + * arrived so a turn never renders empty. + */ +function extractAssistantMessage(json: unknown): BatchMessage { + const root = (json ?? {}) as Record + const data = (root.data ?? {}) as Record + const outputs = data.outputs ?? root.outputs ?? root + + if (typeof outputs === "string") { + return {role: "assistant", parts: [{type: "text", text: outputs}]} + } + + let candidates: BatchMessage[] = [] + if (Array.isArray(outputs)) candidates = outputs as BatchMessage[] + else if (Array.isArray((outputs as Record)?.messages)) + candidates = (outputs as {messages: BatchMessage[]}).messages + else if (outputs && typeof outputs === "object") candidates = [outputs as BatchMessage] + + const chosen = + [...candidates].reverse().find((m) => m?.role === "assistant") ?? + candidates[candidates.length - 1] + const parts = normalizeToParts(chosen) + if (parts.length > 0) return {id: chosen?.id, role: "assistant", parts} + + return {role: "assistant", parts: [{type: "text", text: JSON.stringify(outputs ?? "")}]} +} + +/** Replay a one-message `WorkflowBatchResponse` as a one-shot v6 UIMessage stream. Buffering the + * whole body is fine here — batch is a single JSON response, not a stream. */ +function batchJsonToUiMessageStream( + byteStream: ReadableStream, +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const emit = (c: Record) => controller.enqueue(c as AnyChunk) + try { + const text = await new Response(byteStream).text() + const json = text ? JSON.parse(text) : {} + const msg = extractAssistantMessage(json) + const sessionId = (json as Record)?.session_id + const traceId = + (json as Record)?.trace_id ?? + ((json as Record)?.data as Record)?.trace_id + + // Unique fallback id per replay — a constant made every id-less batch turn + // collide on the same React key (duplicate-key warning + dropped turns). + const start: Record = { + type: "start", + messageId: msg.id ?? `msg-batch-${generateId()}`, + } + if (sessionId) start.messageMetadata = {sessionId} + emit(start) + emit({type: "start-step"}) + + let seq = 0 + for (const part of msg.parts ?? []) { + seq += 1 + const t = part?.type + if (t === "text") { + const id = `text-${seq}` + emit({type: "text-start", id}) + emit({type: "text-delta", id, delta: part.text ?? ""}) + emit({type: "text-end", id}) + } else if (t === "reasoning") { + const id = `reasoning-${seq}` + emit({type: "reasoning-start", id}) + emit({type: "reasoning-delta", id, delta: part.text ?? ""}) + emit({type: "reasoning-end", id}) + } else if (typeof t === "string" && t.startsWith("tool-")) { + // A UIMessage tool part → re-emit as the tool input/output chunks. + const toolCallId = part.toolCallId ?? `tool-${seq}` + const toolName = t.slice("tool-".length) + // A neutral `tool_result` block normalizes to a nameless `tool-` part + // carrying only the output, under the SAME toolCallId as its sibling + // `tool_use`. The AI SDK keys tool parts by that id, so emitting an + // input chunk here would overwrite the real name and input with "" and + // undefined, and the turn would render an unnamed call. + if (toolName) { + emit({ + type: "tool-input-available", + toolCallId, + toolName, + input: part.input, + }) + } + if (part.output !== undefined) { + emit({type: "tool-output-available", toolCallId, output: part.output}) + } + } else if (typeof part?.text === "string" && part.text) { + // Unknown part with text → surface it as text rather than dropping it. + const id = `text-${seq}` + emit({type: "text-start", id}) + emit({type: "text-delta", id, delta: part.text}) + emit({type: "text-end", id}) + } + } + + emit({type: "finish-step"}) + const finish: Record = {type: "finish"} + if (traceId) finish.messageMetadata = {traceId} + emit(finish) + controller.close() + } catch (err) { + emit({ + type: "error", + errorText: err instanceof Error ? err.message : String(err), + }) + controller.close() + } + }, + }) +} + +export class AgentChatTransport extends DefaultChatTransport { + private readonly negotiator: NegotiatingFetch + + constructor(options: ConstructorParameters>[0] = {}) { + // Own the transport's `fetch` so every request goes through stream→batch negotiation; + // any caller-supplied fetch becomes the negotiator's base (tests inject one here). + super({...options, fetch: undefined}) + this.negotiator = createNegotiatingFetch(options.fetch) + this.fetch = this.negotiator.fetch + } + + protected processResponseStream(stream: ReadableStream): ReadableStream { + // Parse by the channel the request actually resolved to, not the requested one — a stream + // request can come back as a batch via the 406 fallback. The mode is keyed off this exact + // body stream (`resolvedMode(stream)`), so request and parse stay in lockstep. + if (this.negotiator.resolvedMode(stream) === "batch") + return batchJsonToUiMessageStream(stream) + return super.processResponseStream(stream) + } +} diff --git a/web/packages/agenta-chat/src/transport/index.ts b/web/packages/agenta-chat/src/transport/index.ts new file mode 100644 index 0000000000..25aea9a794 --- /dev/null +++ b/web/packages/agenta-chat/src/transport/index.ts @@ -0,0 +1 @@ +export * from "./AgentChatTransport" diff --git a/web/packages/agenta-chat/tests/unit/assets/attachmentRules.test.ts b/web/packages/agenta-chat/tests/unit/assets/attachmentRules.test.ts new file mode 100644 index 0000000000..ccb3af3057 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/attachmentRules.test.ts @@ -0,0 +1,87 @@ +import {describe, expect, it} from "vitest" + +import { + DEFAULT_ATTACHMENT_LIMITS, + formatBytes, + isAcceptedType, + validateIncoming, +} from "../../../src/assets/attachmentRules" + +const makeFile = (name: string, type: string, size: number): File => + new File([new Uint8Array(size)], name, {type}) + +describe("isAcceptedType", () => { + it("matches an exact type", () => { + expect(isAcceptedType("application/pdf", DEFAULT_ATTACHMENT_LIMITS)).toBe(true) + }) + + it("matches a type/ prefix", () => { + expect(isAcceptedType("image/png", DEFAULT_ATTACHMENT_LIMITS)).toBe(true) + }) + + it("rejects an unlisted type", () => { + expect(isAcceptedType("application/zip", DEFAULT_ATTACHMENT_LIMITS)).toBe(false) + }) +}) + +describe("formatBytes", () => { + it("formats sub-KB sizes in bytes", () => { + expect(formatBytes(512)).toBe("512 B") + }) + + it("formats sub-MB sizes in KB", () => { + expect(formatBytes(820 * 1024)).toBe("820 KB") + }) + + it("formats MB-scale sizes with one decimal", () => { + expect(formatBytes(4.2 * 1024 * 1024)).toBe("4.2 MB") + }) +}) + +describe("validateIncoming", () => { + it("accepts a file within limits", () => { + const file = makeFile("a.png", "image/png", 1024) + const {accepted, rejections} = validateIncoming([file], 0) + expect(accepted).toEqual([file]) + expect(rejections).toEqual([]) + }) + + it("rejects an unsupported media type", () => { + const file = makeFile("a.zip", "application/zip", 1024) + const {accepted, rejections} = validateIncoming([file], 0) + expect(accepted).toEqual([]) + expect(rejections).toEqual([{name: "a.zip", reason: "isn't a supported file type"}]) + }) + + it("rejects a file over the per-file byte limit", () => { + const limits = {...DEFAULT_ATTACHMENT_LIMITS, maxBytes: 100} + const file = makeFile("big.png", "image/png", 200) + const {accepted, rejections} = validateIncoming([file], 0, limits) + expect(accepted).toEqual([]) + expect(rejections).toEqual([ + {name: "big.png", reason: "is too large (200 B) · max 100 B per file"}, + ]) + }) + + it("rejects files once the remaining slot count is exhausted, keeping earlier ones in order", () => { + const limits = {...DEFAULT_ATTACHMENT_LIMITS, maxCount: 2} + const a = makeFile("a.png", "image/png", 10) + const b = makeFile("b.png", "image/png", 10) + const c = makeFile("c.png", "image/png", 10) + // maxCount 2, 1 already attached → exactly 1 remaining slot: only `a` fits. + const {accepted, rejections} = validateIncoming([a, b, c], 1, limits) + expect(accepted).toEqual([a]) + expect(rejections).toEqual([ + {name: "b.png", reason: "exceeds the 2-file limit"}, + {name: "c.png", reason: "exceeds the 2-file limit"}, + ]) + }) + + it("accounts for currentCount when computing remaining slots", () => { + const limits = {...DEFAULT_ATTACHMENT_LIMITS, maxCount: 3} + const a = makeFile("a.png", "image/png", 10) + const {accepted, rejections} = validateIncoming([a], 3, limits) + expect(accepted).toEqual([]) + expect(rejections).toEqual([{name: "a.png", reason: "exceeds the 3-file limit"}]) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/files.test.ts b/web/packages/agenta-chat/tests/unit/assets/files.test.ts new file mode 100644 index 0000000000..8e199d8648 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/files.test.ts @@ -0,0 +1,148 @@ +import type {FileUIPart, UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {fileKind, fileParts, filePartName, filesToParts} from "../../../src/assets/files" + +// `filesToParts`/`fileToPart` read a `File` via `FileReader.readAsDataURL`, which the node +// vitest environment (`environment: "node"` in vitest.config.ts) does not provide — no DOM, +// no FileReader global. Only the pure, FileReader-free exports are covered here. + +describe("fileKind", () => { + it("classifies image types", () => { + expect(fileKind("image/png")).toBe("image") + }) + + it("classifies audio types", () => { + expect(fileKind("audio/mpeg")).toBe("audio") + }) + + it("classifies video types", () => { + expect(fileKind("video/mp4")).toBe("video") + }) + + it("falls back to file for anything else", () => { + expect(fileKind("application/pdf")).toBe("file") + }) +}) + +describe("fileParts", () => { + it("extracts only the file parts of a message, in order", () => { + const filePart: FileUIPart = { + type: "file", + mediaType: "image/png", + filename: "a.png", + url: "data:image/png;base64,AAAA", + } + const message = { + id: "m1", + role: "user", + parts: [{type: "text", text: "hi"}, filePart], + } as unknown as UIMessage + expect(fileParts(message)).toEqual([filePart]) + }) + + it("returns an empty array when there are no file parts", () => { + const message = { + id: "m1", + role: "user", + parts: [{type: "text", text: "hi"}], + } as unknown as UIMessage + expect(fileParts(message)).toEqual([]) + }) +}) + +describe("filePartName", () => { + it("prefers the filename when present", () => { + const part: FileUIPart = { + type: "file", + mediaType: "image/png", + filename: "notes.png", + url: "https://example.com/x/notes.png?sig=1", + } + expect(filePartName(part)).toBe("notes.png") + }) + + it("does not label an inline data: URL with its own base64 payload", () => { + // fileToPart emits data:;base64,<...>; its URL tail IS the payload, so the tail + // fallback would render ~70 characters of base64 where a name belongs. + const part: FileUIPart = { + type: "file", + mediaType: "image/png", + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk", + } + + expect(filePartName(part)).toBe("attachment") + }) + + it("falls back to the tail of the url, stripped of query params", () => { + const part: FileUIPart = { + type: "file", + mediaType: "image/png", + url: "https://example.com/x/generated.png?sig=1", + } + expect(filePartName(part)).toBe("generated.png") + }) + + it("falls back to 'file' when neither filename nor a url tail is available", () => { + const part: FileUIPart = { + type: "file", + mediaType: "image/png", + url: "", + } + expect(filePartName(part)).toBe("file") + }) +}) + +// `filesToParts` needs a FileReader, which this environment does not provide. A minimal stub is +// enough to prove the settle-individually contract: one file reads, the other errors. +class StubFileReader { + onload: (() => void) | null = null + onerror: (() => void) | null = null + error: unknown = null + result: string | null = null + readAsDataURL(file: File) { + if (file.name === "gone.txt") { + this.error = new Error("unreadable") + setTimeout(() => this.onerror?.(), 0) + return + } + this.result = `data:${file.type};base64,aGVsbG8=` + setTimeout(() => this.onload?.(), 0) + } +} + +const withStubReader = async (run: () => Promise) => { + const original = (globalThis as {FileReader?: unknown}).FileReader + ;(globalThis as {FileReader?: unknown}).FileReader = StubFileReader + try { + await run() + } finally { + ;(globalThis as {FileReader?: unknown}).FileReader = original + } +} + +describe("filesToParts", () => { + // A staged file can become unreadable between picking and submit (moved, permission revoked, + // a disconnected drive). That used to reject the whole conversion, losing the message text + // and every readable attachment with it. + it("keeps the readable files when one cannot be read", async () => { + await withStubReader(async () => { + const good = new File(["hello"], "good.txt", {type: "text/plain"}) + const bad = new File(["x"], "gone.txt", {type: "text/plain"}) + const {parts, rejections} = await filesToParts([good, bad]) + expect(parts.map((p) => p.filename)).toEqual(["good.txt"]) + expect(rejections).toEqual([{name: "gone.txt", reason: "could not be read"}]) + }) + }) + + it("reports no rejections when every file reads", async () => { + await withStubReader(async () => { + const {parts, rejections} = await filesToParts([ + new File(["a"], "a.txt", {type: "text/plain"}), + new File(["b"], "b.txt", {type: "text/plain"}), + ]) + expect(parts).toHaveLength(2) + expect(rejections).toEqual([]) + }) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts new file mode 100644 index 0000000000..ab85a64503 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -0,0 +1,128 @@ +import type {SessionRecord} from "@agenta/entities/session" +import {atom} from "jotai" +import {beforeEach, describe, expect, it, vi} from "vitest" + +// `fetchSessionRecordsAtom` (real impl in @agenta/entities) hits the network via +// jotai-tanstack-query; loadSessionMessages only needs its {records, refreshed} contract, so the +// atom is replaced with a controllable stub rather than standing up a query client + fetch mock. +let fetchResult: {records: SessionRecord[] | null; refreshed?: Promise} +vi.mock("@agenta/entities/session", () => ({ + fetchSessionRecordsAtom: atom(null, async () => fetchResult), +})) + +const {loadSessionMessages} = await import("../../../src/assets/loadSession") + +const record = (id: string, payload: Record, sender = "agent"): SessionRecord => ({ + id, + session_id: "session-1", + project_id: "project-1", + event_index: null, + sender, + session_update: String(payload.type), + payload, + created_at: null, +}) + +describe("loadSessionMessages", () => { + beforeEach(() => { + fetchResult = {records: null} + }) + + it("returns null when there are no records", async () => { + fetchResult = {records: null} + expect(await loadSessionMessages("session-1")).toBeNull() + }) + + it("returns null when the record log is empty", async () => { + fetchResult = {records: []} + expect(await loadSessionMessages("session-1")).toBeNull() + }) + + it("replays the fetched records through transcriptToMessages", async () => { + fetchResult = { + records: [record("r1", {type: "message", text: "hi"}), record("r2", {type: "done"})], + } + const transcript = await loadSessionMessages("session-1") + expect(transcript?.messages).toHaveLength(1) + expect(transcript?.messages[0]).toMatchObject({parts: [{type: "text", text: "hi"}]}) + }) + + // The adoption watermark: records, not messages — a turn that grows in place keeps its + // message count (issue #5530), so only this number sees the log move. + it("reports how many records the transcript was built from", async () => { + fetchResult = { + records: [ + record("r1", {type: "message", text: "hi"}), + record("r2", {type: "message", text: " there"}), + record("r3", {type: "done"}), + ], + } + const transcript = await loadSessionMessages("session-1") + expect(transcript?.messages).toHaveLength(1) + expect(transcript?.recordCount).toBe(3) + }) + + it("delivers a refreshed transcript via onRefreshed once the background revalidation resolves", async () => { + const fresh = [record("r3", {type: "message", text: "fresh"}), record("r4", {type: "done"})] + fetchResult = { + records: [record("r1", {type: "message", text: "stale"}), record("r2", {type: "done"})], + refreshed: Promise.resolve(fresh), + } + const onRefreshed = vi.fn() + await loadSessionMessages("session-1", onRefreshed) + // `refreshed` resolves asynchronously after the function returns — flush microtasks. + await Promise.resolve() + await Promise.resolve() + expect(onRefreshed).toHaveBeenCalledTimes(1) + const delivered = onRefreshed.mock.calls[0][0] as { + messages: {parts: unknown}[] + recordCount: number + } + expect(delivered.messages[0]).toMatchObject({parts: [{type: "text", text: "fresh"}]}) + // The refreshed delivery carries the FRESH log's count, not the stale one's. + expect(delivered.recordCount).toBe(2) + }) + + // The chain outlives the call, so the function's own try/catch never sees a rejection here. + it("survives a rejected background revalidation without an unhandled rejection", async () => { + const unhandled = vi.fn() + process.on("unhandledRejection", unhandled) + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + try { + fetchResult = { + records: [ + record("r1", {type: "message", text: "stale"}), + record("r2", {type: "done"}), + ], + refreshed: Promise.reject(new Error("boom")), + } + const onRefreshed = vi.fn() + const transcript = await loadSessionMessages("session-1", onRefreshed) + await Promise.resolve() + await Promise.resolve() + // The restored transcript still stands; only the revalidation was lost. + expect(transcript?.messages[0]).toMatchObject({ + parts: [{type: "text", text: "stale"}], + }) + expect(onRefreshed).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalled() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unhandled).not.toHaveBeenCalled() + } finally { + process.off("unhandledRejection", unhandled) + warn.mockRestore() + } + }) + + it("does not call onRefreshed when the background revalidation yields nothing new", async () => { + fetchResult = { + records: [record("r1", {type: "message", text: "stale"}), record("r2", {type: "done"})], + refreshed: Promise.resolve(null), + } + const onRefreshed = vi.fn() + await loadSessionMessages("session-1", onRefreshed) + await Promise.resolve() + await Promise.resolve() + expect(onRefreshed).not.toHaveBeenCalled() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/rewind.test.ts b/web/packages/agenta-chat/tests/unit/assets/rewind.test.ts new file mode 100644 index 0000000000..8297754f79 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/rewind.test.ts @@ -0,0 +1,72 @@ +import type {UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {messageText, sideEffectingToolsInRange} from "../../../src/assets/rewind" + +const textMessage = (id: string, text: string): UIMessage => + ({id, role: "user", parts: [{type: "text", text}]}) as unknown as UIMessage + +const toolMessage = (id: string, toolName: string, state: string): UIMessage => + ({ + id, + role: "assistant", + parts: [{type: `tool-${toolName}`, state}], + }) as unknown as UIMessage + +describe("messageText", () => { + it("concatenates a message's text parts", () => { + const message = { + id: "m1", + role: "user", + parts: [ + {type: "text", text: "hello "}, + {type: "text", text: "world"}, + ], + } as unknown as UIMessage + expect(messageText(message)).toBe("hello world") + }) + + it("ignores non-text parts", () => { + const message = { + id: "m1", + role: "assistant", + parts: [ + {type: "text", text: "ok"}, + {type: "tool-search_docs", state: "output-available"}, + ], + } as unknown as UIMessage + expect(messageText(message)).toBe("ok") + }) +}) + +describe("sideEffectingToolsInRange", () => { + it("reports a side-effecting tool that already produced output", () => { + const messages = [ + textMessage("m1", "send it"), + toolMessage("m2", "send_email", "output-available"), + ] + expect(sideEffectingToolsInRange(messages)).toEqual(["send_email"]) + }) + + it("ignores read-only tools even when they ran", () => { + const messages = [toolMessage("m1", "search_docs", "output-available")] + expect(sideEffectingToolsInRange(messages)).toEqual([]) + }) + + it("ignores tool calls that never ran (still pending, denied, or errored)", () => { + const messages = [ + toolMessage("m1", "send_email", "input-available"), + toolMessage("m2", "send_email", "output-denied"), + toolMessage("m3", "send_email", "output-error"), + ] + expect(sideEffectingToolsInRange(messages)).toEqual([]) + }) + + it("dedupes repeated tool names across messages", () => { + const messages = [ + toolMessage("m1", "send_email", "output-available"), + toolMessage("m2", "send_email", "output-available"), + ] + expect(sideEffectingToolsInRange(messages)).toEqual(["send_email"]) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts b/web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts new file mode 100644 index 0000000000..fe47dda101 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from "vitest" + +import {formatToolValue, stripFence} from "../../../src/assets/toolFormat" + +describe("stripFence", () => { + it("strips a fence that spans the whole string", () => { + expect(stripFence('```json\n{"a":1}\n```')).toBe('{"a":1}') + }) + + it("leaves inner fenced blocks intact when they don't span the whole string", () => { + const value = "see ```inline``` here" + expect(stripFence(value)).toBe(value) + }) + + it("returns plain text unchanged", () => { + expect(stripFence("plain text")).toBe("plain text") + }) +}) + +describe("formatToolValue", () => { + it("returns an empty string for null/undefined", () => { + expect(formatToolValue(null)).toBe("") + expect(formatToolValue(undefined)).toBe("") + }) + + it("pretty-prints a JSON string", () => { + expect(formatToolValue('{"a":1}')).toBe('{\n "a": 1\n}') + }) + + it("pretty-prints a fence-wrapped JSON string", () => { + expect(formatToolValue('```json\n{"a":1}\n```')).toBe('{\n "a": 1\n}') + }) + + it("pretty-prints an object value", () => { + expect(formatToolValue({a: 1})).toBe('{\n "a": 1\n}') + }) + + it("does not reformat a bare primitive-looking string", () => { + expect(formatToolValue("42")).toBe("42") + expect(formatToolValue("true")).toBe("true") + }) + + it("returns a plain non-JSON string as-is (fence-stripped)", () => { + expect(formatToolValue("just a sentence.")).toBe("just a sentence.") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/trace.test.ts b/web/packages/agenta-chat/tests/unit/assets/trace.test.ts new file mode 100644 index 0000000000..95a8b7b762 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/trace.test.ts @@ -0,0 +1,98 @@ +import type {UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {getMessageRunError, getMessageTraceId, getMessageUsage} from "../../../src/assets/trace" + +describe("getMessageTraceId", () => { + it("prefers message.metadata.traceId", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {traceId: "trace-1"}, + parts: [], + } as unknown as UIMessage + expect(getMessageTraceId(message)).toBe("trace-1") + }) + + it("falls back to the data-trace part's traceId", () => { + const message = { + id: "m1", + role: "assistant", + parts: [{type: "data-trace", data: {traceId: "trace-2"}}], + } as unknown as UIMessage + expect(getMessageTraceId(message)).toBe("trace-2") + }) + + it("parses the trace id out of the data-trace part's url when no traceId is sent", () => { + const message = { + id: "m1", + role: "assistant", + parts: [{type: "data-trace", data: {url: "https://x/traces/abc123?tab=overview"}}], + } as unknown as UIMessage + expect(getMessageTraceId(message)).toBe("abc123") + }) + + it("returns undefined when nothing is present", () => { + const message = {id: "m1", role: "assistant", parts: []} as unknown as UIMessage + expect(getMessageTraceId(message)).toBeUndefined() + }) +}) + +describe("getMessageRunError", () => { + it("returns the run error message when present and non-blank", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {runError: {message: "boom"}}, + parts: [], + } as unknown as UIMessage + expect(getMessageRunError(message)).toBe("boom") + }) + + it("returns undefined for a blank message", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {runError: {message: " "}}, + parts: [], + } as unknown as UIMessage + expect(getMessageRunError(message)).toBeUndefined() + }) + + it("returns undefined when there is no runError", () => { + const message = {id: "m1", role: "assistant", parts: []} as unknown as UIMessage + expect(getMessageRunError(message)).toBeUndefined() + }) +}) + +describe("getMessageUsage", () => { + it("maps the service's usage fields to the metrics-display names", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {usage: {input: 10, output: 20, total: 30, cost: 0.01}}, + parts: [], + } as unknown as UIMessage + expect(getMessageUsage(message)).toEqual({ + promptTokens: 10, + completionTokens: 20, + totalTokens: 30, + totalCost: 0.01, + }) + }) + + it("returns undefined when usage is absent", () => { + const message = {id: "m1", role: "assistant", parts: []} as unknown as UIMessage + expect(getMessageUsage(message)).toBeUndefined() + }) + + it("returns undefined when usage has no numeric fields", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {usage: {input: "not-a-number"}}, + parts: [], + } as unknown as UIMessage + expect(getMessageUsage(message)).toBeUndefined() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts new file mode 100644 index 0000000000..15cc881ae4 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -0,0 +1,381 @@ +import type {SessionRecord} from "@agenta/entities/session" +import {describe, expect, it} from "vitest" + +import {transcriptToMessages} from "../../../src/assets/transcriptToMessages" + +const record = (id: string, payload: Record, sender = "agent"): SessionRecord => ({ + id, + session_id: "session-1", + project_id: "project-1", + event_index: null, + sender, + session_update: String(payload.type), + payload, + created_at: null, +}) + +describe("transcriptToMessages", () => { + it("returns null for an empty transcript", () => { + expect(transcriptToMessages([])).toBeNull() + }) + + it("returns null when records carry no renderable payload", () => { + expect(transcriptToMessages([record("r1", {type: "done"})])).toBeNull() + }) + + it("splits assistant turns on a `done` boundary into separate messages", () => { + const messages = transcriptToMessages([ + record("r1", {type: "message", text: "first turn"}), + record("r2", {type: "done"}), + record("r3", {type: "message", text: "second turn"}), + record("r4", {type: "done"}), + ]) + + expect(messages).toHaveLength(2) + expect(messages?.[0]).toMatchObject({ + id: "r1", + role: "assistant", + parts: [{type: "text", text: "first turn"}], + }) + expect(messages?.[1]).toMatchObject({ + id: "r3", + role: "assistant", + parts: [{type: "text", text: "second turn"}], + }) + }) + + it("accumulates a streamed text turn from message_start/message_delta", () => { + const messages = transcriptToMessages([ + record("r1", {type: "message_start", id: "text-1"}), + record("r2", {type: "message_delta", id: "text-1", delta: "hel"}), + record("r3", {type: "message_delta", id: "text-1", delta: "lo"}), + record("r4", {type: "done"}), + ]) + + expect(messages).toHaveLength(1) + expect(messages?.[0].parts).toEqual([{type: "text", text: "hello"}]) + }) + + it("opens a new message when the sender role changes, even mid-turn", () => { + const messages = transcriptToMessages([ + record("r1", {type: "message", text: "hi"}, "user"), + record("r2", {type: "message", text: "hello back"}, "agent"), + ]) + + expect(messages).toHaveLength(2) + expect(messages?.[0]).toMatchObject({role: "user"}) + expect(messages?.[1]).toMatchObject({role: "assistant"}) + }) + + it("assembles a tool_call + tool_result pair into one settled tool part", () => { + const messages = transcriptToMessages([ + record("r1", { + type: "tool_call", + id: "tool-1", + name: "bash", + input: {command: "ls"}, + }), + record("r2", {type: "tool_result", id: "tool-1", output: "file.txt"}), + record("r3", {type: "done"}), + ]) + + expect(messages).toHaveLength(1) + expect(messages?.[0].parts).toEqual([ + { + type: "tool-bash", + toolCallId: "tool-1", + state: "output-available", + input: {command: "ls"}, + output: "file.txt", + }, + ]) + }) + + it("marks a tool call still awaiting its result as input-available", () => { + const messages = transcriptToMessages([ + record("r1", { + type: "tool_call", + id: "tool-1", + name: "search_docs", + input: {query: "x"}, + }), + ]) + + expect(messages?.[0].parts).toEqual([ + { + type: "tool-search_docs", + toolCallId: "tool-1", + state: "input-available", + input: {query: "x"}, + }, + ]) + }) + + it("marks a denied tool call as output-denied", () => { + const messages = transcriptToMessages([ + record("r1", {type: "tool_call", id: "tool-1", name: "bash", input: {}}), + record("r2", {type: "tool_result", id: "tool-1", denied: true}), + ]) + + expect(messages?.[0].parts[0]).toMatchObject({state: "output-denied"}) + }) +}) + +const approvalRecords = (): SessionRecord[] => [ + record("record-call", { + type: "tool_call", + id: "tool-1", + name: "bash", + input: {command: "ls"}, + }), + record("record-request", { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }), +] + +/** + * Ported from the OSS original (see the copy header): a resumed turn must not replay as still + * parked, or a reload keeps the approval dock up on a turn the user already answered. + */ +describe("transcriptToMessages approval resume", () => { + it("merges a paused turn with its resume into one message and settles the re-emitted call once", () => { + // Real cold-replay shape (verified against records): a Write call pauses for approval, the + // turn ends stopReason:"paused", then the resume turn RE-EMITS the same call id, settles it, + // and finishes. Reload must match the single live turn, not a dangling "awaiting" bubble. + const messages = transcriptToMessages([ + record("r-user", {type: "message", text: "write notes.md"}, "user"), + record("r-thought-1", {type: "thought", text: "let me write it"}), + record("r-call", { + type: "tool_call", + id: "tool-1", + name: "Write", + input: {path: "notes.md"}, + }), + record("r-req", { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }), + record("r-done-paused", { + type: "done", + stopReason: "paused", + traceId: "trace-paused", + }), + // resume turn: re-emits the SAME call id, then settles it and finishes. + record("r-call-reemit", { + type: "tool_call", + id: "tool-1", + name: "Write", + input: {path: "notes.md"}, + }), + record("r-resp", { + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1", approved: true}, + }), + record("r-result", {type: "tool_result", id: "tool-1", output: "written"}), + record("r-thought-2", {type: "thought", text: "done"}), + record("r-msg", {type: "message", text: "Done!"}), + record("r-done", {type: "done", traceId: "trace-resume"}), + ]) + + expect(messages).not.toBeNull() + // user + ONE merged assistant turn, not user + paused bubble + resumed bubble. + expect(messages).toHaveLength(2) + const assistant = messages![1] + expect(assistant.role).toBe("assistant") + + // Exactly one Write tool part, settled to a single output-available — no duplicate. + const toolParts = (assistant.parts as unknown as Record[]).filter( + (part) => "toolCallId" in part, + ) + expect(toolParts).toHaveLength(1) + expect(toolParts[0]).toMatchObject({toolCallId: "tool-1", state: "output-available"}) + + // The resumed-and-completed turn is no longer flagged paused. + expect( + (assistant as unknown as {metadata?: {paused?: boolean}}).metadata?.paused, + ).toBeFalsy() + + // "View full trace" on the merged turn links to the RESUME trace (where the tool ran), + // not the paused turn's trace. + expect((assistant as unknown as {metadata?: {traceId?: string}}).metadata?.traceId).toBe( + "trace-resume", + ) + }) + + it("settles a resumed turn's gate even when the log has no interaction_response", () => { + // Real shape of an approval answered on ANOTHER device (verified against `records`): the + // paused turn carries the request, the resume turn carries only thought/usage/message/done — + // no `interaction_response`, no re-emitted call, no `tool_result`. The gate must NOT replay + // as pending, or the desktop reload keeps showing "Approval needed to continue". + const messages = transcriptToMessages([ + record("r-user", {type: "message", text: "create hello.md"}, "user"), + record("r-call", { + type: "tool_call", + id: "tool-1", + name: "bash", + input: {command: "cat > hello.md"}, + }), + record("r-req", { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }), + record("r-done-paused", {type: "done", stopReason: "paused"}), + record("r-thought", {type: "thought", text: "the user approved it"}), + record("r-msg", {type: "message", text: "Created hello.md"}), + record("r-done", {type: "done"}), + ]) + + expect(messages).toHaveLength(2) + const assistant = messages![1] + const parts = assistant.parts as unknown as Record[] + expect(parts.filter((part) => part.state === "approval-requested")).toEqual([]) + expect(parts.find((part) => part.toolCallId === "tool-1")).toMatchObject({ + state: "approval-responded", + approval: {id: "approval-1"}, + }) + expect( + (assistant as unknown as {metadata?: {paused?: boolean}}).metadata?.paused, + ).toBeFalsy() + }) + + it("keeps a still-parked turn's gate pending (no resume records yet)", () => { + const messages = transcriptToMessages([ + record("r-user", {type: "message", text: "create hello.md"}, "user"), + ...approvalRecords(), + record("r-done-paused", {type: "done", stopReason: "paused"}), + ]) + + const parts = messages![1].parts as unknown as Record[] + expect(parts.find((part) => part.toolCallId === "tool-1")).toMatchObject({ + state: "approval-requested", + }) + }) + + it("leaves a denied call denied across the pause boundary", () => { + const messages = transcriptToMessages([ + record("r-user", {type: "message", text: "create hello.md"}, "user"), + ...approvalRecords(), + record("r-done-paused", {type: "done", stopReason: "paused"}), + record("r-result-denied", {type: "tool_result", id: "tool-1", denied: true}), + record("r-msg", {type: "message", text: "Okay, skipping it."}), + record("r-done", {type: "done"}), + ]) + + const parts = messages![1].parts as unknown as Record[] + expect(parts.find((part) => part.toolCallId === "tool-1")).toMatchObject({ + state: "output-denied", + }) + }) +}) + +/** + * Cold approval resume: the harness re-raises the approved call under a NEW toolCallId, so the + * response's `toolCallId` no longer matches the gated part. Only the interaction id still does. + */ +describe("transcriptToMessages cold approval resume (re-raised tool call id)", () => { + const pausedTurn = (): SessionRecord[] => [ + record("r-user", {type: "message", text: "delete the file"}, "user"), + record("r-call-old", { + type: "tool_call", + id: "tool-old", + name: "bash", + input: {command: "rm x"}, + }), + record("r-req", { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-old"}, + }), + record("r-done-paused", {type: "done", stopReason: "paused"}), + record("r-call-new", { + type: "tool_call", + id: "tool-new", + name: "bash", + input: {command: "rm x"}, + }), + ] + + const response = (approved: boolean): SessionRecord => + record("r-resp", { + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-new", approved}, + }) + + const errorResult = (): SessionRecord => + record("r-result", { + type: "tool_result", + id: "tool-new", + output: "boom", + isError: true, + }) + + const toolParts = (records: SessionRecord[]): Record[] => { + const messages = transcriptToMessages(records) + expect(messages).not.toBeNull() + return (messages ?? []) + .flatMap((message) => message.parts as unknown as Record[]) + .filter((part) => "toolCallId" in part) + } + + it("settles the gated part when the response arrives before the re-raised result", () => { + const parts = toolParts([ + ...pausedTurn(), + response(true), + errorResult(), + record("r-done", {type: "done"}), + ]) + + expect(parts).toHaveLength(1) + expect(parts[0]).toMatchObject({ + state: "output-error", + errorText: "boom", + approval: {id: "approval-1", approved: true}, + }) + expect(parts.filter((part) => part.state === "approval-requested")).toEqual([]) + }) + + it("settles the gated part when the re-raised result arrives before the response", () => { + const parts = toolParts([ + ...pausedTurn(), + errorResult(), + response(true), + record("r-done", {type: "done"}), + ]) + + expect(parts).toHaveLength(1) + expect(parts[0]).toMatchObject({ + state: "output-error", + errorText: "boom", + approval: {id: "approval-1", approved: true}, + }) + expect(parts.filter((part) => part.state === "approval-requested")).toEqual([]) + }) + + it("resolves a denied re-raised call to output-denied", () => { + const parts = toolParts([ + ...pausedTurn(), + response(false), + record("r-result", {type: "tool_result", id: "tool-new", denied: true}), + record("r-done", {type: "done"}), + ]) + + expect(parts).toHaveLength(1) + expect(parts[0]).toMatchObject({ + state: "output-denied", + approval: {id: "approval-1", approved: false}, + }) + expect(parts.filter((part) => part.state === "approval-requested")).toEqual([]) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/fixtures/approvalTurn.json b/web/packages/agenta-chat/tests/unit/fixtures/approvalTurn.json new file mode 100644 index 0000000000..4c58feb65c --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/fixtures/approvalTurn.json @@ -0,0 +1,31 @@ +[ + {"id": "u1", "role": "user", "parts": [{"type": "text", "text": "delete the file and send a mail"}]}, + { + "id": "a1", + "role": "assistant", + "parts": [ + { + "type": "tool-delete_file", + "toolCallId": "call_1", + "state": "approval-requested", + "input": {"path": "notes.txt"}, + "approval": {"id": "appr_1"} + }, + { + "type": "tool-read_file", + "toolCallId": "call_2", + "state": "output-available", + "input": {"path": "readme.md"}, + "output": {"content": "hello"} + }, + { + "type": "dynamic-tool", + "toolCallId": "call_3", + "toolName": "send_mail", + "state": "approval-requested", + "input": {"to": "a@b.com"}, + "approval": {"id": "appr_2"} + } + ] + } +] diff --git a/web/packages/agenta-chat/tests/unit/fixtures/emptyTurns.json b/web/packages/agenta-chat/tests/unit/fixtures/emptyTurns.json new file mode 100644 index 0000000000..d3e93084c4 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/fixtures/emptyTurns.json @@ -0,0 +1,6 @@ +[ + {"id": "u1", "role": "user", "parts": [{"type": "text", "text": "hi"}]}, + {"id": "a1", "role": "assistant", "parts": []}, + {"id": "a2", "role": "assistant", "parts": [{"type": "reasoning", "text": " "}]}, + {"id": "a3", "role": "assistant", "parts": [{"type": "text", "text": "hello"}]} +] diff --git a/web/packages/agenta-chat/tests/unit/fixtures/reasoningOnlyTurn.json b/web/packages/agenta-chat/tests/unit/fixtures/reasoningOnlyTurn.json new file mode 100644 index 0000000000..a1ef7f9183 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/fixtures/reasoningOnlyTurn.json @@ -0,0 +1,7 @@ +[ + { + "id": "a1", + "role": "assistant", + "parts": [{"type": "reasoning", "text": "Let me think about this for a moment."}] + } +] diff --git a/web/packages/agenta-chat/tests/unit/fixtures/supersededGate.json b/web/packages/agenta-chat/tests/unit/fixtures/supersededGate.json new file mode 100644 index 0000000000..69e03ba30f --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/fixtures/supersededGate.json @@ -0,0 +1,27 @@ +[ + { + "id": "a1", + "role": "assistant", + "parts": [ + { + "type": "tool-write_file", + "toolCallId": "call_gate", + "state": "approval-responded", + "input": {"p": 1} + }, + { + "type": "tool-write_file", + "toolCallId": "call_gate_replay", + "state": "output-available", + "input": {"p": 1}, + "output": {"ok": true} + }, + { + "type": "tool-send_mail", + "toolCallId": "call_mail_gate", + "state": "approval-responded", + "input": {"q": 2} + } + ] + } +] diff --git a/web/packages/agenta-chat/tests/unit/fixtures/toolTurn.json b/web/packages/agenta-chat/tests/unit/fixtures/toolTurn.json new file mode 100644 index 0000000000..1231423c26 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/fixtures/toolTurn.json @@ -0,0 +1,24 @@ +[ + { + "id": "a1", + "role": "assistant", + "parts": [ + {"type": "text", "text": "Let me check a couple of things."}, + { + "type": "tool-read_file", + "toolCallId": "call_1", + "state": "output-available", + "input": {"path": "a.txt"}, + "output": {"content": "a"} + }, + { + "type": "tool-read_file", + "toolCallId": "call_2", + "state": "output-error", + "input": {"path": "missing.txt"}, + "errorText": "not found" + }, + {"type": "text", "text": "Here's what I found."} + ] + } +] diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts new file mode 100644 index 0000000000..c02f31485d --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +import {act, renderHook} from "@testing-library/react" +import type {UIMessage} from "ai" +import {describe, expect, it, vi} from "vitest" + +import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" + +// The pure release predicates (`canReleaseQueuedMessage`, `isHitlPending`) are unit-tested in +// the playground package; these tests cover the HOOK's stateful behavior on top of them: +// queue-while-streaming, one-per-settle FIFO release, the HITL hold, the stop/orphan voids, +// and per-session queue restoration across remounts. + +const userTurn = (id: string, text: string): UIMessage => + ({id, role: "user", parts: [{type: "text", text}]}) as UIMessage + +const assistantText = (id: string, text: string): UIMessage => + ({id, role: "assistant", parts: [{type: "text", text}]}) as UIMessage + +/** An assistant tail paused on a HITL tool gate (the dock-actionable state). */ +const assistantAwaitingApproval = (id: string): UIMessage => + ({ + id, + role: "assistant", + parts: [ + { + type: "tool-send_email", + state: "approval-requested", + toolCallId: `${id}-call`, + input: {to: "a@b.c"}, + approval: {id: `${id}-approval`}, + }, + ], + }) as unknown as UIMessage + +interface HarnessProps { + status: string + messages: UIMessage[] + stopped: boolean + resumeOrphaned?: boolean + sessionId?: string +} + +const setup = (initial: HarnessProps) => { + const sendQueued = vi.fn() + const view = renderHook((props: HarnessProps) => useAgentChatQueue({...props, sendQueued}), { + initialProps: initial, + }) + return {sendQueued, ...view} +} + +const settledEmpty: HarnessProps = {status: "ready", messages: [], stopped: false} + +describe("useAgentChatQueue", () => { + it("sends immediately when settled, unlatched, and the queue is empty", () => { + const {result, sendQueued} = setup(settledEmpty) + act(() => { + result.current.submit({text: "hello"}) + }) + expect(sendQueued).toHaveBeenCalledTimes(1) + expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "hello"}) + expect(result.current.queued).toHaveLength(0) + }) + + it("queues messages typed while a turn is streaming", () => { + const {result, sendQueued} = setup({ + status: "streaming", + messages: [userTurn("u1", "go")], + stopped: false, + }) + act(() => { + result.current.submit({text: "first"}) + }) + act(() => { + result.current.submit({text: "second"}) + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued.map((m) => m.text)).toEqual(["first", "second"]) + }) + + it("releases held messages one per settle, in FIFO order", () => { + const streaming: HarnessProps = { + status: "streaming", + messages: [userTurn("u1", "go")], + stopped: false, + } + const {result, rerender, sendQueued} = setup(streaming) + act(() => { + result.current.submit({text: "first"}) + result.current.submit({text: "second"}) + }) + // Stream settles → only the head releases (the latch caps it at one per settle). + rerender({...streaming, status: "ready", messages: [assistantText("a1", "done")]}) + expect(sendQueued).toHaveBeenCalledTimes(1) + expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "first"}) + expect(result.current.queued.map((m) => m.text)).toEqual(["second"]) + // The released message flips the conversation busy again; the next settle releases #2. + rerender({...streaming, status: "streaming"}) + rerender({...streaming, status: "ready", messages: [assistantText("a2", "done")]}) + expect(sendQueued).toHaveBeenCalledTimes(2) + expect(sendQueued.mock.calls[1][0]).toMatchObject({text: "second"}) + expect(result.current.queued).toHaveLength(0) + }) + + it("holds the queue (and reports hitlPending) while a HITL approval is pending", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + } + const {result, sendQueued} = setup(paused) + expect(result.current.hitlPending).toBe(true) + act(() => { + result.current.submit({text: "while paused"}) + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued.map((m) => m.text)).toEqual(["while paused"]) + }) + + it("releases a held message once the approval gate resolves", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + } + const {result, rerender, sendQueued} = setup(paused) + act(() => { + result.current.submit({text: "held"}) + }) + expect(sendQueued).not.toHaveBeenCalled() + // The approved tool ran and the resumed turn settled with real output. + rerender({...paused, messages: [userTurn("u1", "go"), assistantText("a2", "sent")]}) + expect(sendQueued).toHaveBeenCalledTimes(1) + expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "held"}) + expect(result.current.queued).toHaveLength(0) + }) + + it("a user stop voids the HITL hold: settled sends go immediately and hitlPending clears", () => { + const stoppedPaused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: true, + } + const {result, sendQueued} = setup(stoppedPaused) + expect(result.current.hitlPending).toBe(false) + act(() => { + result.current.submit({text: "after stop"}) + }) + expect(sendQueued).toHaveBeenCalledTimes(1) + expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "after stop"}) + }) + + it("an orphaned restored resume shape voids the hold the same way", () => { + // `approval-responded` with no live interaction = the pre-resume hold that can never fire. + const orphanTail = { + id: "a1", + role: "assistant", + parts: [ + { + type: "tool-send_email", + state: "approval-responded", + toolCallId: "a1-call", + input: {}, + approval: {id: "a1-approval", approved: true}, + }, + ], + } as unknown as UIMessage + const base: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), orphanTail], + stopped: false, + } + // Without the orphan flag the pre-resume hold applies… + const held = setup(base) + act(() => { + held.result.current.submit({text: "queued"}) + }) + expect(held.sendQueued).not.toHaveBeenCalled() + // …with it, the settled conversation is releasable. + const released = setup({...base, resumeOrphaned: true}) + act(() => { + released.result.current.submit({text: "released"}) + }) + expect(released.sendQueued).toHaveBeenCalledTimes(1) + }) + + it("removeQueued and clearQueue edit the held list without sending", () => { + const streaming: HarnessProps = { + status: "streaming", + messages: [userTurn("u1", "go")], + stopped: false, + } + const {result, sendQueued} = setup(streaming) + act(() => { + result.current.submit({text: "one"}) + result.current.submit({text: "two"}) + result.current.submit({text: "three"}) + }) + const secondId = result.current.queued[1].id + act(() => { + result.current.removeQueued(secondId) + }) + expect(result.current.queued.map((m) => m.text)).toEqual(["one", "three"]) + act(() => { + result.current.clearQueue() + }) + expect(result.current.queued).toHaveLength(0) + expect(sendQueued).not.toHaveBeenCalled() + }) + + it("restores a held queue for the same session across a remount", () => { + const sessionId = `queue-restore-${Date.now()}` + const streaming: HarnessProps = { + status: "streaming", + messages: [userTurn("u1", "go")], + stopped: false, + sessionId, + } + const first = setup(streaming) + act(() => { + first.result.current.submit({text: "survives"}) + }) + first.unmount() + // A fresh mount under the same session id picks the held message back up… + const second = setup(streaming) + expect(second.result.current.queued.map((m) => m.text)).toEqual(["survives"]) + // …and a different session starts empty. + const other = setup({...streaming, sessionId: `${sessionId}-other`}) + expect(other.result.current.queued).toHaveLength(0) + act(() => { + second.result.current.clearQueue() + }) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts new file mode 100644 index 0000000000..fc62b5745e --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -0,0 +1,247 @@ +// @vitest-environment jsdom +// +// Integration smoke for the headless conversation host. The stream engine is REAL +// (`useChat` + the negotiating transport parsing a mocked SSE `fetch`); only the app-layer +// seams are stubbed: the playground request builder (no live workflow config in a unit test) +// and the entities/session revalidation atoms (no query client here). The assertions cover +// genuine end-to-end behavior: send → queue → transport → streamed assistant turn → +// persist-on-settle → run-status publish, plus error stamping and the rewind plan. +import {createElement, type ReactNode} from "react" + +import {act, renderHook, waitFor} from "@testing-library/react" +import type {UIMessage} from "ai" +import {createStore, Provider} from "jotai" +import {beforeEach, describe, expect, it, vi} from "vitest" + +vi.mock("@agenta/playground", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + buildAgentRequest: vi.fn( + async (_entityId: string, _messages: UIMessage[], opts?: {sessionId?: string}) => ({ + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream", "content-type": "application/json"}, + requestBody: {session_id: opts?.sessionId}, + }), + ), + } +}) + +vi.mock("@agenta/entities/session", async () => { + const {atom} = await import("jotai") + return { + revalidateSessionMountsAtom: atom(null, () => {}), + revalidateSessionRecordsAtom: atom(null, () => {}), + // The hydration seam's records fetch: "no server history" for these tests. + fetchSessionRecordsAtom: atom(null, () => ({records: null, refreshed: null})), + } +}) + +vi.mock("@agenta/entities/trace", () => ({ + markTraceAsFresh: vi.fn(), +})) + +import {buildAgentRequest} from "@agenta/playground" + +import {useAgentConversation} from "../../../src/hooks/useAgentConversation" +import {markSessionFresh} from "../../../src/state/sessionEphemera" +import {sessionMessagesAtom, sessionStatusAtomFamily} from "../../../src/state/sessionMessages" + +const sseBody = (text: string): string => { + const chunks = [ + {type: "start", messageId: `assist-${Math.random().toString(36).slice(2)}`}, + {type: "start-step"}, + {type: "text-start", id: "t1"}, + {type: "text-delta", id: "t1", delta: text}, + {type: "text-end", id: "t1"}, + {type: "finish-step"}, + {type: "finish"}, + ] + return chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + "data: [DONE]\n\n" +} + +const streamResponse = (text: string): Response => + new Response(sseBody(text), { + status: 200, + headers: {"content-type": "text/event-stream"}, + }) + +const errorResponse = (): Response => + new Response(JSON.stringify({status: {code: 500, message: "boom"}}), { + status: 500, + headers: {"content-type": "application/json"}, + }) + +const fetchMock = vi.fn() +vi.stubGlobal("fetch", fetchMock) + +let seq = 0 +const nextSessionId = () => `conv-test-${Date.now()}-${(seq += 1)}` + +const mount = (store: ReturnType, entityId: string, sessionId: string) => + renderHook(() => useAgentConversation({entityId, sessionId}), { + wrapper: ({children}: {children: ReactNode}) => createElement(Provider, {store}, children), + }) + +beforeEach(() => { + fetchMock.mockReset() + vi.mocked(buildAgentRequest).mockClear() +}) + +describe("useAgentConversation", () => { + it("runs a full turn: send → stream → settle → persist + status publish", async () => { + fetchMock.mockResolvedValue(streamResponse("Hello back")) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) // brand-new session: no hydration fetch + const {result} = mount(store, "rev-1", sessionId) + + expect(result.current.isEmpty).toBe(true) + expect(result.current.isHydrating).toBe(false) + expect(result.current.runStatus).toBe("idle") + expect(result.current.status).toBe("ready") + + await act(async () => { + await result.current.send({text: "hi there"}) + }) + await waitFor( + () => { + expect(result.current.status).toBe("ready") + expect(result.current.messages).toHaveLength(2) + }, + {timeout: 5000}, + ) + + // The request went through the playground builder with the LIVE entity + session. + expect(vi.mocked(buildAgentRequest)).toHaveBeenCalledWith( + "rev-1", + expect.any(Array), + expect.objectContaining({sessionId}), + ) + + // Turn view models: user turn + answered assistant turn. + expect(result.current.turns).toHaveLength(2) + expect(result.current.turns[0].isUser).toBe(true) + expect(result.current.turns[1].status.hasAnswer).toBe(true) + const answer = result.current.messages[1].parts.find((p) => p.type === "text") as + | {text?: string} + | undefined + expect(answer?.text).toBe("Hello back") + + // Persist-on-settle wrote the conversation to the package message store… + expect(store.get(sessionMessagesAtom)[sessionId]).toHaveLength(2) + // …and the published run status is back to idle. + expect(store.get(sessionStatusAtomFamily(sessionId))).toBe("idle") + expect(result.current.runStatus).toBe("idle") + expect(result.current.isEmpty).toBe(false) + }) + + it("rewinding a user message truncates the conversation and hands back its text", async () => { + fetchMock.mockResolvedValue(streamResponse("answer")) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "rewind me"}) + }) + await waitFor(() => expect(result.current.messages).toHaveLength(2), {timeout: 5000}) + + const plan = result.current.rewind(result.current.messages[0]) + expect(plan).not.toBeNull() + expect(plan?.sideEffects).toEqual([]) + expect(plan?.restoreText).toBe("rewind me") + await act(async () => { + plan?.confirm() + }) + // The stream throttle coalesces UI commits — the truncation lands a beat later. + await waitFor(() => expect(result.current.messages).toHaveLength(0), {timeout: 5000}) + }) + + // The skin holds the plan open across its warning dialog, so the transcript can move + // underneath it. Truncating against the scan-time snapshot would wipe whatever replaced it. + it("a stale rewind plan leaves a transcript its target no longer belongs to alone", async () => { + // Two turns here, so each send needs its own unread body. + fetchMock.mockImplementation(async () => streamResponse("answer")) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "first"}) + }) + await waitFor(() => expect(result.current.messages).toHaveLength(2), {timeout: 5000}) + + const plan = result.current.rewind(result.current.messages[0]) + await act(async () => { + plan?.confirm() + }) + await waitFor(() => expect(result.current.messages).toHaveLength(0), {timeout: 5000}) + + await act(async () => { + await result.current.send({text: "second"}) + }) + await waitFor(() => expect(result.current.messages).toHaveLength(2), {timeout: 5000}) + + // Confirming the now-stale plan must not truncate the conversation that replaced it. + // A truncation commits one throttle window later (50ms), so wait past it before + // asserting nothing happened. + await act(async () => { + plan?.confirm() + await new Promise((resolve) => setTimeout(resolve, 400)) + }) + expect(result.current.messages).toHaveLength(2) + }) + + it("seeds from the persisted store and skips hydration for cached sessions", async () => { + const store = createStore() + const sessionId = nextSessionId() + const cached = [ + {id: "u1", role: "user", parts: [{type: "text", text: "earlier"}]}, + {id: "a1", role: "assistant", parts: [{type: "text", text: "before"}]}, + ] as UIMessage[] + store.set(sessionMessagesAtom, {[sessionId]: cached}) + const {result} = mount(store, "rev-1", sessionId) + + expect(result.current.isHydrating).toBe(false) + expect(result.current.messages).toHaveLength(2) + expect(result.current.isEmpty).toBe(false) + // The revalidate-on-open pass found no server records — the cache stays authoritative. + await waitFor(() => expect(result.current.messages).toHaveLength(2)) + expect(result.current.historyUnavailable).toBe(false) + }) + + it("flags a known-but-empty session as history-unavailable after hydration", async () => { + const store = createStore() + const sessionId = nextSessionId() // NOT fresh, NOT cached → hydration path + const {result} = mount(store, "rev-1", sessionId) + + expect(result.current.isHydrating).toBe(true) + await waitFor(() => expect(result.current.isHydrating).toBe(false), {timeout: 5000}) + expect(result.current.historyUnavailable).toBe(true) + expect(result.current.isEmpty).toBe(true) + }) + + it("stamps a stream failure onto the turn and reports the parsed error", async () => { + fetchMock.mockResolvedValue(errorResponse()) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "explode"}) + }) + await waitFor(() => expect(result.current.runStatus).toBe("error"), {timeout: 5000}) + + expect(result.current.error?.message).toBe("boom") + // The failure landed on a stamped assistant carrier turn, surfaced via the turn model. + await waitFor(() => { + const last = result.current.turns[result.current.turns.length - 1] + expect(last.status.errorText).toBe("boom") + expect(last.status.isError).toBe(true) + }) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts new file mode 100644 index 0000000000..62fa011ce9 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import {act, renderHook} from "@testing-library/react" +import type {UIMessage} from "ai" +import {describe, expect, it, vi} from "vitest" + +import {useApprovalDock} from "../../../src/hooks/useApprovalDock" + +const gatePart = (approvalId: string, toolName = "send_email") => ({ + type: `tool-${toolName}`, + state: "approval-requested", + toolCallId: `${approvalId}-call`, + input: {gate: approvalId}, + approval: {id: approvalId}, +}) + +const assistantWithGates = (...approvalIds: string[]): UIMessage => + ({ + id: "a1", + role: "assistant", + parts: approvalIds.map((id) => gatePart(id)), + }) as unknown as UIMessage + +const userTurn: UIMessage = { + id: "u1", + role: "user", + parts: [{type: "text", text: "go"}], +} as UIMessage + +const settledAssistant: UIMessage = { + id: "a1", + role: "assistant", + parts: [{type: "text", text: "done"}], +} as UIMessage + +const setup = (messages: UIMessage[]) => { + const respond = vi.fn() + const view = renderHook( + (props: {messages: UIMessage[]}) => useApprovalDock({messages: props.messages, respond}), + {initialProps: {messages}}, + ) + return {respond, ...view} +} + +describe("useApprovalDock", () => { + it("is closed with no pending gates", () => { + const {result} = setup([userTurn, settledAssistant]) + expect(result.current.open).toBe(false) + expect(result.current.current).toBeNull() + expect(result.current.count).toBe(0) + // respond on an empty dock is a no-op. + act(() => { + result.current.respond(true) + }) + expect(result.current.responding).toBe(false) + }) + + it("extracts the paused turn's gates: first is current, count covers the batch", () => { + const {result} = setup([userTurn, assistantWithGates("g1", "g2", "g3")]) + expect(result.current.open).toBe(true) + expect(result.current.count).toBe(3) + expect(result.current.current?.approvalId).toBe("g1") + expect(result.current.current?.toolName).toBe("send_email") + }) + + it("respond answers the current gate once and latches until the gate changes", () => { + const {result, rerender, respond} = setup([userTurn, assistantWithGates("g1", "g2")]) + act(() => { + result.current.respond(true) + }) + expect(respond).toHaveBeenCalledTimes(1) + expect(respond).toHaveBeenCalledWith({id: "g1", approved: true}) + expect(result.current.responding).toBe(true) + // A second click while responding is swallowed. + act(() => { + result.current.respond(false) + }) + expect(respond).toHaveBeenCalledTimes(1) + // The SDK settles g1 → the next gate slides in and responding resets. + rerender({messages: [userTurn, assistantWithGates("g2")]}) + expect(result.current.current?.approvalId).toBe("g2") + expect(result.current.responding).toBe(false) + act(() => { + result.current.respond(false) + }) + expect(respond).toHaveBeenCalledWith({id: "g2", approved: false}) + }) + + it("approveAll fans out to every gate and freezes the shown set while they settle", () => { + const {result, rerender, respond} = setup([userTurn, assistantWithGates("g1", "g2")]) + act(() => { + result.current.approveAll() + }) + expect(respond).toHaveBeenCalledTimes(2) + expect(respond).toHaveBeenNthCalledWith(1, {id: "g1", approved: true}) + expect(respond).toHaveBeenNthCalledWith(2, {id: "g2", approved: true}) + // g1 settles first — the card must NOT step to "1 of 1"; the shown set stays frozen. + rerender({messages: [userTurn, assistantWithGates("g2")]}) + expect(result.current.count).toBe(2) + expect(result.current.current?.approvalId).toBe("g1") + expect(result.current.responding).toBe(true) + // All settle → the dock closes in one step. + rerender({messages: [userTurn, settledAssistant]}) + expect(result.current.open).toBe(false) + }) + + it("keeps the last card latched while closed so a leave transition has content", () => { + const {result, rerender} = setup([userTurn, assistantWithGates("g1")]) + expect(result.current.current?.approvalId).toBe("g1") + rerender({messages: [userTurn, settledAssistant]}) + expect(result.current.open).toBe(false) + // The latched gate is still available for the closing animation frame. + expect(result.current.current?.approvalId).toBe("g1") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts new file mode 100644 index 0000000000..1b377a216b --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +import {act, renderHook, waitFor} from "@testing-library/react" +import {describe, expect, it} from "vitest" + +import {DEFAULT_ATTACHMENT_LIMITS} from "../../../src/assets/attachmentRules" +import { + useComposerAttachments, + type UseComposerAttachmentsArgs, +} from "../../../src/hooks/useComposerAttachments" +import {attachmentsBySession} from "../../../src/state/sessionEphemera" + +const makeFile = (name: string, type = "text/plain", size = 16): File => { + const blob = new Uint8Array(size).fill(97) + return new File([blob], name, {type, lastModified: 1_700_000_000_000}) +} + +const setup = (args: UseComposerAttachmentsArgs = {}) => + renderHook((props: UseComposerAttachmentsArgs) => useComposerAttachments(props), { + initialProps: args, + }) + +describe("useComposerAttachments", () => { + it("stages accepted files with the shared dedup uid and no rejections", () => { + const {result} = setup() + act(() => { + result.current.add([makeFile("notes.txt")]) + }) + expect(result.current.files).toHaveLength(1) + expect(result.current.files[0].name).toBe("notes.txt") + expect(result.current.files[0].uid).toBe("notes.txt-1700000000000-16") + expect(result.current.rejections).toHaveLength(0) + expect(result.current.atMax).toBe(false) + }) + + it("rejects unsupported types and oversized files with a per-file reason", () => { + const {result} = setup() + act(() => { + result.current.add([ + makeFile("clip.mp4", "video/mp4"), + makeFile("huge.txt", "text/plain", DEFAULT_ATTACHMENT_LIMITS.maxBytes + 1), + makeFile("ok.txt"), + ]) + }) + expect(result.current.files.map((f) => f.name)).toEqual(["ok.txt"]) + expect(result.current.rejections.map((r) => r.name)).toEqual(["clip.mp4", "huge.txt"]) + expect(result.current.rejections[0].reason).toContain("supported file type") + expect(result.current.rejections[1].reason).toContain("too large") + }) + + it("caps the staged set at the count limit and flags atMax", () => { + const {result} = setup() + const batch = Array.from({length: DEFAULT_ATTACHMENT_LIMITS.maxCount + 2}, (_, i) => + makeFile(`f${i}.txt`), + ) + act(() => { + result.current.add(batch) + }) + expect(result.current.files).toHaveLength(DEFAULT_ATTACHMENT_LIMITS.maxCount) + expect(result.current.atMax).toBe(true) + expect(result.current.rejections).toHaveLength(2) + expect(result.current.rejections[0].reason).toContain("limit") + // At the cap, another add stages nothing more. + act(() => { + result.current.add([makeFile("extra.txt")]) + }) + expect(result.current.files).toHaveLength(DEFAULT_ATTACHMENT_LIMITS.maxCount) + expect(result.current.rejections.map((r) => r.name)).toEqual(["extra.txt"]) + }) + + // A paste and a drop can both fire before React re-renders. Reading the count from the + // render closure makes both batches see zero staged files, so the cap is passed. + it("holds the count limit across two adds in the same tick", () => { + const {result} = setup() + const half = Math.ceil(DEFAULT_ATTACHMENT_LIMITS.maxCount / 2) + 1 + const batch = (tag: string) => + Array.from({length: half}, (_, i) => makeFile(`${tag}${i}.txt`)) + act(() => { + result.current.add(batch("a")) + result.current.add(batch("b")) + }) + expect(result.current.files.length).toBeLessThanOrEqual(DEFAULT_ATTACHMENT_LIMITS.maxCount) + expect(result.current.atMax).toBe(true) + }) + + it("re-seeds from the new session when sessionId changes on a mounted instance", () => { + const a = `attach-swap-a-${Date.now()}` + const b = `attach-swap-b-${Date.now()}` + attachmentsBySession.set(b, [ + {uid: "seeded", name: "from-b.txt", size: 4, type: "text/plain", file: makeFile("x")}, + ] as never) + const {result, rerender} = setup({sessionId: a}) + act(() => { + result.current.add([makeFile("from-a.txt")]) + }) + expect(result.current.files.map((f) => f.name)).toEqual(["from-a.txt"]) + + rerender({sessionId: b}) + // Session b's own staged file, not session a's leaking across. + expect(result.current.files.map((f) => f.name)).toEqual(["from-b.txt"]) + // …and session a keeps what it had rather than being overwritten under the new key. + expect(attachmentsBySession.get(a)?.map((f) => f.name)).toEqual(["from-a.txt"]) + }) + + it("remove unstages one file; dismissRejections keeps files; clear drops both", () => { + const {result} = setup() + act(() => { + result.current.add([ + makeFile("a.txt"), + makeFile("b.txt"), + makeFile("bad.mp4", "video/mp4"), + ]) + }) + expect(result.current.files).toHaveLength(2) + expect(result.current.rejections).toHaveLength(1) + act(() => { + result.current.remove(result.current.files[0].uid) + }) + expect(result.current.files.map((f) => f.name)).toEqual(["b.txt"]) + act(() => { + result.current.dismissRejections() + }) + expect(result.current.rejections).toHaveLength(0) + expect(result.current.files).toHaveLength(1) + act(() => { + result.current.clear() + }) + expect(result.current.files).toHaveLength(0) + }) + + it("toParts encodes the staged files as inline data-URL file parts", async () => { + const {result} = setup() + act(() => { + result.current.add([makeFile("doc.txt")]) + }) + const parts = await result.current.toParts() + expect(parts).toHaveLength(1) + expect(parts[0]).toMatchObject({type: "file", mediaType: "text/plain", filename: "doc.txt"}) + expect(parts[0].url.startsWith("data:text/plain;base64,")).toBe(true) + // Empty staged set resolves to an empty list without touching FileReader. + act(() => { + result.current.clear() + }) + await waitFor(async () => expect(await result.current.toParts()).toEqual([])) + }) + + it("persists staged files per session across a remount, keyed by session id", () => { + const sessionId = `attach-restore-${Date.now()}` + const first = setup({sessionId}) + act(() => { + first.result.current.add([makeFile("keep.txt")]) + }) + first.unmount() + expect(attachmentsBySession.get(sessionId)).toHaveLength(1) + const second = setup({sessionId}) + expect(second.result.current.files.map((f) => f.name)).toEqual(["keep.txt"]) + const other = setup({sessionId: `${sessionId}-other`}) + expect(other.result.current.files).toHaveLength(0) + // Clearing empties the per-session store too. + act(() => { + second.result.current.clear() + }) + expect(attachmentsBySession.has(sessionId)).toBe(false) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/approvals.test.ts b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts new file mode 100644 index 0000000000..48d1f6650f --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts @@ -0,0 +1,30 @@ +import type {UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {getPendingApprovals} from "../../../src/model/approvals" +import approvalTurnFixture from "../fixtures/approvalTurn.json" + +describe("getPendingApprovals", () => { + it("returns the pending approvals off the last assistant turn, in order", () => { + const messages = approvalTurnFixture as UIMessage[] + expect(getPendingApprovals(messages)).toEqual([ + {approvalId: "appr_1", toolName: "delete_file", input: {path: "notes.txt"}}, + {approvalId: "appr_2", toolName: "send_mail", input: {to: "a@b.com"}}, + ]) + }) + + it("is empty when the last message is from the user", () => { + const messages: UIMessage[] = [ + { + id: "u1", + role: "user", + parts: [{type: "text", text: "hi"}], + } as unknown as UIMessage, + ] + expect(getPendingApprovals(messages)).toEqual([]) + }) + + it("is empty for an empty message list", () => { + expect(getPendingApprovals([])).toEqual([]) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/attachments.test.ts b/web/packages/agenta-chat/tests/unit/model/attachments.test.ts new file mode 100644 index 0000000000..1e3fc38080 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/attachments.test.ts @@ -0,0 +1,14 @@ +import {describe, expect, it} from "vitest" + +import {toPendingAttachment} from "../../../src/model/attachments" + +describe("toPendingAttachment", () => { + it("derives the same uid formula the desktop composer uses", () => { + const file = new File(["hello"], "notes.txt", {lastModified: 1720000000000}) + expect(toPendingAttachment(file)).toEqual({ + file, + uid: `notes.txt-1720000000000-${file.size}`, + name: "notes.txt", + }) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/error.test.ts b/web/packages/agenta-chat/tests/unit/model/error.test.ts new file mode 100644 index 0000000000..b7cbaad89f --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/error.test.ts @@ -0,0 +1,32 @@ +import {describe, expect, it} from "vitest" + +import {parseAgentRunError} from "../../../src/model/error" + +describe("parseAgentRunError", () => { + it("pulls message + code out of a status envelope carried on an Error", () => { + const err = new Error(JSON.stringify({status: {code: 404, message: "Not found"}})) + expect(parseAgentRunError(err)).toEqual({message: "Not found", code: 404}) + }) + + it("pulls message + code out of a status envelope passed as a raw string", () => { + const raw = JSON.stringify({status: {code: 500, message: "Boom"}}) + expect(parseAgentRunError(raw)).toEqual({message: "Boom", code: 500}) + }) + + it("falls back to a top-level message when there's no status wrapper", () => { + const raw = JSON.stringify({message: "Top level"}) + expect(parseAgentRunError(raw)).toEqual({message: "Top level", code: undefined}) + }) + + it("passes a plain non-JSON string straight through", () => { + expect(parseAgentRunError("Something broke")).toEqual({message: "Something broke"}) + }) + + it("uses the real fallback copy for an undefined error", () => { + expect(parseAgentRunError(undefined)).toEqual({message: "The agent run failed."}) + }) + + it("uses the real fallback copy for an empty string", () => { + expect(parseAgentRunError("")).toEqual({message: "The agent run failed."}) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/grouping.test.ts b/web/packages/agenta-chat/tests/unit/model/grouping.test.ts new file mode 100644 index 0000000000..2884d96c47 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/grouping.test.ts @@ -0,0 +1,51 @@ +import type {UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {getTurnGrouping} from "../../../src/model/grouping" + +const msg = (role: UIMessage["role"], id: string): UIMessage => + ({id, role, parts: []}) as unknown as UIMessage + +describe("getTurnGrouping", () => { + it("anchors the active turn on the last user message", () => { + const messages = [ + msg("user", "u1"), + msg("assistant", "a1"), + msg("user", "u2"), + msg("assistant", "a2"), + ] + expect(getTurnGrouping(messages)).toEqual({ + lastUserIndex: 2, + activeStart: 2, + reserveActive: true, + }) + }) + + it("reserves fill even in the degenerate no-user case, anchored past the end", () => { + // No user message at all — lastUserIndex stays -1, so activeStart falls back to + // messages.length. reserveActive is still true because activeStart (2) > 0. + const messages = [msg("assistant", "a1"), msg("assistant", "a2")] + expect(getTurnGrouping(messages)).toEqual({ + lastUserIndex: -1, + activeStart: 2, + reserveActive: true, + }) + }) + + it("does not reserve fill for an empty conversation", () => { + expect(getTurnGrouping([])).toEqual({ + lastUserIndex: -1, + activeStart: 0, + reserveActive: false, + }) + }) + + it("does not reserve fill for the opening turn (a single leading user message)", () => { + const messages = [msg("user", "u1")] + expect(getTurnGrouping(messages)).toEqual({ + lastUserIndex: 0, + activeStart: 0, + reserveActive: false, + }) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/parts.test.ts b/web/packages/agenta-chat/tests/unit/model/parts.test.ts new file mode 100644 index 0000000000..026dcf5218 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/parts.test.ts @@ -0,0 +1,79 @@ +import type {ToolUIPart, UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import { + isEmptyAssistantTurn, + isToolPart, + isVisiblePart, + partToolName, + toolIdentity, +} from "../../../src/model/parts" +import emptyTurnsFixture from "../fixtures/emptyTurns.json" + +describe("isToolPart", () => { + it("is true for typed tool-* parts", () => { + expect(isToolPart("tool-web_search")).toBe(true) + }) + + it("is true for dynamic-tool parts", () => { + expect(isToolPart("dynamic-tool")).toBe(true) + }) + + it("is false for a plain text part", () => { + expect(isToolPart("text")).toBe(false) + }) +}) + +describe("isVisiblePart", () => { + it("is false for a blank reasoning part", () => { + expect(isVisiblePart({type: "reasoning", text: " "} as UIMessage["parts"][number])).toBe( + false, + ) + }) + + it("is true for a file part", () => { + expect( + isVisiblePart({ + type: "file", + mediaType: "text/plain", + url: "https://example.com/a.txt", + } as UIMessage["parts"][number]), + ).toBe(true) + }) +}) + +describe("isEmptyAssistantTurn", () => { + it("matches the real predicate over the fixture turns", () => { + const messages = emptyTurnsFixture as UIMessage[] + expect(messages.map(isEmptyAssistantTurn)).toEqual([false, true, true, false]) + }) +}) + +describe("toolIdentity", () => { + it("dedup-keys on type + stringified input", () => { + const part = {type: "tool-x", input: {a: 1}} as unknown as ToolUIPart + expect(toolIdentity(part)).toBe('tool-x::{"a":1}') + }) + + it("falls back to a null input key when the part has no input", () => { + const part = {type: "dynamic-tool"} as unknown as ToolUIPart + expect(toolIdentity(part)).toBe("dynamic-tool::null") + }) +}) + +describe("partToolName", () => { + it("strips the tool- prefix off a typed tool part", () => { + const part = {type: "tool-web_search"} as unknown as ToolUIPart + expect(partToolName(part)).toBe("web_search") + }) + + it("reads toolName off a dynamic-tool part", () => { + const part = {type: "dynamic-tool", toolName: "custom_tool"} as unknown as ToolUIPart + expect(partToolName(part)).toBe("custom_tool") + }) + + it("falls back to 'tool' when a dynamic-tool part has no toolName", () => { + const part = {type: "dynamic-tool"} as unknown as ToolUIPart + expect(partToolName(part)).toBe("tool") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/renderModel.test.ts b/web/packages/agenta-chat/tests/unit/model/renderModel.test.ts new file mode 100644 index 0000000000..06239af2cc --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/renderModel.test.ts @@ -0,0 +1,83 @@ +import type {ToolUIPart, UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import { + buildTurnRenderItems, + executedToolIdentities, + isSupersededGate, + type RenderItem, +} from "../../../src/model/renderModel" +import supersededGateFixture from "../fixtures/supersededGate.json" +import toolTurnFixture from "../fixtures/toolTurn.json" + +const noClientTools = () => false + +describe("executedToolIdentities", () => { + it("collects tool identities that reached output-available or output-error", () => { + const [message] = toolTurnFixture as UIMessage[] + const executed = executedToolIdentities(message.parts) + expect(executed.size).toBe(2) + }) +}) + +describe("buildTurnRenderItems", () => { + it("folds consecutive tool parts into one tools group between the surrounding text parts", () => { + const [message] = toolTurnFixture as UIMessage[] + const executed = executedToolIdentities(message.parts) + const items = buildTurnRenderItems(message.parts, { + executed, + isClientToolPart: noClientTools, + }) + expect(items.map((i) => i.kind)).toEqual(["part", "tools", "part"]) + expect((items[1] as Extract).parts).toHaveLength(2) + }) + + it("drops a superseded approval-responded gate while keeping an in-flight one", () => { + const [message] = supersededGateFixture as UIMessage[] + const executed = executedToolIdentities(message.parts) + const items = buildTurnRenderItems(message.parts, { + executed, + isClientToolPart: noClientTools, + }) + // The write_file gate is dropped (superseded); its executed sibling and the still-pending + // send_mail gate fold into a single consecutive tools group. + expect(items).toHaveLength(1) + expect(items[0].kind).toBe("tools") + const toolParts = (items[0] as Extract).parts + expect(toolParts.map((p) => p.toolCallId)).toEqual(["call_gate_replay", "call_mail_gate"]) + }) + + it("breaks the tool fold across a client-tool part", () => { + const parts = [ + {type: "text", text: "before"}, + {type: "tool-a", toolCallId: "t1", state: "output-available", output: {}}, + {type: "tool-b", toolCallId: "t2", state: "output-available", output: {}}, + {type: "tool-client", toolCallId: "client_1", state: "output-available", output: {}}, + {type: "text", text: "after"}, + ] as unknown as UIMessage["parts"] + const items = buildTurnRenderItems(parts, { + executed: new Set(), + isClientToolPart: (p: ToolUIPart) => p.toolCallId === "client_1", + }) + expect(items.map((i) => i.kind)).toEqual(["part", "tools", "clientTool", "part"]) + expect((items[1] as Extract).parts).toHaveLength(2) + }) +}) + +describe("isSupersededGate", () => { + it("is true for an approval-responded gate whose identity is in the executed set", () => { + const [message] = supersededGateFixture as UIMessage[] + const [gate] = message.parts as ToolUIPart[] + const executed = executedToolIdentities(message.parts) + expect(isSupersededGate(gate, executed)).toBe(true) + }) + + it("is false when the identity hasn't executed", () => { + const part = { + type: "tool-x", + state: "approval-responded", + input: {a: 1}, + } as unknown as ToolUIPart + expect(isSupersededGate(part, new Set())).toBe(false) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/sessionStatus.test.ts b/web/packages/agenta-chat/tests/unit/model/sessionStatus.test.ts new file mode 100644 index 0000000000..a4e8600baf --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/sessionStatus.test.ts @@ -0,0 +1,16 @@ +import {describe, expect, it} from "vitest" + +import {deriveSessionRunStatus, type SessionRunStatusInputs} from "../../../src/model/sessionStatus" + +describe("deriveSessionRunStatus", () => { + it.each<[SessionRunStatusInputs, string]>([ + [{error: true, hitlPending: true, busy: true}, "error"], + [{error: true, hitlPending: false, busy: false}, "error"], + [{error: false, hitlPending: true, busy: true}, "awaiting"], + [{error: false, hitlPending: true, busy: false}, "awaiting"], + [{error: false, hitlPending: false, busy: true}, "running"], + [{error: false, hitlPending: false, busy: false}, "idle"], + ])("precedence for %j is %s", (inputs, expected) => { + expect(deriveSessionRunStatus(inputs)).toBe(expected) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/toolSummary.test.ts b/web/packages/agenta-chat/tests/unit/model/toolSummary.test.ts new file mode 100644 index 0000000000..bf5827da44 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/toolSummary.test.ts @@ -0,0 +1,143 @@ +import type {ToolUIPart} from "ai" +import {describe, expect, it} from "vitest" + +import { + isNotHandledOutput, + isSettled, + rowSummary, + stripFence, + summarizeOutput, + type ToolSummaryDisplay, +} from "../../../src/model/toolSummary" + +describe("summarizeOutput", () => { + it("counts array results, pluralized", () => { + expect(summarizeOutput([1, 2])).toBe("2 results") + }) + + it("counts a single array result as singular", () => { + expect(summarizeOutput([1])).toBe("1 result") + }) + + it("normalizes whitespace and clamps a long string to 80 chars with an ellipsis", () => { + const long = "a".repeat(100) + const result = summarizeOutput(long) + expect(result).toBe(`${"a".repeat(80)}…`) + }) + + it("collapses internal whitespace runs to a single space", () => { + expect(summarizeOutput("hello \n world")).toBe("hello world") + }) + + it("prefers a well-known field over the generic field count", () => { + expect(summarizeOutput({summary: "done"})).toBe("done") + }) + + it("falls back to a field count when no well-known field matches", () => { + expect(summarizeOutput({a: 1, b: 2})).toBe("2 fields") + }) + + it("returns null for an empty object", () => { + expect(summarizeOutput({})).toBeNull() + }) + + it("returns null for a nullish output", () => { + expect(summarizeOutput(null)).toBeNull() + expect(summarizeOutput(undefined)).toBeNull() + }) +}) + +describe("isNotHandledOutput", () => { + it("is true for a status: not_handled envelope", () => { + expect(isNotHandledOutput({status: "not_handled"})).toBe(true) + }) + + it("is false for any other status", () => { + expect(isNotHandledOutput({status: "ok"})).toBe(false) + }) + + it("is false for a non-object output", () => { + expect(isNotHandledOutput("not_handled")).toBe(false) + expect(isNotHandledOutput(null)).toBe(false) + }) +}) + +describe("isSettled", () => { + it("is true for the three settled tool states", () => { + expect(isSettled("output-available")).toBe(true) + expect(isSettled("output-error")).toBe(true) + expect(isSettled("output-denied")).toBe(true) + }) + + it("is false for in-flight states", () => { + expect(isSettled("input-streaming")).toBe(false) + expect(isSettled("approval-requested")).toBe(false) + expect(isSettled("approval-responded")).toBe(false) + }) +}) + +describe("stripFence", () => { + it("strips a markdown code fence spanning the whole string", () => { + expect(stripFence('```json\n{"a":1}\n```')).toBe('{"a":1}') + }) + + it("leaves a string with no wrapping fence untouched", () => { + expect(stripFence("plain text")).toBe("plain text") + }) +}) + +describe("rowSummary", () => { + it("labels a not_handled output-available part", () => { + const part = {state: "output-available", output: {status: "not_handled"}} as ToolUIPart + expect(rowSummary(part)).toBe("not handled by this client") + }) + + it("prefers a registered display.summary over the generic shape heuristics", () => { + const display: ToolSummaryDisplay = { + summary: () => " custom result ", + } + const part = { + state: "output-available", + input: {q: "x"}, + output: {irrelevant: true}, + } as ToolUIPart + expect(rowSummary(part, display)).toBe("custom result") + }) + + it("falls back to shape heuristics when display.summary returns null", () => { + const display: ToolSummaryDisplay = {summary: () => null} + const part = {state: "output-available", output: [1, 2, 3]} as ToolUIPart + expect(rowSummary(part, display)).toBe("3 results") + }) + + it("labels a deferred (not-yet-executed) error", () => { + const part = { + state: "output-error", + errorText: "DEFERRED_NOT_EXECUTED: waiting on sibling", + } as ToolUIPart + expect(rowSummary(part)).toBe("waiting on another approval") + }) + + it("labels an approved-but-unknown-result error", () => { + const part = { + state: "output-error", + errorText: "APPROVED_EXECUTION_RESULT_UNKNOWN: no output recorded", + } as ToolUIPart + expect(rowSummary(part)).toBe("approved, result unknown") + }) + + it("labels a generic output-error as failed", () => { + const part = {state: "output-error", errorText: "boom"} as ToolUIPart + expect(rowSummary(part)).toBe("failed") + }) + + it("labels a denied state", () => { + const part = {state: "output-denied"} as ToolUIPart + expect(rowSummary(part)).toBe("denied") + }) + + it("returns null for a state that hasn't settled yet", () => { + const part = {state: "input-streaming"} as ToolUIPart + expect(rowSummary(part)).toBeNull() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts b/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts new file mode 100644 index 0000000000..db3add3ff2 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts @@ -0,0 +1,75 @@ +import type {UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {deriveTurnStatus} from "../../../src/model/turnStatus" +import reasoningOnlyTurnFixture from "../fixtures/reasoningOnlyTurn.json" + +describe("deriveTurnStatus", () => { + it("marks a reasoning-only settled turn as content-bearing but answer-less", () => { + const [message] = reasoningOnlyTurnFixture as UIMessage[] + const status = deriveTurnStatus(message, {isUser: false, isStreaming: false}) + expect(status.hasAnswer).toBe(false) + expect(status.hasReasoning).toBe(true) + expect(status.hasContent).toBe(true) + expect(status.noResponse).toBe(true) + }) + + it("trusts traceError on an answer-less turn", () => { + const message = {id: "a1", role: "assistant", parts: []} as unknown as UIMessage + const status = deriveTurnStatus(message, { + isUser: false, + isStreaming: false, + traceError: "model quota exceeded", + }) + expect(status.noResponse).toBe(true) + expect(status.errorText).toBe("model quota exceeded") + expect(status.showError).toBe(true) + expect(status.isError).toBe(true) + }) + + it("ignores traceError once the turn produced an answer", () => { + const message = { + id: "a1", + role: "assistant", + parts: [{type: "text", text: "here you go"}], + } as unknown as UIMessage + const status = deriveTurnStatus(message, { + isUser: false, + isStreaming: false, + traceError: "swallowed tool-level error", + }) + expect(status.noResponse).toBe(false) + expect(status.errorText).toBeNull() + expect(status.showError).toBe(false) + expect(status.isError).toBe(false) + }) + + it("always counts runError, even on a turn that produced an answer", () => { + const message = { + id: "a1", + role: "assistant", + parts: [{type: "text", text: "partial answer"}], + } as unknown as UIMessage + const status = deriveTurnStatus(message, { + isUser: false, + isStreaming: false, + runError: "stream died", + }) + expect(status.noResponse).toBe(false) + expect(status.errorText).toBe("stream died") + expect(status.showError).toBe(true) + // isError stays answer-less-only — a turn with an answer never renders as a full failure. + expect(status.isError).toBe(false) + }) + + it("suppresses showError while the turn is still streaming", () => { + const message = {id: "a1", role: "assistant", parts: []} as unknown as UIMessage + const status = deriveTurnStatus(message, { + isUser: false, + isStreaming: true, + runError: "stream died", + }) + expect(status.showError).toBe(false) + expect(status.isError).toBe(false) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/turnViewModel.test.ts b/web/packages/agenta-chat/tests/unit/model/turnViewModel.test.ts new file mode 100644 index 0000000000..63e25e8f4e --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/turnViewModel.test.ts @@ -0,0 +1,156 @@ +import type {ToolUIPart, UIMessage} from "ai" +import {describe, expect, it, vi} from "vitest" + +import { + buildTurnViewModels, + createExecutedToolIdentityCache, + toolPartsSignature, +} from "../../../src/model/turnViewModel" + +const user = (id: string, text: string): UIMessage => + ({id, role: "user", parts: [{type: "text", text}]}) as UIMessage + +const assistant = (id: string, parts: unknown[]): UIMessage => + ({id, role: "assistant", parts}) as unknown as UIMessage + +const toolPart = (toolCallId: string, state: string, input: unknown = {q: 1}) => ({ + type: "tool-search", + toolCallId, + state, + input, +}) + +describe("toolPartsSignature", () => { + it("keys on tool-call id + state and ignores streamed text", () => { + const parts = [ + {type: "text", text: "partial"}, + toolPart("c1", "output-available"), + toolPart("c2", "input-available"), + ] as UIMessage["parts"] + expect(toolPartsSignature(parts)).toBe("c1:output-available|c2:input-available") + // More text streaming in does not change the signature. + const more = [...parts, {type: "text", text: "more"}] as UIMessage["parts"] + expect(toolPartsSignature(more)).toBe(toolPartsSignature(parts)) + }) +}) + +describe("createExecutedToolIdentityCache", () => { + it("reuses the executed set while the signature is stable and recomputes on a state flip", () => { + const executedFor = createExecutedToolIdentityCache() + const m1 = assistant("a1", [toolPart("c1", "input-available")]) + const first = executedFor(m1) + expect(first.size).toBe(0) + // Same signature (text streamed, tools unchanged) → the SAME set instance comes back. + const m1b = assistant("a1", [toolPart("c1", "input-available"), {type: "text", text: "x"}]) + expect(executedFor(m1b)).toBe(first) + // The tool settles → new signature → recomputed set now holds the identity. + const m1c = assistant("a1", [toolPart("c1", "output-available")]) + const second = executedFor(m1c) + expect(second).not.toBe(first) + expect(second.size).toBe(1) + }) +}) + +describe("buildTurnViewModels", () => { + it("marks the active turn group and the streaming turn", () => { + const messages = [ + user("u1", "one"), + assistant("a1", [{type: "text", text: "first answer"}]), + user("u2", "two"), + assistant("a2", [{type: "text", text: "streami"}]), + ] + const turns = buildTurnViewModels(messages, { + busy: true, + executedFor: createExecutedToolIdentityCache(), + }) + expect(turns.map((t) => t.isActive)).toEqual([false, false, true, true]) + expect(turns.map((t) => t.isStreamingTurn)).toEqual([false, false, false, true]) + expect(turns[3].isLast).toBe(true) + expect(turns[3].status.hasAnswer).toBe(true) + }) + + it("collapses a run of empty no-response turns down to the first", () => { + const messages = [ + user("u1", "go"), + assistant("a1", []), + assistant("a2", []), + assistant("a3", []), + ] + const turns = buildTurnViewModels(messages, { + busy: false, + executedFor: createExecutedToolIdentityCache(), + }) + expect(turns[1].status.noResponse).toBe(true) + expect(turns[1].hidden).toBe(false) // the first empty turn still shows "no response" + expect(turns[2].hidden).toBe(true) + expect(turns[3].hidden).toBe(true) + }) + + it("drops a superseded approval gate once its executed sibling exists", () => { + const input = {cmd: "ls"} + const messages = [ + user("u1", "run it"), + assistant("a1", [ + {...toolPart("gate-1", "approval-responded", input)}, + {...toolPart("exec-1", "output-available", input)}, + ]), + ] + const turns = buildTurnViewModels(messages, { + busy: false, + executedFor: createExecutedToolIdentityCache(), + }) + const items = turns[1].items + expect(items).toHaveLength(1) + expect(items[0].kind).toBe("tools") + const group = items[0] as {kind: "tools"; parts: ToolUIPart[]} + expect(group.parts.map((p) => p.toolCallId)).toEqual(["exec-1"]) + }) + + it("splits client-tool parts out of the tool fold via the parameterized predicate", () => { + const messages = [ + user("u1", "connect"), + assistant("a1", [ + toolPart("c1", "output-available", {a: 1}), + { + type: "tool-request_connection", + toolCallId: "ct-1", + state: "input-available", + input: {}, + }, + toolPart("c2", "output-available", {b: 2}), + ]), + ] + const isClientToolPart = vi.fn( + (part: ToolUIPart) => (part.type as string) === "tool-request_connection", + ) + const turns = buildTurnViewModels(messages, { + busy: false, + executedFor: createExecutedToolIdentityCache(), + isClientToolPart, + }) + expect(turns[1].items.map((i) => i.kind)).toEqual(["tools", "clientTool", "tools"]) + // The predicate receives the desktop's context shape (streaming + last-message flags). + expect(isClientToolPart).toHaveBeenCalledWith( + expect.objectContaining({toolCallId: "ct-1"}), + {isStreaming: false, isLastMessage: true}, + ) + }) + + it("surfaces a stamped run error and borrows the paired trace for user turns", () => { + const failed = { + id: "a1", + role: "assistant", + parts: [], + metadata: {runError: {message: "boom"}, traceId: "tr-1"}, + } as unknown as UIMessage + const turns = buildTurnViewModels([user("u1", "go"), failed], { + busy: false, + executedFor: createExecutedToolIdentityCache(), + }) + expect(turns[1].status.errorText).toBe("boom") + expect(turns[1].status.isError).toBe(true) + expect(turns[1].traceId).toBe("tr-1") + // The user turn has no trace of its own; it borrows the next assistant turn's. + expect(turns[0].turnTraceId).toBe("tr-1") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/package.test.ts b/web/packages/agenta-chat/tests/unit/package.test.ts new file mode 100644 index 0000000000..cf27336863 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/package.test.ts @@ -0,0 +1,20 @@ +import {readFileSync} from "node:fs" +import {join} from "node:path" + +import {describe, expect, it} from "vitest" + +const FORBIDDEN = ["antd", "@ant-design/x", "@ant-design/icons", "react-virtuoso", "lexical"] + +describe("@agenta/chat package contract", () => { + const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")) + + it("is named @agenta/chat with a src entry", () => { + expect(pkg.name).toBe("@agenta/chat") + expect(pkg.main).toBe("./src/index.ts") + }) + + it("never depends on desktop UI toolkits", () => { + const all = {...pkg.dependencies, ...pkg.peerDependencies, ...pkg.devDependencies} + for (const dep of FORBIDDEN) expect(all[dep], dep).toBeUndefined() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/skin/registry.test.ts b/web/packages/agenta-chat/tests/unit/skin/registry.test.ts new file mode 100644 index 0000000000..75da7ece06 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/skin/registry.test.ts @@ -0,0 +1,156 @@ +import {describe, expect, it} from "vitest" + +import { + hasClientToolWidget, + registerChatSkin, + resolveApprovalBody, + resolveClientToolWidget, + resolveToolDisplay, +} from "../../../src/skin/registry" +import type {ClientToolWidget} from "../../../src/skin/types" + +// Registrations mutate a module-level store shared across this file's tests (documented merge +// semantics: later registration wins per key), so every test below uses its own unique key(s) to +// stay independent of test order. +const widget = (id: string): ClientToolWidget => { + const Widget = () => null + Widget.displayName = id + return Widget +} + +describe("clientTools registry", () => { + it("round-trips a render-kind registration", () => { + const w = widget("A") + registerChatSkin({clientTools: {byRenderKind: {rt_elicitation: w}}}) + expect(resolveClientToolWidget({toolName: "unrelated", renderKind: "rt_elicitation"})).toBe( + w, + ) + }) + + it("round-trips a tool-name registration", () => { + const w = widget("B") + registerChatSkin({clientTools: {byToolName: {rt_tool_name_1: w}}}) + expect(resolveClientToolWidget({toolName: "rt_tool_name_1"})).toBe(w) + }) + + it("prefers render.kind over toolName when both are registered (real OSS precedence)", () => { + const byKind = widget("kind") + const byName = widget("name") + registerChatSkin({ + clientTools: { + byRenderKind: {rt_precedence_kind: byKind}, + byToolName: {rt_precedence_name: byName}, + }, + }) + const resolved = resolveClientToolWidget({ + toolName: "rt_precedence_name", + renderKind: "rt_precedence_kind", + }) + expect(resolved).toBe(byKind) + }) + + it("falls back to toolName when renderKind is absent or unregistered", () => { + const w = widget("fallback") + registerChatSkin({clientTools: {byToolName: {rt_fallback_tool: w}}}) + expect( + resolveClientToolWidget({toolName: "rt_fallback_tool", renderKind: "rt_unregistered"}), + ).toBe(w) + expect(resolveClientToolWidget({toolName: "rt_fallback_tool"})).toBe(w) + }) + + it("resolves to undefined for a completely unregistered tool", () => { + expect(resolveClientToolWidget({toolName: "rt_never_registered"})).toBeUndefined() + }) + + it("hasClientToolWidget mirrors resolveClientToolWidget", () => { + const w = widget("has") + registerChatSkin({clientTools: {byToolName: {rt_has_tool: w}}}) + expect(hasClientToolWidget({toolName: "rt_has_tool"})).toBe(true) + expect(hasClientToolWidget({toolName: "rt_has_tool_missing"})).toBe(false) + }) + + it("a later registration wins over an earlier one for the same key", () => { + const first = widget("first") + const second = widget("second") + registerChatSkin({clientTools: {byToolName: {rt_wins: first}}}) + expect(resolveClientToolWidget({toolName: "rt_wins"})).toBe(first) + registerChatSkin({clientTools: {byToolName: {rt_wins: second}}}) + expect(resolveClientToolWidget({toolName: "rt_wins"})).toBe(second) + }) +}) + +describe("approvals registry", () => { + it("round-trips a registration and resolves it by tool name", () => { + const Body = () => null + registerChatSkin({approvals: {ap_commit: {Body, headline: null, approveLabel: "Approve"}}}) + const entry = resolveApprovalBody("ap_commit") + expect(entry?.Body).toBe(Body) + expect(entry?.headline).toBeNull() + expect(entry?.approveLabel).toBe("Approve") + }) + + it("resolves undefined for an unregistered tool name (generic card)", () => { + expect(resolveApprovalBody("ap_never_registered")).toBeUndefined() + }) + + it("a later registration wins over an earlier one for the same tool name", () => { + const First = () => null + const Second = () => null + registerChatSkin({approvals: {ap_wins: {Body: First}}}) + expect(resolveApprovalBody("ap_wins")?.Body).toBe(First) + registerChatSkin({approvals: {ap_wins: {Body: Second}}}) + expect(resolveApprovalBody("ap_wins")?.Body).toBe(Second) + }) +}) + +describe("toolDisplay registry — resolveToolDisplay fallback chain", () => { + it("pins the gateway `tools__provider__integration__ACTION__connection` prettification", () => { + const display = resolveToolDisplay("tools__composio__gmail__ADD_LABEL__b81") + expect(display).toEqual({ + raw: "tools__composio__gmail__ADD_LABEL__b81", + kind: "gateway", + label: "Add label", + source: "Gmail", + summary: undefined, + }) + }) + + it("pins the mcp__{server}__{tool} prettification", () => { + const display = resolveToolDisplay("mcp__linear__search_issues") + expect(display).toEqual({ + raw: "mcp__linear__search_issues", + kind: "mcp", + label: "Search issues", + source: "Linear · MCP", + summary: undefined, + }) + }) + + it("pins the plain-name title-case fallback with no source (platform kind)", () => { + const display = resolveToolDisplay("search") + expect(display).toEqual({ + raw: "search", + kind: "platform", + label: "Search", + source: undefined, + summary: undefined, + }) + }) + + it("merges a registered override with the parsed fallback, piece by piece", () => { + const summary = (input: unknown) => (typeof input === "string" ? input : null) + registerChatSkin({toolDisplay: {td_commit_like: {summary}}}) + const display = resolveToolDisplay("td_commit_like") + // label/source/kind still come from the parsed name shape — only summary was overridden. + expect(display.label).toBe("Td commit like") + expect(display.kind).toBe("platform") + expect(display.summary).toBe(summary) + }) + + it("a later registration wins over an earlier one for the same raw name", () => { + registerChatSkin({toolDisplay: {td_wins: {label: "First"}}}) + expect(resolveToolDisplay("td_wins").label).toBe("First") + registerChatSkin({toolDisplay: {td_wins: {label: "Second"}}}) + expect(resolveToolDisplay("td_wins").label).toBe("Second") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/state/expandState.test.ts b/web/packages/agenta-chat/tests/unit/state/expandState.test.ts new file mode 100644 index 0000000000..a0c003457e --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/state/expandState.test.ts @@ -0,0 +1,97 @@ +import type {UIMessage} from "ai" +import {createStore} from "jotai" +import {describe, expect, it} from "vitest" + +import { + errorKey, + expandedKeysForMessages, + expandedValueAtomFamily, + pruneExpandedAtom, + reasoningKey, + setExpandedAtom, + toolGroupKey, + toolRowKey, +} from "../../../src/state/expandState" + +describe("expandState key builders", () => { + it("builds a reasoning key from the message id and part index", () => { + expect(reasoningKey("m1", 2)).toBe("m1::reason::2") + }) + + it("builds an error key from the message id", () => { + expect(errorKey("m1")).toBe("m1::error") + }) + + it("builds a tool row key from the tool call id", () => { + expect(toolRowKey("tool-1")).toBe("tool::row::tool-1") + }) + + it("builds a tool group key from the tool call id", () => { + expect(toolGroupKey("tool-1")).toBe("tool::group::tool-1") + }) +}) + +describe("expandedKeysForMessages", () => { + it("emits an error key for every message plus reasoning/tool keys for their parts", () => { + const messages = [ + { + id: "m1", + role: "assistant", + parts: [ + {type: "reasoning", text: "thinking"}, + {type: "tool-bash", toolCallId: "tool-1", state: "output-available"}, + {type: "dynamic-tool", toolCallId: "tool-2", state: "output-available"}, + {type: "text", text: "hi"}, + ], + }, + ] as unknown as UIMessage[] + + const keys = expandedKeysForMessages(messages) + expect(keys).toEqual( + new Set([ + errorKey("m1"), + reasoningKey("m1", 0), + toolRowKey("tool-1"), + toolGroupKey("tool-1"), + toolRowKey("tool-2"), + toolGroupKey("tool-2"), + ]), + ) + }) + + it("skips a tool part with no toolCallId", () => { + const messages = [ + {id: "m1", role: "assistant", parts: [{type: "tool-bash", state: "input-available"}]}, + ] as unknown as UIMessage[] + expect(expandedKeysForMessages(messages)).toEqual(new Set([errorKey("m1")])) + }) +}) + +describe("setExpandedAtom / pruneExpandedAtom", () => { + it("sets and reads a widget's expanded state through the scoped selector", () => { + const store = createStore() + store.set(setExpandedAtom, {key: reasoningKey("m1", 0), value: true}) + expect(store.get(expandedValueAtomFamily(reasoningKey("m1", 0)))).toBe(true) + }) + + it("prunes map entries whose key isn't in the live set", () => { + const store = createStore() + store.set(setExpandedAtom, {key: errorKey("m1"), value: true}) + store.set(setExpandedAtom, {key: errorKey("m2"), value: true}) + + store.set(pruneExpandedAtom, new Set([errorKey("m1")])) + + expect(store.get(expandedValueAtomFamily(errorKey("m1")))).toBe(true) + expect(store.get(expandedValueAtomFamily(errorKey("m2")))).toBeUndefined() + }) + + it("removes the pruned key's cached selector atom from the family", () => { + const store = createStore() + store.set(setExpandedAtom, {key: errorKey("m1"), value: true}) + expect(expandedValueAtomFamily.getParams()).toContain(errorKey("m1")) + + store.set(pruneExpandedAtom, new Set()) + + expect(expandedValueAtomFamily.getParams()).not.toContain(errorKey("m1")) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/state/sessionEphemera.test.ts b/web/packages/agenta-chat/tests/unit/state/sessionEphemera.test.ts new file mode 100644 index 0000000000..2cffe9b85f --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/state/sessionEphemera.test.ts @@ -0,0 +1,74 @@ +import {beforeEach, describe, expect, it} from "vitest" + +import type {PendingAttachment} from "../../../src/model/attachments" +import { + attachmentsBySession, + clearSessionEphemera, + clearSessionFresh, + composerDraftBySession, + freshSessionIds, + isSessionFresh, + markSessionFresh, +} from "../../../src/state/sessionEphemera" + +const attachment = (uid: string): PendingAttachment => ({ + file: new File(["x"], `${uid}.txt`), + uid, + name: `${uid}.txt`, +}) + +beforeEach(() => { + composerDraftBySession.clear() + attachmentsBySession.clear() + freshSessionIds.clear() +}) + +describe("composerDraftBySession", () => { + it("holds one in-progress draft per session", () => { + composerDraftBySession.set("s1", "hello") + expect(composerDraftBySession.get("s1")).toBe("hello") + expect(composerDraftBySession.get("s2")).toBeUndefined() + }) +}) + +describe("attachmentsBySession", () => { + it("holds pending attachments typed as PendingAttachment[], not antd UploadFile", () => { + const pending = [attachment("a1")] + attachmentsBySession.set("s1", pending) + expect(attachmentsBySession.get("s1")).toBe(pending) + }) +}) + +describe("fresh-session marker", () => { + it("marks, reads, and clears a session's fresh state", () => { + expect(isSessionFresh("s1")).toBe(false) + markSessionFresh("s1") + expect(isSessionFresh("s1")).toBe(true) + clearSessionFresh("s1") + expect(isSessionFresh("s1")).toBe(false) + }) +}) + +describe("clearSessionEphemera", () => { + it("clears the draft, attachments, and fresh marker for one session", () => { + composerDraftBySession.set("s1", "draft") + attachmentsBySession.set("s1", [attachment("a1")]) + markSessionFresh("s1") + + clearSessionEphemera("s1") + + expect(composerDraftBySession.has("s1")).toBe(false) + expect(attachmentsBySession.has("s1")).toBe(false) + expect(freshSessionIds.has("s1")).toBe(false) + }) + + it("leaves other sessions' ephemera untouched", () => { + composerDraftBySession.set("s1", "draft-1") + composerDraftBySession.set("s2", "draft-2") + + clearSessionEphemera("s1") + + expect(composerDraftBySession.has("s1")).toBe(false) + expect(composerDraftBySession.get("s2")).toBe("draft-2") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts b/web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts new file mode 100644 index 0000000000..9246248140 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import type {UIMessage} from "ai" +import {createStore} from "jotai" +import {describe, expect, it} from "vitest" + +import { + isSessionStreamingAtomFamily, + persistSessionMessagesAtom, + sessionMessagesAtom, + sessionStatusAtomFamily, + setSessionStatusAtom, +} from "../../../src/state/sessionMessages" + +const msg = (id: string, text: string): UIMessage => + ({id, role: "user", parts: [{type: "text", text}]}) as UIMessage + +describe("sessionMessages state", () => { + it("persists a session's messages under its id", () => { + const store = createStore() + store.set(persistSessionMessagesAtom, {id: "s1", messages: [msg("m1", "hello")]}) + expect(store.get(sessionMessagesAtom)["s1"]).toHaveLength(1) + // A later settle replaces that session's slice without touching others. + store.set(persistSessionMessagesAtom, {id: "s2", messages: [msg("m2", "other")]}) + store.set(persistSessionMessagesAtom, { + id: "s1", + messages: [msg("m1", "hello"), msg("m3", "again")], + }) + expect(store.get(sessionMessagesAtom)["s1"]).toHaveLength(2) + expect(store.get(sessionMessagesAtom)["s2"]).toHaveLength(1) + }) + + it("run status defaults to idle, stores non-idle, and clears on idle", () => { + const store = createStore() + expect(store.get(sessionStatusAtomFamily("sx"))).toBe("idle") + store.set(setSessionStatusAtom, {id: "sx", status: "running"}) + expect(store.get(sessionStatusAtomFamily("sx"))).toBe("running") + expect(store.get(isSessionStreamingAtomFamily("sx"))).toBe(true) + store.set(setSessionStatusAtom, {id: "sx", status: "awaiting"}) + expect(store.get(sessionStatusAtomFamily("sx"))).toBe("awaiting") + expect(store.get(isSessionStreamingAtomFamily("sx"))).toBe(false) + // Idle is stored as absence (clear-on-unmount semantics). + store.set(setSessionStatusAtom, {id: "sx", status: "idle"}) + expect(store.get(sessionStatusAtomFamily("sx"))).toBe("idle") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts b/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts new file mode 100644 index 0000000000..8c175f21af --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts @@ -0,0 +1,130 @@ +import type {UIMessage, UIMessageChunk} from "ai" +import {describe, expect, it, vi} from "vitest" + +import {AgentChatTransport} from "../../../src/transport/AgentChatTransport" + +const readAll = async (stream: ReadableStream): Promise => { + const reader = stream.getReader() + const chunks: UIMessageChunk[] = [] + for (;;) { + const {done, value} = await reader.read() + if (done) break + chunks.push(value) + } + return chunks +} + +const userMessage = (text: string): UIMessage => + ({id: "m1", role: "user", parts: [{type: "text", text}]}) as unknown as UIMessage + +describe("AgentChatTransport", () => { + it("constructs and owns its own fetch (a caller-supplied fetch becomes the negotiator's base)", () => { + const baseFetch = vi.fn() + const transport = new AgentChatTransport({api: "/api/agent/invoke", fetch: baseFetch}) + expect(transport).toBeInstanceOf(AgentChatTransport) + }) + + it("parses a batch JSON response (the negotiator's 406 fallback shape) into a UIMessage chunk stream", async () => { + const batchBody = JSON.stringify({ + session_id: "session-1", + data: { + outputs: { + messages: [{role: "assistant", content: "hello from batch"}], + }, + }, + }) + // The negotiator requests SSE first (Accept: text/event-stream); answering 406 there + // triggers its batch re-request with Accept: application/json — that's the request this + // stub honours, matching what a handler that can't stream actually does. + const baseFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const accept = new Headers(init?.headers).get("accept") ?? "" + if (accept.includes("text/event-stream")) { + return new Response(null, {status: 406}) + } + return new Response(batchBody, { + status: 200, + headers: {"content-type": "application/json"}, + }) + }) + + const transport = new AgentChatTransport({ + api: "/api/agent/invoke", + // Mirrors the desktop caller (AgentConversation.tsx): the request builder's headers + // carry the Accept the negotiator branches on — this transport itself sets none. + headers: {Accept: "text/event-stream"}, + fetch: baseFetch as unknown as typeof fetch, + }) + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [userMessage("hi")], + }) + + const chunks = await readAll(stream) + expect(chunks[0]).toMatchObject({type: "start"}) + expect(chunks.some((c) => c.type === "text-delta" && c.delta === "hello from batch")).toBe( + true, + ) + expect(chunks[chunks.length - 1]).toMatchObject({type: "finish"}) + // Two requests: the stream attempt (406) then the batch fallback. + expect(baseFetch).toHaveBeenCalledTimes(2) + }) + + // A batch turn carries the call and its result as two blocks sharing one tool_use_id. The + // AI SDK keys tool parts by that id, so a second input chunk would overwrite the named call. + it("does not replay an input chunk for the nameless tool_result half of a batch tool turn", async () => { + const batchBody = JSON.stringify({ + session_id: "session-1", + data: { + outputs: { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call-1", + name: "bash", + input: {command: "echo hi"}, + }, + {type: "tool_result", tool_use_id: "call-1", content: "hi"}, + ], + }, + ], + }, + }, + }) + const baseFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const accept = new Headers(init?.headers).get("accept") ?? "" + if (accept.includes("text/event-stream")) return new Response(null, {status: 406}) + return new Response(batchBody, { + status: 200, + headers: {"content-type": "application/json"}, + }) + }) + + const transport = new AgentChatTransport({ + api: "/api/agent/invoke", + headers: {Accept: "text/event-stream"}, + fetch: baseFetch as unknown as typeof fetch, + }) + const chunks = await readAll( + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [userMessage("run it")], + }), + ) + + const inputs = chunks.filter((c) => c.type === "tool-input-available") + expect(inputs).toHaveLength(1) + expect(inputs[0]).toMatchObject({toolCallId: "call-1", toolName: "bash"}) + // The result still arrives, under the same id, so the call renders as completed. + expect(chunks.filter((c) => c.type === "tool-output-available")).toMatchObject([ + {toolCallId: "call-1", output: "hi"}, + ]) + }) +}) diff --git a/web/packages/agenta-chat/tsconfig.json b/web/packages/agenta-chat/tsconfig.json new file mode 100644 index 0000000000..bff6d81817 --- /dev/null +++ b/web/packages/agenta-chat/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "tsBuildInfoFile": ".tsbuildinfo", + "moduleResolution": "bundler" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "../css-modules.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/web/packages/agenta-chat/vitest.config.ts b/web/packages/agenta-chat/vitest.config.ts new file mode 100644 index 0000000000..a9a2cfed1d --- /dev/null +++ b/web/packages/agenta-chat/vitest.config.ts @@ -0,0 +1,19 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + include: ["tests/unit/**/*.test.ts"], + environment: "node", + reporters: ["default", "junit"], + outputFile: { + junit: "./test-results/junit.xml", + }, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/index.ts"], + reporter: ["text", "lcov", "json-summary"], + reportsDirectory: "./coverage", + }, + }, +}) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 569be38ea3..c3aed5e005 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@agenta/annotation-ui': specifier: workspace:../packages/agenta-annotation-ui version: link:../packages/agenta-annotation-ui + '@agenta/chat': + specifier: workspace:../packages/agenta-chat + version: link:../packages/agenta-chat '@agenta/entities': specifier: workspace:../packages/agenta-entities version: link:../packages/agenta-entities @@ -436,6 +439,9 @@ importers: '@agenta/annotation-ui': specifier: workspace:../packages/agenta-annotation-ui version: link:../packages/agenta-annotation-ui + '@agenta/chat': + specifier: workspace:../packages/agenta-chat + version: link:../packages/agenta-chat '@agenta/entities': specifier: workspace:../packages/agenta-entities version: link:../packages/agenta-entities @@ -905,6 +911,55 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/agenta-chat: + dependencies: + '@agenta/entities': + specifier: workspace:../agenta-entities + version: link:../agenta-entities + '@agenta/playground': + specifier: workspace:../agenta-playground + version: link:../agenta-playground + '@agenta/shared': + specifier: workspace:../agenta-shared + version: link:../agenta-shared + devDependencies: + '@ai-sdk/react': + specifier: 3.0.0-beta.153 + version: 3.0.0-beta.153(react@19.2.6)(zod@4.4.3) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/node': + specifier: ^20.19.20 + version: 20.19.39 + '@types/react': + specifier: ^19.0.10 + version: 19.2.14 + '@vitest/coverage-v8': + specifier: ^4.1.4 + version: 4.1.6(vitest@4.1.6) + ai: + specifier: 6.0.0-beta.150 + version: 6.0.0-beta.150(zod@4.4.3) + jotai: + specifier: ^2.15.0 + version: 2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.2.6 + react-dom: + specifier: ^19.0.0 + version: 19.2.6(react@19.2.6) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.4 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.6)(jsdom@26.1.0)(vite@8.1.5(@types/node@20.19.39)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.0)(tsx@4.22.4)(yaml@2.8.4)) + packages/agenta-entities: dependencies: '@agenta/sdk': @@ -5325,6 +5380,21 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} engines: {node: '>=12', npm: '>=6'} @@ -14504,6 +14574,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 diff --git a/web/turbo.json b/web/turbo.json index 2f120ba490..5e9968204c 100644 --- a/web/turbo.json +++ b/web/turbo.json @@ -79,6 +79,11 @@ "inputs": ["src/**", "tsconfig.json", "../tsconfig.base.json", "../css-modules.d.ts"], "outputs": [".tsbuildinfo"] }, + "@agenta/chat#build": { + "dependsOn": ["@agenta/shared#build", "@agenta/entities#build", "@agenta/playground#build"], + "inputs": ["src/**", "tsconfig.json", "../tsconfig.base.json", "../css-modules.d.ts"], + "outputs": [".tsbuildinfo"] + }, "@agenta/oss#build": { "dependsOn": [ "@agenta/shared#build", @@ -86,7 +91,8 @@ "@agenta/entities#build", "@agenta/entity-ui#build", "@agenta/playground#build", - "@agenta/playground-ui#build" + "@agenta/playground-ui#build", + "@agenta/chat#build" ], "inputs": [ "src/**", @@ -107,7 +113,8 @@ "@agenta/entities#build", "@agenta/entity-ui#build", "@agenta/playground#build", - "@agenta/playground-ui#build" + "@agenta/playground-ui#build", + "@agenta/chat#build" ], "inputs": [ "src/**", @@ -157,6 +164,10 @@ "inputs": ["src/**/*.ts", "src/**/*.tsx"], "outputs": [] }, + "@agenta/chat#lint": { + "inputs": ["src/**/*.ts", "src/**/*.tsx"], + "outputs": [] + }, "@agenta/oss#lint": { "dependsOn": [ "@agenta/shared#lint", @@ -164,7 +175,8 @@ "@agenta/entities#lint", "@agenta/entity-ui#lint", "@agenta/playground#lint", - "@agenta/playground-ui#lint" + "@agenta/playground-ui#lint", + "@agenta/chat#lint" ], "inputs": [ "src/**/*.ts", @@ -218,6 +230,15 @@ "inputs": ["src/**", "tsconfig.json", "../tsconfig.base.json", "../css-modules.d.ts"], "outputs": [] }, + "@agenta/chat#types:check": { + "dependsOn": [ + "@agenta/shared#types:check", + "@agenta/entities#types:check", + "@agenta/playground#types:check" + ], + "inputs": ["src/**", "tsconfig.json", "../tsconfig.base.json", "../css-modules.d.ts"], + "outputs": [] + }, "@agenta/oss#types:check": { "dependsOn": [ "@agenta/shared#types:check", @@ -225,7 +246,8 @@ "@agenta/entities#types:check", "@agenta/entity-ui#types:check", "@agenta/playground#types:check", - "@agenta/playground-ui#types:check" + "@agenta/playground-ui#types:check", + "@agenta/chat#types:check" ], "inputs": [ "src/**",