From ff8acd5427f45a3c40ce556a7de0c793b510f1d3 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 05:08:48 +0200 Subject: [PATCH 01/22] feat(chat): scaffold @agenta/chat headless package Adds the empty @agenta/chat package (model placeholder only) mirroring agenta-playground's script/tsconfig/vitest shape, and wires it into the oss/ee apps (dependency + transpilePackages) and turbo.json build graph. --- web/ee/package.json | 1 + web/oss/next.config.ts | 1 + web/oss/package.json | 1 + web/packages/agenta-chat/package.json | 42 +++++++++++++++++ web/packages/agenta-chat/src/index.ts | 1 + web/packages/agenta-chat/src/model/index.ts | 1 + .../agenta-chat/tests/unit/package.test.ts | 20 ++++++++ web/packages/agenta-chat/tsconfig.json | 11 +++++ web/packages/agenta-chat/vitest.config.ts | 19 ++++++++ web/pnpm-lock.yaml | 46 +++++++++++++++++++ web/turbo.json | 30 ++++++++++-- 11 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 web/packages/agenta-chat/package.json create mode 100644 web/packages/agenta-chat/src/index.ts create mode 100644 web/packages/agenta-chat/src/model/index.ts create mode 100644 web/packages/agenta-chat/tests/unit/package.test.ts create mode 100644 web/packages/agenta-chat/tsconfig.json create mode 100644 web/packages/agenta-chat/vitest.config.ts diff --git a/web/ee/package.json b/web/ee/package.json index f816bbeb457..80dfa31ea9b 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 6da0db89ead..b29b20379f9 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 1b9e0fd9182..7ff9eec2068 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/package.json b/web/packages/agenta-chat/package.json new file mode 100644 index 00000000000..eb1acf3a723 --- /dev/null +++ b/web/packages/agenta-chat/package.json @@ -0,0 +1,42 @@ +{ + "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" + }, + "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", + "@types/node": "^20.19.20", + "@types/react": "^19.0.10", + "@vitest/coverage-v8": "^4.1.4", + "ai": "6.0.0-beta.150", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + } +} diff --git a/web/packages/agenta-chat/src/index.ts b/web/packages/agenta-chat/src/index.ts new file mode 100644 index 00000000000..c1f8e9c2ca8 --- /dev/null +++ b/web/packages/agenta-chat/src/index.ts @@ -0,0 +1 @@ +export * from "./model" 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 00000000000..f733f2a3a46 --- /dev/null +++ b/web/packages/agenta-chat/src/model/index.ts @@ -0,0 +1 @@ +export const CHAT_PACKAGE = "@agenta/chat" 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 00000000000..cf273368636 --- /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/tsconfig.json b/web/packages/agenta-chat/tsconfig.json new file mode 100644 index 00000000000..bff6d818178 --- /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 00000000000..a9a2cfed1d8 --- /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 569be38ea3b..2d84b8b546e 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,46 @@ 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 + jotai: + specifier: '>=2.0.0' + version: 2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6) + react: + specifier: '>=18.0.0' + version: 19.2.6 + devDependencies: + '@ai-sdk/react': + specifier: 3.0.0-beta.153 + version: 3.0.0-beta.153(react@19.2.6)(zod@4.4.3) + '@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) + 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)(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': diff --git a/web/turbo.json b/web/turbo.json index 2f120ba490b..5e9968204c5 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/**", From 4ebba74fc64694c7990310a209d267af85e38d98 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 18:56:11 +0200 Subject: [PATCH 02/22] feat(chat): add neutral attachment and action types --- web/packages/agenta-chat/src/model/actions.ts | 10 ++++++++++ .../agenta-chat/src/model/attachments.ts | 17 +++++++++++++++++ web/packages/agenta-chat/src/model/index.ts | 3 ++- .../tests/unit/model/attachments.test.ts | 14 ++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 web/packages/agenta-chat/src/model/actions.ts create mode 100644 web/packages/agenta-chat/src/model/attachments.ts create mode 100644 web/packages/agenta-chat/tests/unit/model/attachments.test.ts 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 00000000000..c70fedc22dc --- /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/attachments.ts b/web/packages/agenta-chat/src/model/attachments.ts new file mode 100644 index 00000000000..14dcd26fab2 --- /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/index.ts b/web/packages/agenta-chat/src/model/index.ts index f733f2a3a46..cbbc80d2ba1 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -1 +1,2 @@ -export const CHAT_PACKAGE = "@agenta/chat" +export * from "./attachments" +export * from "./actions" 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 00000000000..1e3fc38080b --- /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", + }) + }) +}) From 5e5d1eb5bd9e00b74d7c981176cfc29c07583945 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 19:00:23 +0200 Subject: [PATCH 03/22] feat(chat): copy part predicates and tool identity from the desktop chat --- web/packages/agenta-chat/src/model/index.ts | 1 + web/packages/agenta-chat/src/model/parts.ts | 56 +++++++++++++ .../tests/unit/fixtures/emptyTurns.json | 6 ++ .../tests/unit/model/parts.test.ts | 79 +++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 web/packages/agenta-chat/src/model/parts.ts create mode 100644 web/packages/agenta-chat/tests/unit/fixtures/emptyTurns.json create mode 100644 web/packages/agenta-chat/tests/unit/model/parts.test.ts diff --git a/web/packages/agenta-chat/src/model/index.ts b/web/packages/agenta-chat/src/model/index.ts index cbbc80d2ba1..ec4cad9700a 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -1,2 +1,3 @@ export * from "./attachments" export * from "./actions" +export * from "./parts" 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 00000000000..60b40443e18 --- /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/tests/unit/fixtures/emptyTurns.json b/web/packages/agenta-chat/tests/unit/fixtures/emptyTurns.json new file mode 100644 index 00000000000..d3e93084c4e --- /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/model/parts.test.ts b/web/packages/agenta-chat/tests/unit/model/parts.test.ts new file mode 100644 index 00000000000..026dcf52187 --- /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") + }) +}) From 0df882978fb903d3aae9e24a8ba0875e8a077790 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 19:09:40 +0200 Subject: [PATCH 04/22] feat(chat): copy parseAgentRunError from the desktop chat --- web/packages/agenta-chat/src/model/error.ts | 40 +++++++++++++++++++ web/packages/agenta-chat/src/model/index.ts | 1 + .../tests/unit/model/error.test.ts | 32 +++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 web/packages/agenta-chat/src/model/error.ts create mode 100644 web/packages/agenta-chat/tests/unit/model/error.test.ts 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 00000000000..ef60bbc629c --- /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/index.ts b/web/packages/agenta-chat/src/model/index.ts index ec4cad9700a..7214c33cd28 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -1,3 +1,4 @@ export * from "./attachments" export * from "./actions" export * from "./parts" +export * from "./error" 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 00000000000..b7cbaad89f3 --- /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."}) + }) +}) From 9b404c2cd0dcdc3206b2b8a38f29137dc29a1409 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 19:25:48 +0200 Subject: [PATCH 05/22] feat(chat): copy tool output summarization from the desktop chat Copy SETTLED/isSettled, isDeferredError/isUnknownResultError, isNotHandledOutput, summarizeOutput, rowSummary, and stripFence verbatim from ToolActivity.tsx / toolFormat.ts so the headless chat package can render tool rows without pulling in the OSS ToolDisplay registry. --- web/packages/agenta-chat/src/model/index.ts | 1 + .../agenta-chat/src/model/toolSummary.ts | 108 +++++++++++++ .../tests/unit/model/toolSummary.test.ts | 143 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 web/packages/agenta-chat/src/model/toolSummary.ts create mode 100644 web/packages/agenta-chat/tests/unit/model/toolSummary.test.ts diff --git a/web/packages/agenta-chat/src/model/index.ts b/web/packages/agenta-chat/src/model/index.ts index 7214c33cd28..3ca3df0974f 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -2,3 +2,4 @@ export * from "./attachments" export * from "./actions" export * from "./parts" export * from "./error" +export * from "./toolSummary" 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 00000000000..594d4b4b923 --- /dev/null +++ b/web/packages/agenta-chat/src/model/toolSummary.ts @@ -0,0 +1,108 @@ +import type {ToolUIPart} from "ai" + +/** 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 +} + +// 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. +/** 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 +} + +// 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/assets/transcriptToMessages.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. +// Mirrors services/runner/src/tracing/otel.ts; park sentinels report skipped or unobserved work, not final results. +const DEFERRED_NOT_EXECUTED_PREFIX = "DEFERRED_NOT_EXECUTED" +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." +const APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX = APPROVED_EXECUTION_RESULT_UNKNOWN.slice( + 0, + APPROVED_EXECUTION_RESULT_UNKNOWN.indexOf(":"), +) + +// 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/tests/unit/model/toolSummary.test.ts b/web/packages/agenta-chat/tests/unit/model/toolSummary.test.ts new file mode 100644 index 00000000000..bf5827da441 --- /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() + }) +}) From d85ddc79ad6b2615f51334e06bfcdd6c58bb728d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 19:26:52 +0200 Subject: [PATCH 06/22] feat(chat): copy approval extraction from the desktop chat Copy PendingApproval/ApprovalRef/getPendingApprovals verbatim from ApprovalDock.tsx, reusing the package's own isToolPart/partToolName instead of a local duplicate. --- .../agenta-chat/src/model/approvals.ts | 41 +++++++++++++++++++ web/packages/agenta-chat/src/model/index.ts | 1 + .../tests/unit/fixtures/approvalTurn.json | 31 ++++++++++++++ .../tests/unit/model/approvals.test.ts | 30 ++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 web/packages/agenta-chat/src/model/approvals.ts create mode 100644 web/packages/agenta-chat/tests/unit/fixtures/approvalTurn.json create mode 100644 web/packages/agenta-chat/tests/unit/model/approvals.test.ts 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 00000000000..28de88a8d5d --- /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/index.ts b/web/packages/agenta-chat/src/model/index.ts index 3ca3df0974f..15406ec331f 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -3,3 +3,4 @@ export * from "./actions" export * from "./parts" export * from "./error" export * from "./toolSummary" +export * from "./approvals" 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 00000000000..4c58feb65cb --- /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/model/approvals.test.ts b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts new file mode 100644 index 00000000000..48d1f6650ff --- /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([]) + }) +}) From 561b832e3496351f7f91bba17cecef3fec6abc4b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 19:34:45 +0200 Subject: [PATCH 07/22] feat(chat): copy turn status derivation from the desktop chat Copy the seven status derivations (hasAnswer/hasReasoning/hasContent/noResponse/ errorText/showError/isError) verbatim from AgentMessage.tsx as a pure deriveTurnStatus(message, ctx) function, including the traceError-vs-runError gating rule (trace errors only trusted on answer-less turns; run errors always count). --- web/packages/agenta-chat/src/model/index.ts | 1 + .../agenta-chat/src/model/turnStatus.ts | 69 +++++++++++++++++ .../unit/fixtures/reasoningOnlyTurn.json | 7 ++ .../tests/unit/model/turnStatus.test.ts | 75 +++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 web/packages/agenta-chat/src/model/turnStatus.ts create mode 100644 web/packages/agenta-chat/tests/unit/fixtures/reasoningOnlyTurn.json create mode 100644 web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts diff --git a/web/packages/agenta-chat/src/model/index.ts b/web/packages/agenta-chat/src/model/index.ts index 15406ec331f..9671e411de4 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -4,3 +4,4 @@ export * from "./parts" export * from "./error" export * from "./toolSummary" export * from "./approvals" +export * from "./turnStatus" 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 00000000000..9de69fe67d8 --- /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/tests/unit/fixtures/reasoningOnlyTurn.json b/web/packages/agenta-chat/tests/unit/fixtures/reasoningOnlyTurn.json new file mode 100644 index 00000000000..a1ef7f9183d --- /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/model/turnStatus.test.ts b/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts new file mode 100644 index 00000000000..db3add3ff29 --- /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) + }) +}) From eb96d0e97818b9963ded3960c534f492cac9a859 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 19:39:10 +0200 Subject: [PATCH 08/22] feat(chat): copy the turn render model from the desktop chat Copy the RenderItem union, executedToolIdentities, isSupersededGate, and the consecutive-tool-folding loop verbatim from AgentMessage.tsx, injecting the registry-backed isClientToolPart check as a predicate so this layer stays registry-free. --- web/packages/agenta-chat/src/model/index.ts | 1 + .../agenta-chat/src/model/renderModel.ts | 81 ++++++++++++++++++ .../tests/unit/fixtures/supersededGate.json | 27 ++++++ .../tests/unit/fixtures/toolTurn.json | 24 ++++++ .../tests/unit/model/renderModel.test.ts | 83 +++++++++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 web/packages/agenta-chat/src/model/renderModel.ts create mode 100644 web/packages/agenta-chat/tests/unit/fixtures/supersededGate.json create mode 100644 web/packages/agenta-chat/tests/unit/fixtures/toolTurn.json create mode 100644 web/packages/agenta-chat/tests/unit/model/renderModel.test.ts diff --git a/web/packages/agenta-chat/src/model/index.ts b/web/packages/agenta-chat/src/model/index.ts index 9671e411de4..29473c00a23 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -5,3 +5,4 @@ export * from "./error" export * from "./toolSummary" export * from "./approvals" export * from "./turnStatus" +export * from "./renderModel" 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 00000000000..27e0a0a31c1 --- /dev/null +++ b/web/packages/agenta-chat/src/model/renderModel.ts @@ -0,0 +1,81 @@ +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 + 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/tests/unit/fixtures/supersededGate.json b/web/packages/agenta-chat/tests/unit/fixtures/supersededGate.json new file mode 100644 index 00000000000..69e03ba30fb --- /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 00000000000..1231423c263 --- /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/model/renderModel.test.ts b/web/packages/agenta-chat/tests/unit/model/renderModel.test.ts new file mode 100644 index 00000000000..06239af2cc5 --- /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) + }) +}) From 79e7891de2fb5c48dc965567aee987f12d24ce45 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 19:44:32 +0200 Subject: [PATCH 09/22] feat(chat): copy turn grouping and session status derivation from the desktop chat Copy the active-turn anchoring IIFE (lastUserIndex/activeStart/reserveActive) from AgentConversation.tsx as getTurnGrouping, and the error>awaiting>running>idle session-status precedence as deriveSessionRunStatus, both as pure functions. --- .../agenta-chat/src/model/grouping.ts | 28 ++++++++++ web/packages/agenta-chat/src/model/index.ts | 2 + .../agenta-chat/src/model/sessionStatus.ts | 22 ++++++++ .../tests/unit/model/grouping.test.ts | 51 +++++++++++++++++++ .../tests/unit/model/sessionStatus.test.ts | 16 ++++++ 5 files changed, 119 insertions(+) create mode 100644 web/packages/agenta-chat/src/model/grouping.ts create mode 100644 web/packages/agenta-chat/src/model/sessionStatus.ts create mode 100644 web/packages/agenta-chat/tests/unit/model/grouping.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/model/sessionStatus.test.ts 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 00000000000..81b8a2eaefc --- /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 index 29473c00a23..f510d19c5ba 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -6,3 +6,5 @@ export * from "./toolSummary" export * from "./approvals" export * from "./turnStatus" export * from "./renderModel" +export * from "./grouping" +export * from "./sessionStatus" 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 00000000000..a3aa22705e6 --- /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/tests/unit/model/grouping.test.ts b/web/packages/agenta-chat/tests/unit/model/grouping.test.ts new file mode 100644 index 00000000000..2884d96c47f --- /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/sessionStatus.test.ts b/web/packages/agenta-chat/tests/unit/model/sessionStatus.test.ts new file mode 100644 index 00000000000..a4e8600baf5 --- /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) + }) +}) From 9de378c439c33e400c44b995c548689b8cd4faab Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 20:40:13 +0200 Subject: [PATCH 10/22] feat(chat): copy the pure chat asset helpers from the desktop chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies toolFormat, trace, attachmentRules (renamed from attachments.ts), files, and rewind from web/oss/src/components/AgentChatSlice/assets into @agenta/chat's src/assets/ — all pure over `ai` types with no OSS-app imports. Canonicalizes stripFence in assets/toolFormat.ts; model/toolSummary.ts now re-exports it instead of holding a second definition. Also: add a .gitignore for the package's vitest test-results/coverage output (mirrors the sibling packages), and a one-line doc comment on isClientToolPart's registry-backed contract in renderModel.ts. --- web/packages/agenta-chat/.gitignore | 3 + web/packages/agenta-chat/package.json | 3 +- .../agenta-chat/src/assets/attachmentRules.ts | 102 ++++++++++++++++++ web/packages/agenta-chat/src/assets/files.ts | 57 ++++++++++ web/packages/agenta-chat/src/assets/index.ts | 5 + web/packages/agenta-chat/src/assets/rewind.ts | 37 +++++++ .../agenta-chat/src/assets/toolFormat.ts | 45 ++++++++ web/packages/agenta-chat/src/assets/trace.ts | 79 ++++++++++++++ web/packages/agenta-chat/src/index.ts | 1 + .../agenta-chat/src/model/renderModel.ts | 1 + .../agenta-chat/src/model/toolSummary.ts | 34 +++--- .../tests/unit/assets/attachmentRules.test.ts | 87 +++++++++++++++ .../tests/unit/assets/files.test.ts | 94 ++++++++++++++++ .../tests/unit/assets/rewind.test.ts | 69 ++++++++++++ .../tests/unit/assets/toolFormat.test.ts | 46 ++++++++ .../tests/unit/assets/trace.test.ts | 98 +++++++++++++++++ 16 files changed, 739 insertions(+), 22 deletions(-) create mode 100644 web/packages/agenta-chat/.gitignore create mode 100644 web/packages/agenta-chat/src/assets/attachmentRules.ts create mode 100644 web/packages/agenta-chat/src/assets/files.ts create mode 100644 web/packages/agenta-chat/src/assets/index.ts create mode 100644 web/packages/agenta-chat/src/assets/rewind.ts create mode 100644 web/packages/agenta-chat/src/assets/toolFormat.ts create mode 100644 web/packages/agenta-chat/src/assets/trace.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/attachmentRules.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/files.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/rewind.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/trace.test.ts diff --git a/web/packages/agenta-chat/.gitignore b/web/packages/agenta-chat/.gitignore new file mode 100644 index 00000000000..96d253c48ed --- /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 index eb1acf3a723..dadabe26a25 100644 --- a/web/packages/agenta-chat/package.json +++ b/web/packages/agenta-chat/package.json @@ -17,7 +17,8 @@ }, "exports": { ".": "./src/index.ts", - "./model": "./src/model/index.ts" + "./model": "./src/model/index.ts", + "./assets": "./src/assets/index.ts" }, "dependencies": { "@agenta/entities": "workspace:../agenta-entities", 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 00000000000..cd29393d51e --- /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 00000000000..ee6d2f6975e --- /dev/null +++ b/web/packages/agenta-chat/src/assets/files.ts @@ -0,0 +1,57 @@ +// 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" + +/** + * 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) + }) + +/** Convert picked `File`s into `file` parts for `sendMessage({text, files})`. */ +export const filesToParts = (files: File[]): Promise => + Promise.all(files.map(fileToPart)) + +/** 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 00000000000..2cb1c9de53d --- /dev/null +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -0,0 +1,5 @@ +export * from "./toolFormat" +export * from "./trace" +export * from "./attachmentRules" +export * from "./files" +export * from "./rewind" 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 00000000000..6893af4e244 --- /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 00000000000..a3594b89629 --- /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 00000000000..9550885b77c --- /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/index.ts b/web/packages/agenta-chat/src/index.ts index c1f8e9c2ca8..2e75606bbad 100644 --- a/web/packages/agenta-chat/src/index.ts +++ b/web/packages/agenta-chat/src/index.ts @@ -1 +1,2 @@ export * from "./model" +export * from "./assets" diff --git a/web/packages/agenta-chat/src/model/renderModel.ts b/web/packages/agenta-chat/src/model/renderModel.ts index 27e0a0a31c1..e1145e468f8 100644 --- a/web/packages/agenta-chat/src/model/renderModel.ts +++ b/web/packages/agenta-chat/src/model/renderModel.ts @@ -47,6 +47,7 @@ export const isSupersededGate = (part: ToolUIPart, executed: Set): boole export interface BuildTurnRenderItemsOptions { executed: Set + /** Registry-backed check; the desktop closes over {isStreaming, isLastMessage} + renderMap — see AgentMessage.tsx. */ isClientToolPart: (part: ToolUIPart) => boolean } diff --git a/web/packages/agenta-chat/src/model/toolSummary.ts b/web/packages/agenta-chat/src/model/toolSummary.ts index 594d4b4b923..4db33ea3d3c 100644 --- a/web/packages/agenta-chat/src/model/toolSummary.ts +++ b/web/packages/agenta-chat/src/model/toolSummary.ts @@ -1,20 +1,24 @@ 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 } -// 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. -/** 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 -} +// 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 @@ -24,18 +28,6 @@ export const stripFence = (value: string): string => { 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/assets/transcriptToMessages.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. -// Mirrors services/runner/src/tracing/otel.ts; park sentinels report skipped or unobserved work, not final results. -const DEFERRED_NOT_EXECUTED_PREFIX = "DEFERRED_NOT_EXECUTED" -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." -const APPROVED_EXECUTION_RESULT_UNKNOWN_PREFIX = APPROVED_EXECUTION_RESULT_UNKNOWN.slice( - 0, - APPROVED_EXECUTION_RESULT_UNKNOWN.indexOf(":"), -) - // 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. 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 00000000000..ccb3af30573 --- /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 00000000000..89d95f34141 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/files.test.ts @@ -0,0 +1,94 @@ +import type {FileUIPart, UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {fileKind, fileParts, filePartName} 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") + }) +}) 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 00000000000..77dd7b7c925 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/rewind.test.ts @@ -0,0 +1,69 @@ +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 00000000000..ecc605cc71d --- /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 00000000000..95a8b7b7629 --- /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() + }) +}) From 69f66ccb71f66b7b79cdeb3dd94c16f0e1565d68 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 20:49:48 +0200 Subject: [PATCH 11/22] feat(chat): copy the transcript replay adapter and transport from the desktop chat Copies transcriptToMessages (records -> UIMessage[] replay), loadSession (the durable-history hydration seam), and AgentChatTransport (the stream<->batch negotiating AI-SDK transport) from web/oss/src/components/AgentChatSlice/assets into @agenta/chat. All three resolve cleanly through already-allowed package deps (@agenta/entities, @agenta/playground, @agenta/shared, ai, jotai) with no OSS-app imports, so no signature adaptations were needed. New src/transport/ dir + barrel, wired into the package exports map and root src/index.ts. --- web/packages/agenta-chat/package.json | 3 +- web/packages/agenta-chat/src/assets/index.ts | 2 + .../agenta-chat/src/assets/loadSession.ts | 54 +++ .../src/assets/transcriptToMessages.ts | 316 ++++++++++++++++++ web/packages/agenta-chat/src/index.ts | 1 + .../src/transport/AgentChatTransport.ts | 224 +++++++++++++ .../agenta-chat/src/transport/index.ts | 1 + .../tests/unit/assets/loadSession.test.ts | 77 +++++ .../tests/unit/assets/rewind.test.ts | 5 +- .../tests/unit/assets/toolFormat.test.ts | 2 +- .../unit/assets/transcriptToMessages.test.ts | 122 +++++++ .../unit/transport/AgentChatTransport.test.ts | 74 ++++ 12 files changed, 878 insertions(+), 3 deletions(-) create mode 100644 web/packages/agenta-chat/src/assets/loadSession.ts create mode 100644 web/packages/agenta-chat/src/assets/transcriptToMessages.ts create mode 100644 web/packages/agenta-chat/src/transport/AgentChatTransport.ts create mode 100644 web/packages/agenta-chat/src/transport/index.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts diff --git a/web/packages/agenta-chat/package.json b/web/packages/agenta-chat/package.json index dadabe26a25..a13efa62608 100644 --- a/web/packages/agenta-chat/package.json +++ b/web/packages/agenta-chat/package.json @@ -18,7 +18,8 @@ "exports": { ".": "./src/index.ts", "./model": "./src/model/index.ts", - "./assets": "./src/assets/index.ts" + "./assets": "./src/assets/index.ts", + "./transport": "./src/transport/index.ts" }, "dependencies": { "@agenta/entities": "workspace:../agenta-entities", diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 2cb1c9de53d..b40d0a6187c 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -3,3 +3,5 @@ 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 00000000000..4ee95d9386b --- /dev/null +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -0,0 +1,54 @@ +// 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 const loadSessionMessages = async ( + sessionId: string, + onRefreshed?: (messages: UIMessage[]) => 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(freshMsgs) + }) + } + if (!records || records.length === 0) return null + return transcriptToMessages(records) + } catch (err) { + console.warn("[loadSessionMessages] hydration fetch failed:", err) + return null + } +} 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 00000000000..aa2c123ec93 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -0,0 +1,316 @@ +// Copied verbatim 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. Keep byte-parity if either side changes. +import type {SessionRecord} from "@agenta/entities/session" +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 + +// 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} +} + +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) || + errorText === APPROVED_EXECUTION_RESULT_UNKNOWN + ) +} + +/** Apply one transcript event's payload onto the current assistant/user draft message. */ +function applyEvent( + draft: DraftMessage, + payload: Record, + index: TranscriptIndex, +): 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)}) + 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 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) + const part = + (toolCallId ? index.tools.get(toolCallId) : undefined) ?? + index.approvals.get(responseId) + if ( + !part || + part.state !== "approval-requested" || + typeof responsePayload.approved !== "boolean" + ) { + return + } + part.state = "approval-responded" + part.approval = {id: responseId, approved: responsePayload.approved} + return + } + case "file": { + draft.parts.push({ + type: "file", + url: str(payload.url), + mediaType: str(payload.mediaType), + }) + 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") { + if (current && traceId && !current.traceId) current.traceId = traceId + 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) + } + + 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 + 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/index.ts b/web/packages/agenta-chat/src/index.ts index 2e75606bbad..a088209c466 100644 --- a/web/packages/agenta-chat/src/index.ts +++ b/web/packages/agenta-chat/src/index.ts @@ -1,2 +1,3 @@ export * from "./model" export * from "./assets" +export * from "./transport" 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 00000000000..5134d5716b1 --- /dev/null +++ b/web/packages/agenta-chat/src/transport/AgentChatTransport.ts @@ -0,0 +1,224 @@ +// 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) + 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 00000000000..25aea9a7941 --- /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/loadSession.test.ts b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts new file mode 100644 index 00000000000..efc1cb7c4ea --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -0,0 +1,77 @@ +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 messages = await loadSessionMessages("session-1") + expect(messages).toHaveLength(1) + expect(messages?.[0]).toMatchObject({parts: [{type: "text", text: "hi"}]}) + }) + + 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 {parts: unknown}[] + expect(delivered[0]).toMatchObject({parts: [{type: "text", text: "fresh"}]}) + }) + + 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 index 77dd7b7c925..8297754f79d 100644 --- a/web/packages/agenta-chat/tests/unit/assets/rewind.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/rewind.test.ts @@ -30,7 +30,10 @@ describe("messageText", () => { const message = { id: "m1", role: "assistant", - parts: [{type: "text", text: "ok"}, {type: "tool-search_docs", state: "output-available"}], + parts: [ + {type: "text", text: "ok"}, + {type: "tool-search_docs", state: "output-available"}, + ], } as unknown as UIMessage expect(messageText(message)).toBe("ok") }) diff --git a/web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts b/web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts index ecc605cc71d..fe47dda1012 100644 --- a/web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/toolFormat.test.ts @@ -8,7 +8,7 @@ describe("stripFence", () => { }) it("leaves inner fenced blocks intact when they don't span the whole string", () => { - const value = 'see ```inline``` here' + const value = "see ```inline``` here" expect(stripFence(value)).toBe(value) }) 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 00000000000..058f4df8d36 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -0,0 +1,122 @@ +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"}) + }) +}) 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 00000000000..b8e41b91a92 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts @@ -0,0 +1,74 @@ +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) + }) +}) From a0be0783aba21c39ce94b4e33b1a7b5bc9947641 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 21:01:52 +0200 Subject: [PATCH 12/22] feat(chat): copy the chat session state stores from the desktop chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies expandState (per-widget expand/collapse, verbatim) and sessionEphemera (composer drafts, pending attachments, fresh-session markers) from web/oss/src/components/AgentChatSlice/state into @agenta/chat's src/state/. sessionEphemera carries two declared adaptations: attachmentsBySession is typed Map (the package's neutral staged-upload shape) instead of the desktop's upload-widget file type, and the desktop's per-session virtualized-list scroll/row-height snapshot map is omitted entirely — virtualization state is desktop-only and the package must not depend on that library. --- web/packages/agenta-chat/package.json | 3 +- web/packages/agenta-chat/src/index.ts | 1 + .../agenta-chat/src/state/expandState.ts | 79 +++++++++++++++ web/packages/agenta-chat/src/state/index.ts | 2 + .../agenta-chat/src/state/sessionEphemera.ts | 48 +++++++++ .../tests/unit/state/expandState.test.ts | 97 +++++++++++++++++++ .../tests/unit/state/sessionEphemera.test.ts | 74 ++++++++++++++ 7 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 web/packages/agenta-chat/src/state/expandState.ts create mode 100644 web/packages/agenta-chat/src/state/index.ts create mode 100644 web/packages/agenta-chat/src/state/sessionEphemera.ts create mode 100644 web/packages/agenta-chat/tests/unit/state/expandState.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/state/sessionEphemera.test.ts diff --git a/web/packages/agenta-chat/package.json b/web/packages/agenta-chat/package.json index a13efa62608..e0f0f443daa 100644 --- a/web/packages/agenta-chat/package.json +++ b/web/packages/agenta-chat/package.json @@ -19,7 +19,8 @@ ".": "./src/index.ts", "./model": "./src/model/index.ts", "./assets": "./src/assets/index.ts", - "./transport": "./src/transport/index.ts" + "./transport": "./src/transport/index.ts", + "./state": "./src/state/index.ts" }, "dependencies": { "@agenta/entities": "workspace:../agenta-entities", diff --git a/web/packages/agenta-chat/src/index.ts b/web/packages/agenta-chat/src/index.ts index a088209c466..68ceacd2822 100644 --- a/web/packages/agenta-chat/src/index.ts +++ b/web/packages/agenta-chat/src/index.ts @@ -1,3 +1,4 @@ export * from "./model" export * from "./assets" export * from "./transport" +export * from "./state" 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 00000000000..4907a840990 --- /dev/null +++ b/web/packages/agenta-chat/src/state/expandState.ts @@ -0,0 +1,79 @@ +// 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 a Virtuoso unmount when its row scrolls out of the window. + * + * 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 00000000000..22aea514ba2 --- /dev/null +++ b/web/packages/agenta-chat/src/state/index.ts @@ -0,0 +1,2 @@ +export * from "./expandState" +export * from "./sessionEphemera" 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 00000000000..5abf137dd27 --- /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/tests/unit/state/expandState.test.ts b/web/packages/agenta-chat/tests/unit/state/expandState.test.ts new file mode 100644 index 00000000000..a0c003457e8 --- /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 00000000000..2cffe9b85ff --- /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") + }) +}) From 8f9924aff286854ff6a8fd68bbcb2bfa994afa50 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 21:13:00 +0200 Subject: [PATCH 13/22] feat(chat): copy the chat queue and model-key hooks from the desktop chat --- web/packages/agenta-chat/package.json | 8 +- web/packages/agenta-chat/src/hooks/index.ts | 2 + .../src/hooks/useAgentChatQueue.ts | 134 ++++++++++ .../src/hooks/useAgentModelKeyStatus.ts | 115 +++++++++ web/packages/agenta-chat/src/index.ts | 1 + .../unit/hooks/useAgentChatQueue.test.ts | 233 ++++++++++++++++++ 6 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 web/packages/agenta-chat/src/hooks/index.ts create mode 100644 web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts create mode 100644 web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts create mode 100644 web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts diff --git a/web/packages/agenta-chat/package.json b/web/packages/agenta-chat/package.json index e0f0f443daa..70ed1bdcd97 100644 --- a/web/packages/agenta-chat/package.json +++ b/web/packages/agenta-chat/package.json @@ -20,7 +20,8 @@ "./model": "./src/model/index.ts", "./assets": "./src/assets/index.ts", "./transport": "./src/transport/index.ts", - "./state": "./src/state/index.ts" + "./state": "./src/state/index.ts", + "./hooks": "./src/hooks/index.ts" }, "dependencies": { "@agenta/entities": "workspace:../agenta-entities", @@ -35,10 +36,15 @@ }, "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/hooks/index.ts b/web/packages/agenta-chat/src/hooks/index.ts new file mode 100644 index 00000000000..c87fd398f23 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useAgentChatQueue" +export * from "./useAgentModelKeyStatus" 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 00000000000..3aad0d10cfb --- /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/useAgentModelKeyStatus.ts b/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts new file mode 100644 index 00000000000..563dd284a73 --- /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/index.ts b/web/packages/agenta-chat/src/index.ts index 68ceacd2822..ebad43d6cb2 100644 --- a/web/packages/agenta-chat/src/index.ts +++ b/web/packages/agenta-chat/src/index.ts @@ -2,3 +2,4 @@ export * from "./model" export * from "./assets" export * from "./transport" export * from "./state" +export * from "./hooks" 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 00000000000..c02f31485d0 --- /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() + }) + }) +}) From 7805ae778a669ba63f7974ce5c6040e6451cd1f0 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 21:18:36 +0200 Subject: [PATCH 14/22] feat(chat): assemble the composer attachment and approval hooks --- web/packages/agenta-chat/src/hooks/index.ts | 2 + .../agenta-chat/src/hooks/useApprovalDock.ts | 101 ++++++++++++++ .../src/hooks/useComposerAttachments.ts | 103 ++++++++++++++ .../tests/unit/hooks/useApprovalDock.test.ts | 114 +++++++++++++++ .../unit/hooks/useComposerAttachments.test.ts | 130 ++++++++++++++++++ 5 files changed, 450 insertions(+) create mode 100644 web/packages/agenta-chat/src/hooks/useApprovalDock.ts create mode 100644 web/packages/agenta-chat/src/hooks/useComposerAttachments.ts create mode 100644 web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts diff --git a/web/packages/agenta-chat/src/hooks/index.ts b/web/packages/agenta-chat/src/hooks/index.ts index c87fd398f23..8115d9149d1 100644 --- a/web/packages/agenta-chat/src/hooks/index.ts +++ b/web/packages/agenta-chat/src/hooks/index.ts @@ -1,2 +1,4 @@ export * from "./useAgentChatQueue" export * from "./useAgentModelKeyStatus" +export * from "./useComposerAttachments" +export * from "./useApprovalDock" 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 00000000000..7b23c92372f --- /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 00000000000..fbceef811ad --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts @@ -0,0 +1,103 @@ +// 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, 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) ?? []) : [], + ) + 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 + + /** Add files from picker / paste / programmatic sources through the guardrails. */ + const add = useCallback( + (incoming: File[]) => { + setFiles((prev) => { + const {accepted, rejections: rej} = validateIncoming(incoming, prev.length, limits) + setRejections(rej) + return accepted.length ? [...prev, ...accepted.map(toPendingAttachment)] : prev + }) + }, + [limits], + ) + + const remove = useCallback((uid: string) => { + setFiles((prev) => prev.filter((f) => f.uid !== uid)) + }, []) + + const clear = useCallback(() => { + setFiles([]) + setRejections([]) + }, []) + + const dismissRejections = useCallback(() => setRejections([]), []) + + const toParts = useCallback( + () => (files.length ? filesToParts(files.map((f) => f.file)) : Promise.resolve([])), + [files], + ) + + return {files, rejections, limits, atMax, add, remove, clear, dismissRejections, toParts} +} 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 00000000000..62fa011ce9b --- /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 00000000000..6278ec134cd --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts @@ -0,0 +1,130 @@ +// @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"]) + }) + + 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) + }) +}) From 3d1a06dddc9249425af25b838e5eaf0e514412c6 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 21:42:04 +0200 Subject: [PATCH 15/22] feat(chat): assemble the headless conversation host hook --- web/packages/agenta-chat/src/hooks/index.ts | 1 + .../src/hooks/useAgentConversation.ts | 560 ++++++++++++++++++ web/packages/agenta-chat/src/model/index.ts | 1 + .../agenta-chat/src/model/turnViewModel.ts | 131 ++++ web/packages/agenta-chat/src/state/index.ts | 1 + .../agenta-chat/src/state/sessionMessages.ts | 111 ++++ .../unit/hooks/useAgentConversation.test.ts | 211 +++++++ .../tests/unit/model/turnViewModel.test.ts | 156 +++++ .../tests/unit/state/sessionMessages.test.ts | 45 ++ 9 files changed, 1217 insertions(+) create mode 100644 web/packages/agenta-chat/src/hooks/useAgentConversation.ts create mode 100644 web/packages/agenta-chat/src/model/turnViewModel.ts create mode 100644 web/packages/agenta-chat/src/state/sessionMessages.ts create mode 100644 web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/model/turnViewModel.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts diff --git a/web/packages/agenta-chat/src/hooks/index.ts b/web/packages/agenta-chat/src/hooks/index.ts index 8115d9149d1..93a1545a5b5 100644 --- a/web/packages/agenta-chat/src/hooks/index.ts +++ b/web/packages/agenta-chat/src/hooks/index.ts @@ -2,3 +2,4 @@ export * from "./useAgentChatQueue" export * from "./useAgentModelKeyStatus" export * from "./useComposerAttachments" export * from "./useApprovalDock" +export * from "./useAgentConversation" 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 00000000000..247a35b1257 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -0,0 +1,560 @@ +// 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} 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 = (freshMsgs: UIMessage[]) => { + 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}) + } + loadSessionMessages(sessionId, adoptRefreshed) + .then((msgs) => { + if (cancelled) return + if (!msgs || msgs.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 + } + msgs.forEach((m) => restoredIdsRef.current.add(m.id)) + setMessages(msgs) + persistMessages({id: sessionId, messages: msgs}) + }) + .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 = (serverMsgs: UIMessage[] | null) => { + if (cancelled || !serverMsgs || serverMsgs.length === 0) return + 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}) + } + // 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 + const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined + // 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) { + setMessages(msgs.slice(0, idx)) + } 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/model/index.ts b/web/packages/agenta-chat/src/model/index.ts index f510d19c5ba..06928b76ba7 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -8,3 +8,4 @@ export * from "./turnStatus" export * from "./renderModel" export * from "./grouping" export * from "./sessionStatus" +export * from "./turnViewModel" 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 00000000000..70f948da7af --- /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/state/index.ts b/web/packages/agenta-chat/src/state/index.ts index 22aea514ba2..7c54180abb1 100644 --- a/web/packages/agenta-chat/src/state/index.ts +++ b/web/packages/agenta-chat/src/state/index.ts @@ -1,2 +1,3 @@ export * from "./expandState" export * from "./sessionEphemera" +export * from "./sessionMessages" 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 00000000000..5db480f549e --- /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/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts new file mode 100644 index 00000000000..1264d5c1a90 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -0,0 +1,211 @@ +// @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}) + }) + + 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/model/turnViewModel.test.ts b/web/packages/agenta-chat/tests/unit/model/turnViewModel.test.ts new file mode 100644 index 00000000000..63e25e8f4e8 --- /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/state/sessionMessages.test.ts b/web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts new file mode 100644 index 00000000000..9246248140a --- /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") + }) +}) From cc5aefe8bb9bf85b402345648c4a2c3b9f08002a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 21:57:28 +0200 Subject: [PATCH 16/22] feat(chat): add the chat skin registration and resolvers WP3a-C5: the package's own skin registration store (empty until a skin populates it). src/skin/types.ts mirrors the real OSS clientTools, approvals, and toolDisplay registry shapes (generalized, Handler/Renderer renamed to Widget/Entry, no OSS import). src/skin/registry.ts adds registerChatSkin (later-registration-wins merge) plus resolvers that reproduce the OSS resolution order faithfully: render.kind before toolName for client tools, and the toolDisplay override-then-parsed- name-shape fallback chain (mcp__ split / gateway tools__ parsing via the existing @agenta/entities parseGatewayToolName / title-cased raw name). The OSS registries stay byte-untouched per the plan's COPY-mode banner; this store is wired to nothing until the desktop re-plumb PR. Also rewords a stray "Virtuoso" mention in expandState.ts's docstring to keep the banned-literal grep gate conservative. --- web/packages/agenta-chat/package.json | 3 +- web/packages/agenta-chat/src/index.ts | 1 + web/packages/agenta-chat/src/skin/index.ts | 2 + web/packages/agenta-chat/src/skin/registry.ts | 116 +++++++++++++ web/packages/agenta-chat/src/skin/types.ts | 133 +++++++++++++++ .../agenta-chat/src/state/expandState.ts | 3 +- .../tests/unit/skin/registry.test.ts | 156 ++++++++++++++++++ web/pnpm-lock.yaml | 48 +++++- 8 files changed, 453 insertions(+), 9 deletions(-) create mode 100644 web/packages/agenta-chat/src/skin/index.ts create mode 100644 web/packages/agenta-chat/src/skin/registry.ts create mode 100644 web/packages/agenta-chat/src/skin/types.ts create mode 100644 web/packages/agenta-chat/tests/unit/skin/registry.test.ts diff --git a/web/packages/agenta-chat/package.json b/web/packages/agenta-chat/package.json index 70ed1bdcd97..1f735cb633b 100644 --- a/web/packages/agenta-chat/package.json +++ b/web/packages/agenta-chat/package.json @@ -21,7 +21,8 @@ "./assets": "./src/assets/index.ts", "./transport": "./src/transport/index.ts", "./state": "./src/state/index.ts", - "./hooks": "./src/hooks/index.ts" + "./hooks": "./src/hooks/index.ts", + "./skin": "./src/skin/index.ts" }, "dependencies": { "@agenta/entities": "workspace:../agenta-entities", diff --git a/web/packages/agenta-chat/src/index.ts b/web/packages/agenta-chat/src/index.ts index ebad43d6cb2..0b940b89c79 100644 --- a/web/packages/agenta-chat/src/index.ts +++ b/web/packages/agenta-chat/src/index.ts @@ -3,3 +3,4 @@ export * from "./assets" export * from "./transport" export * from "./state" export * from "./hooks" +export * from "./skin" 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 00000000000..b35c59aaa5f --- /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 00000000000..d73c0899906 --- /dev/null +++ b/web/packages/agenta-chat/src/skin/registry.ts @@ -0,0 +1,116 @@ +/** + * 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.) +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 00000000000..b38800eb0dd --- /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 index 4907a840990..3e5c63b6e1b 100644 --- a/web/packages/agenta-chat/src/state/expandState.ts +++ b/web/packages/agenta-chat/src/state/expandState.ts @@ -7,7 +7,8 @@ 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 a Virtuoso unmount when its row scrolls out of the window. + * 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 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 00000000000..75da7ece06b --- /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/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2d84b8b546e..c3aed5e0054 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -922,16 +922,13 @@ importers: '@agenta/shared': specifier: workspace:../agenta-shared version: link:../agenta-shared - jotai: - specifier: '>=2.0.0' - version: 2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6) - react: - specifier: '>=18.0.0' - version: 19.2.6 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 @@ -944,12 +941,24 @@ importers: 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)(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)) + 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: @@ -5371,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'} @@ -14550,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 From 6fcb2e50f8084e545ac1198170a43c5e72dde91b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 25 Jul 2026 23:11:13 +0200 Subject: [PATCH 17/22] fix(chat): review polish on attachment hook and skin registry Restructure useComposerAttachments' add() so setRejections runs outside the setFiles updater (React state updaters must be pure; StrictMode double-invokes them), mirroring the OSS AgentConversation original. Note the registry's kind-override as a deliberate extension over the OSS resolveToolDisplay chain. --- .../agenta-chat/src/hooks/useComposerAttachments.ts | 12 ++++++------ web/packages/agenta-chat/src/skin/registry.ts | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts index fbceef811ad..f0e0f82f57e 100644 --- a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts +++ b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts @@ -74,13 +74,13 @@ export const useComposerAttachments = ({ /** Add files from picker / paste / programmatic sources through the guardrails. */ const add = useCallback( (incoming: File[]) => { - setFiles((prev) => { - const {accepted, rejections: rej} = validateIncoming(incoming, prev.length, limits) - setRejections(rej) - return accepted.length ? [...prev, ...accepted.map(toPendingAttachment)] : prev - }) + const {accepted, rejections: rej} = validateIncoming(incoming, files.length, limits) + if (accepted.length) { + setFiles((prev) => [...prev, ...accepted.map(toPendingAttachment)]) + } + setRejections(rej) }, - [limits], + [files, limits], ) const remove = useCallback((uid: string) => { diff --git a/web/packages/agenta-chat/src/skin/registry.ts b/web/packages/agenta-chat/src/skin/registry.ts index d73c0899906..decaf66dd18 100644 --- a/web/packages/agenta-chat/src/skin/registry.ts +++ b/web/packages/agenta-chat/src/skin/registry.ts @@ -80,6 +80,9 @@ export const resolveApprovalBody = (toolName: string): ApprovalBodyEntry | undef // 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__")) { From e9fd19dbd505f2013845267389b99b223407a25d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 3 Aug 2026 22:35:45 +0300 Subject: [PATCH 18/22] fix(chat): keep a replayed tool call named, and stop three stale reads Review of the package surfaced five defects that share a shape: a value is read once and used later, after the thing it described has moved on. - The batch transport turned a neutral `tool_result` block into a nameless `tool-` part and replayed an input chunk for it. The AI SDK keys tool parts by `toolCallId`, which that block shares with its `tool_use` sibling, so the empty name and undefined input overwrote the real call and the turn rendered an unnamed tool. Only a part that carries a name emits an input chunk now. - `loadSessionMessages` left its detached revalidation chain without a rejection handler. The chain outlives the function, so the surrounding try/catch never saw it and a failed revalidation surfaced as an unhandled rejection instead of quietly leaving the restored transcript in place. - A rewind plan truncated against the transcript snapshot taken when the plan was built. The skin confirms it after its warning dialog, so anything adopted in that window was thrown away. The index is re-resolved at confirm time, and a plan whose target has left the transcript now does nothing. - The composer validated an incoming batch against the staged count from its render closure, so a paste and a drop in the same tick both saw the same count and could push the staged set past the limit. - The approved-execution sentinel was matched by equality while every sibling user matches by prefix. The code before the colon is the contract; the prose after it is not. Each fix has a test that fails without it. --- .../agenta-chat/src/assets/loadSession.ts | 17 ++++-- .../src/assets/transcriptToMessages.ts | 6 +- .../src/hooks/useAgentConversation.ts | 9 ++- .../src/hooks/useComposerAttachments.ts | 14 ++++- .../src/transport/AgentChatTransport.ts | 19 +++++-- .../tests/unit/assets/loadSession.test.ts | 29 ++++++++++ .../unit/hooks/useAgentConversation.test.ts | 36 ++++++++++++ .../unit/hooks/useComposerAttachments.test.ts | 15 +++++ .../unit/transport/AgentChatTransport.test.ts | 56 +++++++++++++++++++ 9 files changed, 185 insertions(+), 16 deletions(-) diff --git a/web/packages/agenta-chat/src/assets/loadSession.ts b/web/packages/agenta-chat/src/assets/loadSession.ts index 4ee95d9386b..31bcecfcd6e 100644 --- a/web/packages/agenta-chat/src/assets/loadSession.ts +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -39,11 +39,18 @@ export const loadSessionMessages = async ( 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(freshMsgs) - }) + void refreshed + .then((fresh) => { + if (!fresh || fresh.length === 0) return + const freshMsgs = transcriptToMessages(fresh) + if (freshMsgs && freshMsgs.length > 0) onRefreshed(freshMsgs) + }) + // 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 return transcriptToMessages(records) diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index aa2c123ec93..99d6d349c72 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -97,7 +97,11 @@ const isRunnerSentinelError = (part: Part): boolean => { const errorText = typeof part.errorText === "string" ? part.errorText : "" return ( errorText.startsWith(DEFERRED_NOT_EXECUTED_PREFIX) || - errorText === APPROVED_EXECUTION_RESULT_UNKNOWN + // 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) ) } diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 247a35b1257..104506e2aa0 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -517,7 +517,14 @@ export const useAgentConversation = ({ const sideEffects = sideEffectingToolsInRange(msgs.slice(idx)) const confirm = () => { if (isUser) { - setMessages(msgs.slice(0, idx)) + // 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) } diff --git a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts index f0e0f82f57e..1c90f420f09 100644 --- a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts +++ b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts @@ -5,7 +5,7 @@ // 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, useState} from "react" +import {useCallback, useEffect, useRef, useState} from "react" import type {FileUIPart} from "ai" @@ -71,16 +71,24 @@ export const useComposerAttachments = ({ 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, files.length, limits) + const {accepted, rejections: rej} = validateIncoming(incoming, countRef.current, limits) if (accepted.length) { + countRef.current += accepted.length setFiles((prev) => [...prev, ...accepted.map(toPendingAttachment)]) } setRejections(rej) }, - [files, limits], + [limits], ) const remove = useCallback((uid: string) => { diff --git a/web/packages/agenta-chat/src/transport/AgentChatTransport.ts b/web/packages/agenta-chat/src/transport/AgentChatTransport.ts index 5134d5716b1..68283921317 100644 --- a/web/packages/agenta-chat/src/transport/AgentChatTransport.ts +++ b/web/packages/agenta-chat/src/transport/AgentChatTransport.ts @@ -168,12 +168,19 @@ function batchJsonToUiMessageStream( // A UIMessage tool part → re-emit as the tool input/output chunks. const toolCallId = part.toolCallId ?? `tool-${seq}` const toolName = t.slice("tool-".length) - emit({ - type: "tool-input-available", - toolCallId, - toolName, - input: part.input, - }) + // 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}) } diff --git a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts index efc1cb7c4ea..9f6082f1687 100644 --- a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -63,6 +63,35 @@ describe("loadSessionMessages", () => { expect(delivered[0]).toMatchObject({parts: [{type: "text", text: "fresh"}]}) }) + // 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 messages = await loadSessionMessages("session-1", onRefreshed) + await Promise.resolve() + await Promise.resolve() + // The restored transcript still stands; only the revalidation was lost. + expect(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"})], diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index 1264d5c1a90..fc62b5745eb 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -159,6 +159,42 @@ describe("useAgentConversation", () => { 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() diff --git a/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts index 6278ec134cd..bdb47252872 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts @@ -67,6 +67,21 @@ describe("useComposerAttachments", () => { 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("remove unstages one file; dismissRejections keeps files; clear drops both", () => { const {result} = setup() act(() => { diff --git a/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts b/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts index b8e41b91a92..8c175f21afc 100644 --- a/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts +++ b/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts @@ -71,4 +71,60 @@ describe("AgentChatTransport", () => { // 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"}, + ]) + }) }) From 6fd452e825feddca5636886addd468a4f6811257 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 4 Aug 2026 00:47:33 +0300 Subject: [PATCH 19/22] fix(chat): re-seed staged attachments when the session id changes useComposerAttachments seeds its file list from the per-session store in a useState initializer, then keys the mirror effect on sessionId. Today's only caller mounts one instance per session, so the two always agree. A caller that swapped sessionId in place would not: the hook would keep the previous session's staged files, and the mirror effect would immediately write them under the new session's key, overwriting whatever that session had. The seed is now redone during render when sessionId changes, so the effect never observes a mismatched pair. The test covers the swap in both directions: the new session shows its own files, and the old session keeps its own. --- .../src/hooks/useComposerAttachments.ts | 9 +++++++++ .../unit/hooks/useComposerAttachments.test.ts | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts index 1c90f420f09..06f00b667c1 100644 --- a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts +++ b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts @@ -61,6 +61,15 @@ export const useComposerAttachments = ({ 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) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts index bdb47252872..1b377a216b0 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts @@ -82,6 +82,25 @@ describe("useComposerAttachments", () => { 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(() => { From 1a74817979b66c664e3d5185b855723f61fad7d9 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 5 Aug 2026 14:07:13 +0300 Subject: [PATCH 20/22] fix(chat): re-sync the replay adapter with the OSS original MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter was copied from the desktop slice mid-flight and three corrections landed on the original afterwards. Replaying a durable transcript through this package therefore diverges from what the desktop shows for the same session: - A `done` carrying `stopReason: "paused"` is not the end of an assistant turn. Closing the draft there splits a resumed execution into a second message and strands a stale approval row beside it. The draft now stays open across a paused boundary and closes when the resumed turn finishes. - A cold resume can reissue an approved call under a NEW `toolCallId`. Matching on the tool id first finds the reissued input-only part and returns before the original gate is answered, so replay leaves a gate pending on a turn that actually continued. Resolution now goes by the stable interaction id, then remaps the new tool id onto the original gate. - The `message` branch emitted text only and dropped the attachment descriptors in the durable payload, so reopening a file-bearing session lost its files. It now rebuilds `file` parts from the session and attachment ids, as the original does. All three already existed in this branch — they arrived nine lanes later, bundled with the mobile attachment rendering that consumes them. That left this package, the lane that owns the adapter, shipping the divergent copy. The package half moves here; the mobile rendering stays where it belongs. --- .../src/assets/transcriptToMessages.ts | 119 +++++++- .../unit/assets/transcriptToMessages.test.ts | 259 ++++++++++++++++++ 2 files changed, 363 insertions(+), 15 deletions(-) diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index 99d6d349c72..505498e230c 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -1,7 +1,16 @@ -// Copied verbatim 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. Keep byte-parity if either side changes. +// 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" /** @@ -21,6 +30,12 @@ import type {UIMessage} from "ai" 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 = @@ -41,6 +56,11 @@ interface DraftMessage { 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 { @@ -110,6 +130,7 @@ 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)) @@ -117,6 +138,25 @@ function applyEvent( 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": { @@ -147,6 +187,14 @@ function applyEvent( } 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, @@ -214,18 +262,30 @@ function applyEvent( 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 = - (toolCallId ? index.tools.get(toolCallId) : undefined) ?? - index.approvals.get(responseId) - if ( - !part || - part.state !== "approval-requested" || - typeof responsePayload.approved !== "boolean" - ) { - return + 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 } - part.state = "approval-responded" - part.approval = {id: responseId, approved: responsePayload.approved} return } case "file": { @@ -233,6 +293,7 @@ function applyEvent( type: "file", url: str(payload.url), mediaType: str(payload.mediaType), + filename: str(payload.filename) || undefined, }) return } @@ -286,7 +347,23 @@ export function transcriptToMessages(records: SessionRecord[]): UIMessage[] | nu // 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") { - if (current && traceId && !current.traceId) current.traceId = traceId + // 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 } @@ -296,7 +373,18 @@ export function transcriptToMessages(records: SessionRecord[]): UIMessage[] | nu drafts.push(current) } if (traceId && !current.traceId) current.traceId = traceId - applyEvent(current, p, index) + 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 @@ -308,6 +396,7 @@ export function transcriptToMessages(records: SessionRecord[]): UIMessage[] | nu 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, diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 058f4df8d36..15cc881ae47 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -120,3 +120,262 @@ describe("transcriptToMessages", () => { 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([]) + }) +}) From c9e8ad0d1f7c9091c5d704529b415ddf0caead82 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 5 Aug 2026 15:21:55 +0300 Subject: [PATCH 21/22] fix(chat): adopt a revalidated transcript on the record count, not the message count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revalidation guard asked whether the fresh transcript had MORE UI messages than the one on screen. A turn that grows in place produces the same number of messages — an approval answered elsewhere, a tool result that completed after the tab restored — so the newer durable transcript was rejected and the reopened conversation stayed permanently partial (issue #5530). `loadSessionMessages` now carries the durable record count alongside the messages, and adoption compares that watermark, which moves whenever the log does. Same rule the desktop host applies through `shouldAdoptServerTranscript`. This is the package half of a fix that arrived nine lanes later with its mobile consumer, which left the package that owns the adoption guard shipping the message-count version. --- .../agenta-chat/src/assets/loadSession.ts | 21 ++++++++--- .../src/hooks/useAgentConversation.ts | 31 ++++++++++------ .../tests/unit/assets/loadSession.test.ts | 36 +++++++++++++++---- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/web/packages/agenta-chat/src/assets/loadSession.ts b/web/packages/agenta-chat/src/assets/loadSession.ts index 31bcecfcd6e..ef624a52881 100644 --- a/web/packages/agenta-chat/src/assets/loadSession.ts +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -27,10 +27,20 @@ import {transcriptToMessages} from "./transcriptToMessages" * 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?: (messages: UIMessage[]) => void, -): Promise => { + 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` @@ -43,7 +53,9 @@ export const loadSessionMessages = async ( .then((fresh) => { if (!fresh || fresh.length === 0) return const freshMsgs = transcriptToMessages(fresh) - if (freshMsgs && freshMsgs.length > 0) onRefreshed(freshMsgs) + 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 @@ -53,7 +65,8 @@ export const loadSessionMessages = async ( }) } if (!records || records.length === 0) return null - return transcriptToMessages(records) + 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/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 104506e2aa0..612eb5a0ff4 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -29,7 +29,7 @@ import type {UIMessage} from "ai" import {useSetAtom, useStore} from "jotai" import {filesToParts} from "../assets/files" -import {loadSessionMessages} from "../assets/loadSession" +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" @@ -266,27 +266,31 @@ export const useAgentConversation = ({ // 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 = (freshMsgs: UIMessage[]) => { + 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}) + persistMessages({id: sessionId, messages: freshMsgs, recordCount}) } loadSessionMessages(sessionId, adoptRefreshed) - .then((msgs) => { + .then((transcript) => { if (cancelled) return - if (!msgs || msgs.length === 0) { + 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 } - msgs.forEach((m) => restoredIdsRef.current.add(m.id)) - setMessages(msgs) - persistMessages({id: sessionId, messages: msgs}) + 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) @@ -306,13 +310,18 @@ export const useAgentConversation = ({ 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 = (serverMsgs: UIMessage[] | null) => { - if (cancelled || !serverMsgs || serverMsgs.length === 0) return + 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}) + 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. diff --git a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts index 9f6082f1687..ab85a645037 100644 --- a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -42,9 +42,24 @@ describe("loadSessionMessages", () => { fetchResult = { records: [record("r1", {type: "message", text: "hi"}), record("r2", {type: "done"})], } - const messages = await loadSessionMessages("session-1") - expect(messages).toHaveLength(1) - expect(messages?.[0]).toMatchObject({parts: [{type: "text", text: "hi"}]}) + 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 () => { @@ -59,8 +74,13 @@ describe("loadSessionMessages", () => { await Promise.resolve() await Promise.resolve() expect(onRefreshed).toHaveBeenCalledTimes(1) - const delivered = onRefreshed.mock.calls[0][0] as {parts: unknown}[] - expect(delivered[0]).toMatchObject({parts: [{type: "text", text: "fresh"}]}) + 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. @@ -77,11 +97,13 @@ describe("loadSessionMessages", () => { refreshed: Promise.reject(new Error("boom")), } const onRefreshed = vi.fn() - const messages = await loadSessionMessages("session-1", onRefreshed) + const transcript = await loadSessionMessages("session-1", onRefreshed) await Promise.resolve() await Promise.resolve() // The restored transcript still stands; only the revalidation was lost. - expect(messages?.[0]).toMatchObject({parts: [{type: "text", text: "stale"}]}) + expect(transcript?.messages[0]).toMatchObject({ + parts: [{type: "text", text: "stale"}], + }) expect(onRefreshed).not.toHaveBeenCalled() expect(warn).toHaveBeenCalled() await new Promise((resolve) => setTimeout(resolve, 0)) From 410b5f75621c76bfb38a8b6115ae787f49bdfeb3 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 5 Aug 2026 15:41:01 +0300 Subject: [PATCH 22/22] fix(chat): send what encoded when one attachment cannot be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filesToParts` ran every file through `Promise.all`, so one rejection took the whole conversion with it. A file that became unreadable between staging and submit — moved, permission revoked, a disconnected drive — therefore lost the message text and every other attachment, and the caller saw a bare rejection with nothing to show for it. Each file now settles on its own. The readable parts go out, and the ones that failed come back in the same `AttachmentRejection` shape the size and type guardrails already use, so the composer shows them inline next to "too large" instead of needing a second vocabulary for the same idea. --- web/packages/agenta-chat/src/assets/files.ts | 31 +++++++++- .../src/hooks/useAgentConversation.ts | 10 +++- .../src/hooks/useComposerAttachments.ts | 13 +++-- .../tests/unit/assets/files.test.ts | 56 ++++++++++++++++++- 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/web/packages/agenta-chat/src/assets/files.ts b/web/packages/agenta-chat/src/assets/files.ts index ee6d2f6975e..69f53ed4950 100644 --- a/web/packages/agenta-chat/src/assets/files.ts +++ b/web/packages/agenta-chat/src/assets/files.ts @@ -3,6 +3,8 @@ // 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 @@ -35,9 +37,32 @@ const fileToPart = (file: File): Promise => reader.readAsDataURL(file) }) -/** Convert picked `File`s into `file` parts for `sendMessage({text, files})`. */ -export const filesToParts = (files: File[]): Promise => - Promise.all(files.map(fileToPart)) +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[] => diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 612eb5a0ff4..78cf6f1ad39 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -495,7 +495,15 @@ export const useAgentConversation = ({ const trimmed = text.trim() const fileObjs = files ?? [] if (!trimmed && fileObjs.length === 0) return - const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined + // 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. diff --git a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts index 06f00b667c1..dba4519773f 100644 --- a/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts +++ b/web/packages/agenta-chat/src/hooks/useComposerAttachments.ts @@ -111,10 +111,15 @@ export const useComposerAttachments = ({ const dismissRejections = useCallback(() => setRejections([]), []) - const toParts = useCallback( - () => (files.length ? filesToParts(files.map((f) => f.file)) : Promise.resolve([])), - [files], - ) + // 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/tests/unit/assets/files.test.ts b/web/packages/agenta-chat/tests/unit/assets/files.test.ts index 89d95f34141..8e199d8648a 100644 --- a/web/packages/agenta-chat/tests/unit/assets/files.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/files.test.ts @@ -1,7 +1,7 @@ import type {FileUIPart, UIMessage} from "ai" import {describe, expect, it} from "vitest" -import {fileKind, fileParts, filePartName} from "../../../src/assets/files" +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, @@ -92,3 +92,57 @@ describe("filePartName", () => { 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([]) + }) + }) +})