diff --git a/sdk/src/openagents/mods/workspace/messaging/mod.py b/sdk/src/openagents/mods/workspace/messaging/mod.py index b086cff82..147d5fbe5 100644 --- a/sdk/src/openagents/mods/workspace/messaging/mod.py +++ b/sdk/src/openagents/mods/workspace/messaging/mod.py @@ -2258,15 +2258,17 @@ def _extract_text_from_event(self, event: Event) -> str: return "" def _extract_content_from_event(self, event: Event) -> Dict[str, Any]: - """Extract full content (text and files) from an Event object's payload. + """Extract message content from an Event object's payload. - This handles the nested content structure: payload.content + This preserves all fields in the nested payload.content mapping, such + as text, files, schema, embeds, actions, and custom extension fields, + while ensuring the returned dict always has a text key. Args: event: The Event object to extract content from Returns: - Dict with text and optionally files + Dict containing all payload.content fields plus a guaranteed text field """ if not event or not event.payload: return {"text": ""} @@ -2274,12 +2276,8 @@ def _extract_content_from_event(self, event: Event) -> Dict[str, Any]: # Handle nested content structure (payload.content) if "content" in event.payload and isinstance(event.payload["content"], dict): content = event.payload["content"] - result = {"text": content.get("text", "")} - - # Include files if present - if "files" in content and content["files"]: - result["files"] = content["files"] - + result = dict(content) + result["text"] = result.get("text", "") return result else: return {"text": ""} diff --git a/sdk/studio/package-lock.json b/sdk/studio/package-lock.json index 34ab6fbdb..416217e68 100644 --- a/sdk/studio/package-lock.json +++ b/sdk/studio/package-lock.json @@ -110,6 +110,7 @@ "openagents-studio": "bin/cli.js" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/dom": "^10", "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", @@ -121,7 +122,10 @@ "@types/node": "^20.17.50", "@types/three": "^0.182.0", "@types/uuid": "^11.0.0", - "cross-env": "^10.1.0" + "cross-env": "^10.1.0", + "prettier": "^3.8.3", + "source-map-explorer": "^2.5.3", + "wait-on": "^9.0.10" } }, "node_modules/@adobe/css-tools": { @@ -3249,6 +3253,60 @@ "node": ">=12" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@headless-tree/core": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@headless-tree/core/-/core-1.6.1.tgz", @@ -4236,6 +4294,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.16", "license": "MIT", @@ -10257,14 +10331,15 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -10761,6 +10836,19 @@ "node-int64": "^0.4.0" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -14109,7 +14197,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.9", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -18268,6 +18358,25 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joi": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -18879,9 +18988,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -20946,6 +21055,53 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -22283,6 +22439,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -22448,10 +22620,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.15.0", @@ -24256,6 +24431,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -24876,6 +25061,58 @@ "node": ">= 8" } }, + "node_modules/source-map-explorer": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/source-map-explorer/-/source-map-explorer-2.5.3.tgz", + "integrity": "sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "btoa": "^1.2.1", + "chalk": "^4.1.0", + "convert-source-map": "^1.7.0", + "ejs": "^3.1.5", + "escape-html": "^1.0.3", + "glob": "^7.1.6", + "gzip-size": "^6.0.0", + "lodash": "^4.17.20", + "open": "^7.3.1", + "source-map": "^0.7.4", + "temp": "^0.9.4", + "yargs": "^16.2.0" + }, + "bin": { + "sme": "bin/cli.js", + "source-map-explorer": "bin/cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/source-map-explorer/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-explorer/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -25674,6 +25911,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -25683,6 +25934,20 @@ "node": ">=8" } }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/tempy": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", @@ -26822,6 +27087,26 @@ "node": ">=10" } }, + "node_modules/wait-on": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/sdk/studio/package.json b/sdk/studio/package.json index 94df60959..13f6b9aca 100644 --- a/sdk/studio/package.json +++ b/sdk/studio/package.json @@ -111,6 +111,7 @@ "zustand": "^5.0.9" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/dom": "^10", "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", @@ -122,7 +123,10 @@ "@types/node": "^20.17.50", "@types/three": "^0.182.0", "@types/uuid": "^11.0.0", - "cross-env": "^10.1.0" + "cross-env": "^10.1.0", + "prettier": "^3.8.3", + "source-map-explorer": "^2.5.3", + "wait-on": "^9.0.10" }, "scripts": { "start": "HOST=0.0.0.0 PORT=8050 DANGEROUSLY_DISABLE_HOST_CHECK=true craco start", @@ -131,6 +135,9 @@ "build:analyze": "cross-env GENERATE_SOURCEMAP=false craco build && npx source-map-explorer 'build/static/js/*.js'", "test": "craco test", "test:coverage": "craco test --coverage --watchAll=false", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "playwright:install": "playwright install chromium", "eject": "react-scripts eject", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "lint:fix": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix", diff --git a/sdk/studio/playwright.config.ts b/sdk/studio/playwright.config.ts new file mode 100644 index 000000000..79f14ee1b --- /dev/null +++ b/sdk/studio/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:8050", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/sdk/studio/src/pages/messaging/MessagingView.tsx b/sdk/studio/src/pages/messaging/MessagingView.tsx index 3a8245a77..11c4f2724 100644 --- a/sdk/studio/src/pages/messaging/MessagingView.tsx +++ b/sdk/studio/src/pages/messaging/MessagingView.tsx @@ -22,6 +22,8 @@ import { extractProjectIdFromChannel, } from "@/utils/projectUtils" import ProjectChatRoom from "./components/ProjectChatRoom" +import { EventNames } from "@/types/events" +import { MessageAction, UnifiedMessage } from "@/types/message" const ThreadMessagingViewEventBased: React.FC = () => { const { t } = useTranslation("messaging") @@ -58,6 +60,18 @@ const ThreadMessagingViewEventBased: React.FC = () => { // Use new OpenAgents context const { connector, connectionStatus, isConnected } = useOpenAgents() + const currentAgentId = + connectionStatus.agentId || connector?.getAgentId?.() || agentName || "" + + const agentConversationTarget = useMemo(() => { + if (!currentAgentConversation || !currentAgentId) return null + + const [agentA, agentB] = currentAgentConversation.split(",", 2) + if (currentAgentId === agentA) return agentB + if (currentAgentId === agentB) return agentA + return null + }, [currentAgentConversation, currentAgentId]) + // Set chatStore context reference useEffect(() => { setChatStoreContext({ connector, connectionStatus, isConnected }) @@ -265,15 +279,24 @@ const ThreadMessagingViewEventBased: React.FC = () => { console.log(`🔍 Channel selection logic:`, { currentChannel, currentDirectMessage, + currentAgentConversation, availableChannels: channels.map((c) => c.name), availableAgents: filteredAgents.map((a) => a.agent_id), - selectionStateFromChatStore: { currentChannel, currentDirectMessage }, + selectionStateFromChatStore: { + currentChannel, + currentDirectMessage, + currentAgentConversation, + }, }) let selectedChannel = null let selectionReason = "" - if (currentChannel) { + if (currentAgentConversation) { + console.log( + `✅ Keep current agent conversation selection: ${currentAgentConversation}` + ) + } else if (currentChannel) { // Check if currently selected regular channel still exists const channelExists = channels.some( (channel) => channel.name === currentChannel @@ -351,6 +374,7 @@ const ThreadMessagingViewEventBased: React.FC = () => { filteredAgents, currentChannel, currentDirectMessage, + currentAgentConversation, selectChannel, ]) @@ -454,13 +478,21 @@ const ThreadMessagingViewEventBased: React.FC = () => { replyToId, currentChannel, currentDirectMessage, + currentAgentConversation, + agentConversationTarget, isProjectChannel: isProjectChannelActive, }) setSendingMessage(true) try { let success = false - if (currentChannel) { + if (agentConversationTarget) { + success = await sendDirectMessage(agentConversationTarget, content) + // TODO: Add attachment support for direct messages + } else if (currentAgentConversation) { + toast.error("This agent conversation is read-only.") + return + } else if (currentChannel) { // Check if this is a project channel const projectId = extractProjectIdFromChannel(currentChannel) @@ -534,6 +566,8 @@ const ThreadMessagingViewEventBased: React.FC = () => { [ currentChannel, currentDirectMessage, + currentAgentConversation, + agentConversationTarget, sendingMessage, sendChannelMessage, sendDirectMessage, @@ -544,6 +578,73 @@ const ThreadMessagingViewEventBased: React.FC = () => { ] ) + const handleMessageAction = useCallback( + async ( + message: UnifiedMessage, + action: MessageAction, + values: Record + ) => { + if (!currentChannel || !connector) { + toast.error("Action responses are only available in channels.") + return + } + + const actionResponse = { + source_message_id: message.id, + source_message_sender: message.senderId, + action_id: action.id, + action_label: action.label, + value: action.value || {}, + inputs: values, + } + const detailLines = Object.entries({ + ...(action.value || {}), + ...values, + }).map(([key, value]) => `${key}: ${String(value)}`) + + try { + const response = await connector.sendEvent({ + event_name: EventNames.THREAD_CHANNEL_MESSAGE_POST, + source_id: connectionStatus.agentId || agentName, + destination_id: `channel:${currentChannel}`, + payload: { + channel: currentChannel, + content: { + text: [ + `Action response: ${action.label}`, + `source_message_id: ${message.id}`, + `action_id: ${action.id}`, + ...detailLines, + ].join("\n"), + schema: "openagents.message.v1", + action_response: actionResponse, + }, + message_type: "channel_message", + }, + }) + + if (!response?.success) { + toast.error(response?.message || "Failed to submit action response.") + throw new Error(response?.message || "Failed to submit action response.") + } + toast.success(`Submitted: ${action.label}`) + await loadChannelMessages(currentChannel) + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to submit action response." + toast.error(message) + throw error + } + }, + [ + agentName, + connectionStatus.agentId, + connector, + currentChannel, + loadChannelMessages, + ] + ) + // Handle reply and quote actions const startReply = useCallback( (messageId: string, text: string, author: string) => { @@ -822,7 +923,9 @@ const ThreadMessagingViewEventBased: React.FC = () => { if (filteredMessages.length === 0) { return (
- {currentChannel + {currentAgentConversation + ? `No messages in ${currentAgentConversation.replace(",", " ↔ ")} yet.` + : currentChannel ? t("empty.noMessagesChannel", { channel: currentChannel, }) @@ -891,9 +994,10 @@ const ThreadMessagingViewEventBased: React.FC = () => { }} onReply={startReply} onQuote={startQuote} - isDMChat={!!currentDirectMessage} + isDMChat={!!(currentDirectMessage || agentConversationTarget)} disableReactions={isProjectChannelActive} disableQuotes={isProjectChannelActive} + onMessageAction={handleMessageAction} networkHost={connector?.getHost()} networkPort={connector?.getPort()} agentSecret={connector?.getSecret()} @@ -904,16 +1008,16 @@ const ThreadMessagingViewEventBased: React.FC = () => {
{/* Read-only banner for agent conversations */} - {currentAgentConversation && ( + {currentAgentConversation && !agentConversationTarget && (
Read-only — agent-to-agent conversation ({currentAgentConversation.replace(",", " ↔ ")})
)} - {/* Message Input — hidden for agent conversation (read-only) */} - {(currentChannel || currentDirectMessage) && !currentAgentConversation && ( + {/* Message Input */} + {(currentChannel || currentDirectMessage || agentConversationTarget) && ( { placeholder={ sendingMessage ? "Sending..." + : agentConversationTarget + ? `Message ${agentConversationTarget}` : currentChannel ? `Message #${currentChannel}` : currentDirectMessage @@ -976,9 +1082,9 @@ const ThreadMessagingViewEventBased: React.FC = () => { : "Select a channel to start typing..." } currentTheme={currentTheme} - currentChannel={currentChannel || undefined} - currentDirectMessage={currentDirectMessage || undefined} - currentAgentId={connectionStatus.agentId || agentName || ""} + currentChannel={agentConversationTarget ? undefined : currentChannel || undefined} + currentDirectMessage={currentDirectMessage || agentConversationTarget || undefined} + currentAgentId={currentAgentId} currentAgentSecret={connector?.getSecret() || null} networkBaseUrl={connector?.getBaseUrl()} replyingTo={replyingTo} diff --git a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx index 2e41ebc67..10512da62 100644 --- a/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx +++ b/sdk/studio/src/pages/messaging/components/MessageRenderer.tsx @@ -10,7 +10,7 @@ */ import React, { useState, useRef } from "react" -import { UnifiedMessage } from "@/types/message" +import { MessageAction, MessageEmbed, UnifiedMessage } from "@/types/message" import { ThreadMessage } from "@/types/events" import { formatRelativeTimestamp, @@ -55,6 +55,11 @@ interface MessageRendererProps { disableReactions?: boolean // Whether to disable quote features (for project channel) disableQuotes?: boolean + onMessageAction?: ( + message: UnifiedMessage, + action: MessageAction, + values: Record + ) => void | Promise // Network connection details for attachment downloads networkHost?: string networkPort?: number @@ -72,6 +77,7 @@ const MessageRenderer: React.FC = ({ isDMChat = false, disableReactions = false, disableQuotes = false, + onMessageAction, networkHost, networkPort, agentSecret, @@ -83,6 +89,20 @@ const MessageRenderer: React.FC = ({ const [collapsedThreads, setCollapsedThreads] = useState>( new Set() ) + const [pendingAction, setPendingAction] = useState<{ + message: UnifiedMessage + action: MessageAction + } | null>(null) + const [actionInputValues, setActionInputValues] = useState>( + {} + ) + const [actionInputError, setActionInputError] = useState(null) + const [submittingActionKey, setSubmittingActionKey] = useState( + null + ) + const [completedActionsByMessageId, setCompletedActionsByMessageId] = useState< + Record + >({}) const messagesEndRef = useRef(null) // Remove auto-scroll logic from MessageRenderer - MessagingView handles this @@ -106,6 +126,8 @@ const MessageRenderer: React.FC = ({ senderId: threadMsg.sender_id, timestamp: threadMsg.timestamp, content: threadMsg.content?.text || "", + embeds: threadMsg.content?.embeds || [], + actions: threadMsg.content?.actions || [], replyToId: threadMsg.reply_to_id, reactions: threadMsg.reactions, attachments, @@ -118,6 +140,8 @@ const MessageRenderer: React.FC = ({ senderId: unifiedMsg.senderId, timestamp: unifiedMsg.timestamp, content: unifiedMsg.content, + embeds: unifiedMsg.embeds || [], + actions: unifiedMsg.actions || [], replyToId: unifiedMsg.replyToId, reactions: unifiedMsg.reactions, attachments: unifiedMsg.attachments, @@ -203,6 +227,306 @@ const MessageRenderer: React.FC = ({ setCollapsedThreads(newCollapsed) } + const handleMessageAction = async ( + message: UnifiedMessage, + action: MessageAction + ) => { + if (action.type === "link" && action.href) { + window.open(action.href, "_blank", "noopener,noreferrer") + return + } + if (action.requires && action.requires.length > 0) { + const initialValues = action.requires.reduce>( + (values, requirement) => { + values[requirement.name] = requirement.type === "boolean" ? false : "" + return values + }, + {} + ) + setActionInputValues(initialValues) + setActionInputError(null) + setPendingAction({ message, action }) + return + } + if (onMessageAction) { + const actionKey = `${message.id}:${action.id}` + setSubmittingActionKey(actionKey) + try { + await onMessageAction(message, action, {}) + setCompletedActionsByMessageId((current) => ({ + ...current, + [message.id]: action.id, + })) + } finally { + setSubmittingActionKey(null) + } + } + } + + const updateActionInputValue = (name: string, value: any) => { + setActionInputValues((current) => ({ + ...current, + [name]: value, + })) + setActionInputError(null) + } + + const submitPendingAction = async () => { + if (!pendingAction || !onMessageAction) return + + for (const requirement of pendingAction.action.requires || []) { + const value = actionInputValues[requirement.name] + const isEmpty = + value === undefined || + value === null || + (typeof value === "string" && value.trim() === "") + if (requirement.required && isEmpty) { + setActionInputError(`${requirement.label || requirement.name} is required.`) + return + } + } + + const actionKey = `${pendingAction.message.id}:${pendingAction.action.id}` + setSubmittingActionKey(actionKey) + try { + await onMessageAction( + pendingAction.message, + pendingAction.action, + actionInputValues + ) + setCompletedActionsByMessageId((current) => ({ + ...current, + [pendingAction.message.id]: pendingAction.action.id, + })) + setPendingAction(null) + setActionInputValues({}) + setActionInputError(null) + } finally { + setSubmittingActionKey(null) + } + } + + const renderActionRequirementInput = ( + requirement: NonNullable[number] + ) => { + const value = actionInputValues[requirement.name] + const label = requirement.label || requirement.name + const id = `message-action-${pendingAction?.action.id}-${requirement.name}` + + if (requirement.type === "textarea") { + return ( +