From aab1c51f0a1721d8dca36118e4e93176e974372a Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:12:38 -0500 Subject: [PATCH 1/6] Add browser AI SDK tool loop demo --- .gitignore | 2 + common/config/rush/pnpm-lock.yaml | 273 +++++++++++++ packages/acp-agent/src/agent.ts | 44 +-- .../src/ai-sdk-model-turn-runner.test.ts | 86 +++++ .../src/ai-sdk-model-turn-runner.ts | 36 ++ packages/agent-core/src/index.ts | 1 + packages/web-demo/.env.example | 3 + packages/web-demo/index.html | 12 + packages/web-demo/package.json | 32 ++ packages/web-demo/src/App.tsx | 326 ++++++++++++++++ packages/web-demo/src/acp-demo.ts | 117 ++++++ packages/web-demo/src/components.tsx | 360 ++++++++++++++++++ packages/web-demo/src/main.tsx | 11 + packages/web-demo/src/model-runner.ts | 56 +++ packages/web-demo/src/styles.css | 356 +++++++++++++++++ packages/web-demo/tsconfig.json | 14 + packages/web-demo/vite.config.ts | 56 +++ rush.json | 4 + 18 files changed, 1766 insertions(+), 23 deletions(-) create mode 100644 packages/agent-core/src/ai-sdk-model-turn-runner.test.ts create mode 100644 packages/agent-core/src/ai-sdk-model-turn-runner.ts create mode 100644 packages/web-demo/.env.example create mode 100644 packages/web-demo/index.html create mode 100644 packages/web-demo/package.json create mode 100644 packages/web-demo/src/App.tsx create mode 100644 packages/web-demo/src/acp-demo.ts create mode 100644 packages/web-demo/src/components.tsx create mode 100644 packages/web-demo/src/main.tsx create mode 100644 packages/web-demo/src/model-runner.ts create mode 100644 packages/web-demo/src/styles.css create mode 100644 packages/web-demo/tsconfig.json create mode 100644 packages/web-demo/vite.config.ts diff --git a/.gitignore b/.gitignore index b4fa67d..32af540 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ temp/ .heft/ *.tsbuildinfo .env +.env.local +.env.*.local .fledgling/ diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 3cf7c4a..69e61f2 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -418,6 +418,52 @@ importers: specifier: ^6.0.1 version: 6.0.1(webpack@5.107.2) + ../../packages/web-demo: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.4.3 + version: 0.4.9 + '@ai-sdk/openai': + specifier: ^2.0.0 + version: 2.0.106(zod@3.25.76) + '@fledgling/web-agent': + specifier: workspace:* + version: link:../web-agent + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@webcontainer/api': + specifier: ^1.5.1 + version: 1.6.4 + ai: + specifier: ^5.0.0 + version: 5.0.193(zod@3.25.76) + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + react: + specifier: ^19.2.3 + version: 19.2.7 + react-arborist: + specifier: ^3.4.3 + version: 3.8.0(@types/node@22.19.19)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: + specifier: ^19.2.3 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.7 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vite: + specifier: ^7.3.5 + version: 7.3.5(@types/node@22.19.19)(terser@5.48.0) + ../../rigs/heft-rig: dependencies: '@rushstack/eslint-config': @@ -495,6 +541,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -779,6 +829,16 @@ packages: '@cfworker/json-schema': optional: true + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -802,6 +862,15 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@react-dnd/asap@4.0.1': + resolution: {integrity: sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==} + + '@react-dnd/invariant@2.0.0': + resolution: {integrity: sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==} + + '@react-dnd/shallowequal@2.0.0': + resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} + '@rollup/rollup-android-arm-eabi@4.61.0': resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} cpu: [arm] @@ -1032,9 +1101,20 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/tapable@1.0.6': resolution: {integrity: sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1474,6 +1554,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1517,10 +1600,16 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dnd-core@14.0.1: + resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -1901,6 +1990,9 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} @@ -2219,6 +2311,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2227,6 +2324,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -2268,6 +2368,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2477,9 +2580,49 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-arborist@3.8.0: + resolution: {integrity: sha512-66UQK2mWtodjkHg4efiIYjQt0VlFhQ4LXTphcqHi0+1Jc7hAxcxAC1SbmCUCpZYUW7C+14WgsnYgBRqG0AYb1A==} + peerDependencies: + react: '>= 16.14' + react-dom: '>= 16.14' + + react-dnd-html5-backend@14.1.0: + resolution: {integrity: sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==} + + react-dnd@14.0.5: + resolution: {integrity: sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==} + peerDependencies: + '@types/hoist-non-react-statics': '>= 3.3.1' + '@types/node': '>= 12' + '@types/react': '>= 16' + react: '>= 16.14' + peerDependenciesMeta: + '@types/hoist-non-react-statics': + optional: true + '@types/node': + optional: true + '@types/react': + optional: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-window@1.8.11: + resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -2491,6 +2634,12 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2560,6 +2709,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@4.3.3: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} @@ -2663,6 +2815,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -2889,6 +3044,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3115,6 +3275,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -3337,6 +3499,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3356,6 +3529,12 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@react-dnd/asap@4.0.1': {} + + '@react-dnd/invariant@2.0.0': {} + + '@react-dnd/shallowequal@2.0.0': {} + '@rollup/rollup-android-arm-eabi@4.61.0': optional: true @@ -3592,8 +3771,19 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/tapable@1.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4127,6 +4317,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -4169,10 +4361,20 @@ snapshots: depd@2.0.0: {} + dnd-core@14.0.1: + dependencies: + '@react-dnd/asap': 4.0.1 + '@react-dnd/invariant': 2.0.0 + redux: 4.2.1 + doctrine@2.1.0: dependencies: esutils: 2.0.3 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@17.4.2: {} dunder-proto@1.0.1: @@ -4695,6 +4897,10 @@ snapshots: help-me@5.0.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + hono@4.12.23: {} html-escaper@2.0.2: {} @@ -5007,10 +5213,14 @@ snapshots: dependencies: semver: 7.5.4 + marked@14.0.0: {} + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} + memoize-one@5.2.1: {} + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -5044,6 +5254,11 @@ snapshots: minipass@7.1.3: {} + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} nanoid@3.3.12: {} @@ -5262,8 +5477,52 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-arborist@3.8.0(@types/node@22.19.19)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dnd: 14.0.5(@types/node@22.19.19)(@types/react@19.2.17)(react@19.2.7) + react-dnd-html5-backend: 14.1.0 + react-dom: 19.2.7(react@19.2.7) + react-window: 1.8.11(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + redux: 5.0.1 + use-sync-external-store: 1.6.0(react@19.2.7) + transitivePeerDependencies: + - '@types/hoist-non-react-statics' + - '@types/node' + - '@types/react' + + react-dnd-html5-backend@14.1.0: + dependencies: + dnd-core: 14.0.1 + + react-dnd@14.0.5(@types/node@22.19.19)(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@react-dnd/invariant': 2.0.0 + '@react-dnd/shallowequal': 2.0.0 + dnd-core: 14.0.1 + fast-deep-equal: 3.1.3 + hoist-non-react-statics: 3.3.2 + react: 19.2.7 + optionalDependencies: + '@types/node': 22.19.19 + '@types/react': 19.2.17 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + react-is@16.13.1: {} + react-window@1.8.11(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + memoize-one: 5.2.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + real-require@0.2.0: {} real-require@1.0.0: {} @@ -5272,6 +5531,12 @@ snapshots: dependencies: resolve: 1.22.12 + redux@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5388,6 +5653,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 @@ -5515,6 +5782,8 @@ snapshots: stackback@0.0.2: {} + state-local@1.0.7: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -5730,6 +5999,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + vary@1.1.2: {} vite-node@3.2.4(@types/node@22.19.19)(terser@5.48.0): diff --git a/packages/acp-agent/src/agent.ts b/packages/acp-agent/src/agent.ts index e59b739..9e23ba9 100644 --- a/packages/acp-agent/src/agent.ts +++ b/packages/acp-agent/src/agent.ts @@ -1,14 +1,14 @@ import { createOpenAI, type OpenAIProvider } from "@ai-sdk/openai"; import { + AiSdkModelTurnRunner, + type AiSdkToolChoice, type FledglingAgentDependencies, type IModelTurnRunner, - type ModelTurnRequest, - type ModelTurnResult, - type ModelStreamPart + type ModelTurnResult } from "@fledgling/agent-core"; import { FileSystemSessionManager } from "@fledgling/session-file-system"; import { NodeMcpToolProvider } from "@fledgling/tools-mcp-node"; -import { stepCountIs, streamText, type LanguageModel, type ToolSet } from "ai"; +import { type LanguageModel, type ToolSet } from "ai"; export { FledglingAgent, @@ -26,31 +26,29 @@ const DEFAULT_SYSTEM_PROMPT: string = "You are Fledgling, a small ACP-native assistant. Answer directly. Use tools when they are available and useful. If the user asks you to inspect, create, modify, delete, search, or execute something in the workspace, use the relevant workspace tool instead of only describing what you would do. If the user asks you to write content to a file, call the file-writing tool. Do not claim you cannot access files when a relevant workspace tool is available. Tool results may include Fledgling context hints that describe identity, retention, and prompt placement for future context assembly."; export class VercelAiSdkModelTurnRunner implements IModelTurnRunner { - public runModelTurn(request: ModelTurnRequest): ModelTurnResult { - const openai = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY, - baseURL: process.env.OPENAI_BASE_URL - }); - const modelName = process.env.OPENAI_MODEL ?? "gpt-4.1-mini"; - const model = selectOpenAiModel(openai, modelName); + readonly #runner: AiSdkModelTurnRunner; - const result = streamText({ - model, - system: process.env.FLEDGLING_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT, - messages: request.messages, - tools: request.tools, - toolChoice: getToolChoice(request.tools), - stopWhen: stepCountIs(5), - abortSignal: request.abortSignal + public constructor() { + this.#runner = new AiSdkModelTurnRunner({ + resolveModel: () => { + const openai = createOpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL + }); + const modelName = process.env.OPENAI_MODEL ?? "gpt-4.1-mini"; + return selectOpenAiModel(openai, modelName); + }, + resolveSystemPrompt: () => process.env.FLEDGLING_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT, + resolveToolChoice: getToolChoice }); + } - return { - fullStream: result.fullStream as AsyncIterable - }; + public runModelTurn(...args: Parameters): ModelTurnResult { + return this.#runner.runModelTurn(...args); } } -function getToolChoice(tools: ToolSet): "auto" | { type: "tool"; toolName: string } { +function getToolChoice(tools: ToolSet): AiSdkToolChoice { const toolName = process.env.FLEDGLING_TOOL_CHOICE; if (!toolName) { return "auto"; diff --git a/packages/agent-core/src/ai-sdk-model-turn-runner.test.ts b/packages/agent-core/src/ai-sdk-model-turn-runner.test.ts new file mode 100644 index 0000000..79d531b --- /dev/null +++ b/packages/agent-core/src/ai-sdk-model-turn-runner.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AiSdkModelTurnRunner } from "./ai-sdk-model-turn-runner.js"; + +const mocks = vi.hoisted(() => { + const fullStream = (async function* (): AsyncIterable {})(); + const stopWhen = { kind: "stop-when" }; + return { + fullStream, + stopWhen, + streamText: vi.fn(() => ({ fullStream })), + stepCountIs: vi.fn(() => stopWhen) + }; +}); + +vi.mock("ai", () => ({ + streamText: mocks.streamText, + stepCountIs: mocks.stepCountIs +})); + +describe("AiSdkModelTurnRunner", () => { + beforeEach(() => { + mocks.streamText.mockClear(); + mocks.stepCountIs.mockClear(); + }); + + it("passes messages, tools, abort signal, and defaults into streamText", () => { + const model = { modelId: "test-model" }; + const messages = [{ role: "user" as const, content: "hello" }]; + const tools = {}; + const abortController = new AbortController(); + const runner = new AiSdkModelTurnRunner({ + resolveModel: () => model as never, + resolveSystemPrompt: () => "system prompt" + }); + + const result = runner.runModelTurn({ + messages, + tools, + abortSignal: abortController.signal + }); + + expect(result.fullStream).toBe(mocks.fullStream); + expect(mocks.stepCountIs).toHaveBeenCalledWith(5); + expect(mocks.streamText).toHaveBeenCalledWith({ + model, + system: "system prompt", + messages, + tools, + toolChoice: "auto", + stopWhen: mocks.stopWhen, + abortSignal: abortController.signal + }); + }); + + it("honors an injected tool choice resolver", () => { + const toolChoice = { type: "tool" as const, toolName: "workspace.read_file" }; + const runner = new AiSdkModelTurnRunner({ + resolveModel: () => ({}) as never, + resolveToolChoice: () => toolChoice + }); + + runner.runModelTurn({ + messages: [], + tools: {}, + abortSignal: new AbortController().signal + }); + + expect(mocks.streamText).toHaveBeenCalledWith(expect.objectContaining({ toolChoice })); + }); + + it("uses the configured max step count", () => { + const runner = new AiSdkModelTurnRunner({ + resolveModel: () => ({}) as never, + maxSteps: 3 + }); + + runner.runModelTurn({ + messages: [], + tools: {}, + abortSignal: new AbortController().signal + }); + + expect(mocks.stepCountIs).toHaveBeenCalledWith(3); + }); +}); diff --git a/packages/agent-core/src/ai-sdk-model-turn-runner.ts b/packages/agent-core/src/ai-sdk-model-turn-runner.ts new file mode 100644 index 0000000..11d7e0d --- /dev/null +++ b/packages/agent-core/src/ai-sdk-model-turn-runner.ts @@ -0,0 +1,36 @@ +import { stepCountIs, streamText, type LanguageModel, type ToolSet } from "ai"; + +import type { IModelTurnRunner, ModelStreamPart, ModelTurnRequest, ModelTurnResult } from "./interfaces.js"; + +export type AiSdkToolChoice = "auto" | { readonly type: "tool"; readonly toolName: string }; + +export interface AiSdkModelTurnRunnerOptions { + readonly resolveModel: () => LanguageModel; + readonly resolveSystemPrompt?: () => string | undefined; + readonly resolveToolChoice?: (tools: ToolSet) => AiSdkToolChoice | undefined; + readonly maxSteps?: number; +} + +export class AiSdkModelTurnRunner implements IModelTurnRunner { + readonly #options: AiSdkModelTurnRunnerOptions; + + public constructor(options: AiSdkModelTurnRunnerOptions) { + this.#options = options; + } + + public runModelTurn(request: ModelTurnRequest): ModelTurnResult { + const result = streamText({ + model: this.#options.resolveModel(), + system: this.#options.resolveSystemPrompt?.(), + messages: request.messages, + tools: request.tools, + toolChoice: this.#options.resolveToolChoice?.(request.tools) ?? "auto", + stopWhen: stepCountIs(this.#options.maxSteps ?? 5), + abortSignal: request.abortSignal + }); + + return { + fullStream: result.fullStream as AsyncIterable + }; + } +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index bf4dd88..0ff8418 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -1,3 +1,4 @@ +export * from "./ai-sdk-model-turn-runner.js"; export * from "./agent.js"; export * from "./interfaces.js"; export * from "./prompt-content.js"; diff --git a/packages/web-demo/.env.example b/packages/web-demo/.env.example new file mode 100644 index 0000000..d4ef0ff --- /dev/null +++ b/packages/web-demo/.env.example @@ -0,0 +1,3 @@ +VITE_OPENAI_BASE_URL=https://api.openai.com/v1 +VITE_OPENAI_API_KEY= +VITE_OPENAI_MODEL=gpt-4.1-mini diff --git a/packages/web-demo/index.html b/packages/web-demo/index.html new file mode 100644 index 0000000..76ed270 --- /dev/null +++ b/packages/web-demo/index.html @@ -0,0 +1,12 @@ + + + + + + Fledgling Web Demo + + +
+ + + diff --git a/packages/web-demo/package.json b/packages/web-demo/package.json new file mode 100644 index 0000000..50d8929 --- /dev/null +++ b/packages/web-demo/package.json @@ -0,0 +1,32 @@ +{ + "name": "@fledgling/web-demo", + "version": "0.0.0", + "private": true, + "description": "Minimal browser demo for the Fledgling web agent.", + "license": "MIT", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json --noEmit && vite build", + "_phase:build": "tsc -p tsconfig.json --noEmit && vite build", + "dev": "vite --host 127.0.0.1", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.4.3", + "@ai-sdk/openai": "^2.0.0", + "@fledgling/web-agent": "workspace:*", + "@monaco-editor/react": "^4.7.0", + "@webcontainer/api": "^1.5.1", + "ai": "^5.0.0", + "monaco-editor": "^0.55.1", + "react-arborist": "^3.4.3", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "typescript": "~5.9.0", + "vite": "^7.3.5" + } +} diff --git a/packages/web-demo/src/App.tsx b/packages/web-demo/src/App.tsx new file mode 100644 index 0000000..dbe98db --- /dev/null +++ b/packages/web-demo/src/App.tsx @@ -0,0 +1,326 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type { IWorkspaceRuntime, WorkspaceEntry } from "@fledgling/web-agent"; +import { type FormEvent, type ReactElement, useEffect, useMemo, useRef, useState } from "react"; + +import { createDemoSession, createDemoWorkspace, type DemoSession } from "./acp-demo.js"; +import { DemoView, type DemoMessage, type WorkspaceBrowserModel } from "./components.js"; +import { BrowserOpenAiModelTurnRunner } from "./model-runner.js"; + +export function App(): ReactElement { + const modelRunner = useMemo(() => new BrowserOpenAiModelTurnRunner(import.meta.env), []); + const workspaceRuntimeRef = useRef(undefined); + const selectedPathRef = useRef(undefined); + const [workspaceRuntime, setWorkspaceRuntime] = useState(); + const [session, setSession] = useState(); + const [messages, setMessages] = useState([]); + const [assistantDraft, setAssistantDraft] = useState(""); + const [prompt, setPrompt] = useState(""); + const [status, setStatus] = useState("Starting"); + const [pending, setPending] = useState(false); + const [browser, setBrowser] = useState({ + entries: [], + selectedPath: undefined, + preview: "", + error: undefined + }); + + useEffect(() => { + selectedPathRef.current = browser.selectedPath; + }, [browser.selectedPath]); + + useEffect(() => { + let disposed = false; + async function boot(): Promise { + try { + setStatus("Starting workspace"); + const runtime = await createDemoWorkspace(); + if (disposed) { + return; + } + + setWorkspaceRuntime(runtime); + workspaceRuntimeRef.current = runtime; + await refreshFileBrowser(runtime); + await startSession(runtime); + } catch (error) { + setMessages((current) => [...current, { role: "error", text: errorText(error) }]); + setStatus("Startup error"); + } + } + + void boot(); + return () => { + disposed = true; + }; + }, []); + + async function startSession(runtime: IWorkspaceRuntime): Promise { + setStatus("Starting"); + setSession(await createDemoSession(modelRunner, runtime, handleSessionUpdate)); + setStatus("Ready"); + } + + function handleSessionUpdate(params: acp.SessionNotification): void { + const { update } = params; + + if (update.sessionUpdate === "agent_message_chunk") { + const text = contentBlockText(update.content); + if (text !== undefined) { + setAssistantDraft((current) => current + text); + } + return; + } + + if (update.sessionUpdate === "tool_call") { + setMessages((current) => [ + ...current, + { + role: "tool", + toolCallId: update.toolCallId, + toolName: update.title, + status: update.status ?? "pending", + args: formatJson(update.rawInput) + } + ]); + return; + } + + if (update.sessionUpdate === "tool_call_update") { + setMessages((current) => updateToolMessage(current, update)); + if (update.status === "completed" && workspaceRuntimeRef.current) { + void refreshFileBrowser(workspaceRuntimeRef.current, selectedPathRef.current); + } + } + } + + async function sendPrompt(event: FormEvent): Promise { + event.preventDefault(); + const text = prompt.trim(); + if (!text || pending || !session) { + return; + } + + setMessages((current) => [...current, { role: "user", text }]); + setAssistantDraft(""); + setPrompt(""); + setPending(true); + setStatus("Running"); + + try { + const response = await session.connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text }] + }); + setAssistantDraft((draft) => { + if (draft) { + setMessages((current) => [...current, { role: "assistant", text: draft }]); + } + + return ""; + }); + setStatus(response.stopReason); + } catch (error) { + setMessages((current) => [...current, { role: "error", text: errorText(error) }]); + setStatus("Error"); + } finally { + setPending(false); + } + } + + async function cancelPrompt(): Promise { + if (session && pending) { + await session.connection.cancel({ sessionId: session.sessionId }); + } + } + + async function newSession(): Promise { + if (!workspaceRuntime) { + return; + } + + if (session) { + await session.connection.cancel({ sessionId: session.sessionId }); + } + + setMessages([]); + setAssistantDraft(""); + await startSession(workspaceRuntime); + } + + async function refreshFileBrowser( + runtime = workspaceRuntimeRef.current ?? workspaceRuntime, + selectedPath = selectedPathRef.current + ): Promise { + if (!runtime) { + return; + } + + try { + const entries = await collectWorkspaceEntries(runtime, "."); + let preview = ""; + let nextSelectedPath = selectedPath; + if (nextSelectedPath) { + preview = await runtime.readFile(nextSelectedPath); + } else { + const firstFile = entries.find((entry) => entry.type === "file"); + nextSelectedPath = firstFile?.path; + preview = firstFile ? await runtime.readFile(firstFile.path) : ""; + } + + setBrowser({ + entries, + selectedPath: nextSelectedPath, + preview, + error: undefined + }); + selectedPathRef.current = nextSelectedPath; + } catch (error) { + setBrowser((current) => ({ ...current, error: errorText(error) })); + } + } + + async function openWorkspaceFile(path: string): Promise { + if (!workspaceRuntime) { + return; + } + + try { + setBrowser((current) => ({ + ...current, + selectedPath: path, + preview: "", + error: undefined + })); + const preview = await workspaceRuntime.readFile(path); + setBrowser((current) => ({ + ...current, + selectedPath: path, + preview, + error: undefined + })); + selectedPathRef.current = path; + } catch (error) { + setBrowser((current) => ({ + ...current, + selectedPath: path, + error: errorText(error) + })); + } + } + + return ( + void cancelPrompt()} + onOpenFile={(path) => void openWorkspaceFile(path)} + onRefreshFiles={() => void refreshFileBrowser()} + onNewSession={() => void newSession()} + onPromptChange={setPrompt} + onSubmit={(event) => void sendPrompt(event)} + /> + ); +} + +async function collectWorkspaceEntries( + runtime: IWorkspaceRuntime, + path: string +): Promise { + const entries = await runtime.listDirectory(path); + const result: WorkspaceEntry[] = []; + + for (const entry of entries.sort((left, right) => compareEntries(left, right))) { + result.push(entry); + if (entry.type === "directory") { + result.push(...(await collectWorkspaceEntries(runtime, entry.path))); + } + } + + return result; +} + +function compareEntries( + left: WorkspaceBrowserModel["entries"][number], + right: WorkspaceBrowserModel["entries"][number] +): number { + if (left.type !== right.type) { + return left.type === "directory" ? -1 : 1; + } + + return left.name.localeCompare(right.name); +} + +function updateToolMessage( + messages: readonly DemoMessage[], + update: ToolCallUpdate +): DemoMessage[] { + const next = [...messages]; + const index = next.findIndex((message) => message.role === "tool" && message.toolCallId === update.toolCallId); + const text = extractToolUpdateText(update); + if (index === -1) { + next.push({ + role: "tool", + toolCallId: update.toolCallId, + toolName: update.toolCallId, + status: update.status ?? "pending", + result: text, + error: update.status === "failed" ? text : undefined + }); + return next; + } + + const current = next[index]; + if (current.role !== "tool") { + return next; + } + + next[index] = { + ...current, + status: update.status ?? "pending", + result: update.status === "failed" ? current.result : text, + error: update.status === "failed" ? text : current.error + }; + return next; +} + +function extractToolUpdateText(update: ToolCallUpdate): string | undefined { + const parts = update.content ?? []; + const text = parts + .map((part) => (part.type === "content" && part.content.type === "text" ? part.content.text : undefined)) + .filter((part): part is string => part !== undefined) + .join("\n"); + return text.length > 0 ? text : undefined; +} + +function formatJson(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + + return JSON.stringify(value, undefined, 2); +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function contentBlockText(content: unknown): string | undefined { + if (typeof content !== "object" || content === null) { + return undefined; + } + + const maybeText = content as { readonly type?: unknown; readonly text?: unknown }; + return maybeText.type === "text" && typeof maybeText.text === "string" ? maybeText.text : undefined; +} + +type ToolCallUpdate = Extract; diff --git a/packages/web-demo/src/acp-demo.ts b/packages/web-demo/src/acp-demo.ts new file mode 100644 index 0000000..6f9f1ea --- /dev/null +++ b/packages/web-demo/src/acp-demo.ts @@ -0,0 +1,117 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type { IModelTurnRunner, IWorkspaceRuntime } from "@fledgling/web-agent"; + +export interface DemoSession { + readonly connection: acp.ClientSideConnection; + readonly protocolVersion: string; + readonly sessionId: string; +} + +class DemoClient { + readonly #onUpdate: (params: acp.SessionNotification) => void; + + public constructor(onUpdate: (params: acp.SessionNotification) => void) { + this.#onUpdate = onUpdate; + } + + public async sessionUpdate(params: acp.SessionNotification): Promise { + this.#onUpdate(params); + } + + public async requestPermission(): Promise { + return { outcome: { outcome: "cancelled" } }; + } + + public async writeTextFile(): Promise { + return {}; + } + + public async readTextFile(): Promise { + return { content: "" }; + } +} + +export async function createDemoSession( + modelTurnRunner: IModelTurnRunner, + workspaceRuntime: IWorkspaceRuntime, + onUpdate: (params: acp.SessionNotification) => void +): Promise { + const { createWebAgent } = await import("@fledgling/web-agent"); + const streams = createConnectionStreams(); + const client = new DemoClient(onUpdate); + + new acp.AgentSideConnection( + (connection) => + createWebAgent(connection, { + modelTurnRunner, + workspaceRuntime + }), + streams.agent + ); + + const connection = new acp.ClientSideConnection(() => client, streams.client); + const init = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {} + }); + const session = await connection.newSession({ + cwd: "/browser", + mcpServers: [] + }); + + return { + connection, + protocolVersion: String(init.protocolVersion), + sessionId: session.sessionId + }; +} + +export async function createDemoWorkspace(): Promise { + const [{ WebContainer }, { WebContainerWorkspaceRuntime }] = await Promise.all([ + import("@webcontainer/api"), + import("@fledgling/web-agent") + ]); + const container = await WebContainer.boot(); + await container.mount({ + "README.md": { + file: { + contents: + "# Fledgling browser workspace\n\nThis workspace is backed by WebContainer. Try asking the agent to list files, write notes.txt, or run pwd.\n" + } + }, + "package.json": { + file: { + contents: JSON.stringify( + { + name: "fledgling-browser-workspace", + private: true, + type: "module", + scripts: { + hello: "echo hello from WebContainer" + } + }, + undefined, + 2 + ) + } + } + }); + + return new WebContainerWorkspaceRuntime(container); +} + +function createConnectionStreams(): { readonly client: acp.Stream; readonly agent: acp.Stream } { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + + return { + client: { + writable: clientToAgent.writable, + readable: agentToClient.readable + } as acp.Stream, + agent: { + writable: agentToClient.writable, + readable: clientToAgent.readable + } as acp.Stream + }; +} diff --git a/packages/web-demo/src/components.tsx b/packages/web-demo/src/components.tsx new file mode 100644 index 0000000..8b5e04b --- /dev/null +++ b/packages/web-demo/src/components.tsx @@ -0,0 +1,360 @@ +import type { WorkspaceEntry } from "@fledgling/web-agent"; +import Editor from "@monaco-editor/react"; +import type { FormEvent, ReactElement } from "react"; +import { Tree, type NodeApi, type NodeRendererProps } from "react-arborist"; + +export type DemoMessage = + | TextDemoMessage + | { + readonly role: "tool"; + readonly toolCallId: string; + readonly toolName: string; + readonly status: string; + readonly args?: string; + readonly result?: string; + readonly error?: string; + }; + +export interface TextDemoMessage { + readonly role: "user" | "assistant" | "error"; + readonly text: string; +} + +export interface RuntimeDetails { + readonly endpoint: string; + readonly model: string; + readonly protocolVersion: string | undefined; + readonly sessionId: string | undefined; +} + +export interface WorkspaceBrowserModel { + readonly entries: readonly WorkspaceEntry[]; + readonly selectedPath: string | undefined; + readonly preview: string; + readonly error: string | undefined; +} + +export interface DemoViewProps { + readonly assistantDraft: string; + readonly browser: WorkspaceBrowserModel; + readonly messages: readonly DemoMessage[]; + readonly pending: boolean; + readonly prompt: string; + readonly runtime: RuntimeDetails; + readonly status: string; + readonly onCancel: () => void; + readonly onOpenFile: (path: string) => void; + readonly onRefreshFiles: () => void; + readonly onNewSession: () => void; + readonly onPromptChange: (prompt: string) => void; + readonly onSubmit: (event: FormEvent) => void; +} + +export function DemoView(props: DemoViewProps): ReactElement { + return ( +
+
+
+ + +
+ +
+ ); +} + +export function Header({ model, status }: { readonly model: string; readonly status: string }): ReactElement { + return ( +
+
+

Fledgling

+

{status}

+
+
{model}
+
+ ); +} + +export function Transcript({ + assistantDraft, + messages +}: { + readonly assistantDraft: string; + readonly messages: readonly DemoMessage[]; +}): ReactElement { + return ( +
+ {messages.map((message, index) => ( + + ))} + {assistantDraft ? : undefined} +
+ ); +} + +export function MessageView({ message }: { readonly message: DemoMessage }): ReactElement { + if (message.role === "tool") { + return ; + } + + return ( +
+
{message.role}
+
{message.text}
+
+ ); +} + +export function ToolMessageView({ + message +}: { + readonly message: Extract; +}): ReactElement { + return ( +
+
+ tool ยท {message.status} + {message.toolName} +
+
+ {message.args ? ( + <> +
args
+
{message.args}
+ + ) : undefined} + {message.result ? ( + <> +
result
+
{message.result}
+ + ) : undefined} + {message.error ? ( + <> +
error
+
{message.error}
+ + ) : undefined} +
+
+ ); +} + +export function Composer({ + pending, + prompt, + ready, + onCancel, + onPromptChange, + onSubmit +}: { + readonly pending: boolean; + readonly prompt: string; + readonly ready: boolean; + readonly onCancel: () => void; + readonly onPromptChange: (prompt: string) => void; + readonly onSubmit: (event: FormEvent) => void; +}): ReactElement { + return ( +
+