-
Notifications
You must be signed in to change notification settings - Fork 607
[feat] Extract the headless chat core into @agenta/chat (3/12) #5682
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ardaerzin
wants to merge
22
commits into
feat/api-sessions-list-ordering
Choose a base branch
from
feat/agenta-chat-package
base: feat/api-sessions-list-ordering
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 4ebba74
feat(chat): add neutral attachment and action types
ardaerzin 5e5d1eb
feat(chat): copy part predicates and tool identity from the desktop chat
ardaerzin 0df8829
feat(chat): copy parseAgentRunError from the desktop chat
ardaerzin 9b404c2
feat(chat): copy tool output summarization from the desktop chat
ardaerzin d85ddc7
feat(chat): copy approval extraction from the desktop chat
ardaerzin 561b832
feat(chat): copy turn status derivation from the desktop chat
ardaerzin eb96d0e
feat(chat): copy the turn render model from the desktop chat
ardaerzin 79e7891
feat(chat): copy turn grouping and session status derivation from the…
ardaerzin 9de378c
feat(chat): copy the pure chat asset helpers from the desktop chat
ardaerzin 69f66cc
feat(chat): copy the transcript replay adapter and transport from the…
ardaerzin a0be078
feat(chat): copy the chat session state stores from the desktop chat
ardaerzin 8f9924a
feat(chat): copy the chat queue and model-key hooks from the desktop …
ardaerzin 7805ae7
feat(chat): assemble the composer attachment and approval hooks
ardaerzin 3d1a06d
feat(chat): assemble the headless conversation host hook
ardaerzin cc5aefe
feat(chat): add the chat skin registration and resolvers
ardaerzin 6fcb2e5
fix(chat): review polish on attachment hook and skin registry
ardaerzin e9fd19d
fix(chat): keep a replayed tool call named, and stop three stale reads
ardaerzin 6fd452e
fix(chat): re-seed staged attachments when the session id changes
ardaerzin 1a74817
fix(chat): re-sync the replay adapter with the OSS original
ardaerzin c9e8ad0
fix(chat): adopt a revalidated transcript on the record count, not th…
ardaerzin 410b5f7
fix(chat): send what encoded when one attachment cannot be read
ardaerzin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # Generated by Vitest — do not commit | ||
| test-results/ | ||
| coverage/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| } | ||
| 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.