Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ff8acd5
feat(chat): scaffold @agenta/chat headless package
ardaerzin Jul 25, 2026
4ebba74
feat(chat): add neutral attachment and action types
ardaerzin Jul 25, 2026
5e5d1eb
feat(chat): copy part predicates and tool identity from the desktop chat
ardaerzin Jul 25, 2026
0df8829
feat(chat): copy parseAgentRunError from the desktop chat
ardaerzin Jul 25, 2026
9b404c2
feat(chat): copy tool output summarization from the desktop chat
ardaerzin Jul 25, 2026
d85ddc7
feat(chat): copy approval extraction from the desktop chat
ardaerzin Jul 25, 2026
561b832
feat(chat): copy turn status derivation from the desktop chat
ardaerzin Jul 25, 2026
eb96d0e
feat(chat): copy the turn render model from the desktop chat
ardaerzin Jul 25, 2026
79e7891
feat(chat): copy turn grouping and session status derivation from the…
ardaerzin Jul 25, 2026
9de378c
feat(chat): copy the pure chat asset helpers from the desktop chat
ardaerzin Jul 25, 2026
69f66cc
feat(chat): copy the transcript replay adapter and transport from the…
ardaerzin Jul 25, 2026
a0be078
feat(chat): copy the chat session state stores from the desktop chat
ardaerzin Jul 25, 2026
8f9924a
feat(chat): copy the chat queue and model-key hooks from the desktop …
ardaerzin Jul 25, 2026
7805ae7
feat(chat): assemble the composer attachment and approval hooks
ardaerzin Jul 25, 2026
3d1a06d
feat(chat): assemble the headless conversation host hook
ardaerzin Jul 25, 2026
cc5aefe
feat(chat): add the chat skin registration and resolvers
ardaerzin Jul 25, 2026
6fcb2e5
fix(chat): review polish on attachment hook and skin registry
ardaerzin Jul 25, 2026
e9fd19d
fix(chat): keep a replayed tool call named, and stop three stale reads
ardaerzin Aug 3, 2026
6fd452e
fix(chat): re-seed staged attachments when the session id changes
ardaerzin Aug 3, 2026
1a74817
fix(chat): re-sync the replay adapter with the OSS original
ardaerzin Aug 5, 2026
c9e8ad0
fix(chat): adopt a revalidated transcript on the record count, not th…
ardaerzin Aug 5, 2026
410b5f7
fix(chat): send what encoded when one attachment cannot be read
ardaerzin Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/ee/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions web/oss/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const COMMON_CONFIG: NextConfig = {
"@agentaai/api-client",
"@agenta/shared",
"@agenta/ui",
"@agenta/chat",
"@agenta/entities",
"@agenta/entity-ui",
"@agenta/playground",
Expand Down
1 change: 1 addition & 0 deletions web/oss/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions web/packages/agenta-chat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Generated by Vitest — do not commit
test-results/
coverage/
52 changes: 52 additions & 0 deletions web/packages/agenta-chat/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
102 changes: 102 additions & 0 deletions web/packages/agenta-chat/src/assets/attachmentRules.ts
Original file line number Diff line number Diff line change
@@ -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}
}
82 changes: 82 additions & 0 deletions web/packages/agenta-chat/src/assets/files.ts
Original file line number Diff line number Diff line change
@@ -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<FileUIPart> =>
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:<mediaType>;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<FilesToPartsResult> => {
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:<type>;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"
}
7 changes: 7 additions & 0 deletions web/packages/agenta-chat/src/assets/index.ts
Original file line number Diff line number Diff line change
@@ -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"
74 changes: 74 additions & 0 deletions web/packages/agenta-chat/src/assets/loadSession.ts
Original file line number Diff line number Diff line change
@@ -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<SessionTranscript | null> => {
// 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)
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
}
}
37 changes: 37 additions & 0 deletions web/packages/agenta-chat/src/assets/rewind.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading