From 036d5412ccf92cec209d0cb89f643389dcba2cf1 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 18 Dec 2025 19:09:14 +0100 Subject: [PATCH] refactor!: restructure repo for v1.x and add v2.x (#2794) - Move original CopilotKit/CopilotKit directory to src/v1.x - Introduce new v2 interfaces in src/v2.x - Align CI paths and preview environments to new layout Signed-off-by: Tyler Slaton Co-authored-by: Ran Shemtov Co-authored-by: Brandon McConnell Co-authored-by: Mike Ryan --- .cursor/rules/agent-development.mdc | 2 +- .cursor/rules/copilotkit-architecture.mdc | 20 +- .cursor/rules/development-workflow.mdc | 24 +- .cursor/rules/frontend-development.mdc | 12 +- .cursor/rules/quick-reference.mdc | 20 +- .cursor/rules/suggestions-development.mdc | 18 +- .github/workflows/ci.yml | 8 +- .github/workflows/custom-prerelease.yml | 2 +- .github/workflows/danger.yml | 2 +- .github/workflows/dojo-e2e.yml | 6 +- .github/workflows/preview-envs-deploy.yml | 10 +- .github/workflows/quality.yml | 10 +- .github/workflows/release.yml | 4 +- .husky/pre-commit | 2 +- CONTRIBUTING.md | 2 +- CopilotKit/.vscode/launch.json | 51 - CopilotKit/examples/next-openai/Dockerfile | 28 - .../react-core/src/lib/copilot-task.ts | 201 - CopilotKit/packages/react-core/tsup.config.ts | 16 - .../copilot-textarea/copilot-textarea.tsx | 210 - CopilotKit/packages/react-ui/tsup.config.ts | 11 - CopilotKit/pnpm-lock.yaml | 26806 --------------- CopilotKit/scripts/docs/lib/files.ts | 205 - CopilotKit/scripts/docs/lib/reference-doc.ts | 220 - dangerfile.js | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../guides/custom-ai-assistant-behavior.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../contributing/code-contributions/index.mdx | 2 +- .../docs/reference/classes/CopilotRuntime.mdx | 2 +- .../docs/reference/classes/CopilotTask.mdx | 4 +- .../classes/llm-adapters/AnthropicAdapter.mdx | 2 +- .../GoogleGenerativeAIAdapter.mdx | 2 +- .../classes/llm-adapters/GroqAdapter.mdx | 2 +- .../classes/llm-adapters/LangChainAdapter.mdx | 2 +- .../classes/llm-adapters/OpenAIAdapter.mdx | 2 +- .../llm-adapters/OpenAIAssistantAdapter.mdx | 2 +- .../docs/reference/components/CopilotKit.mdx | 2 +- .../reference/components/CopilotTextarea.mdx | 8 +- .../reference/components/chat/CopilotChat.mdx | 2 +- .../components/chat/CopilotPopup.mdx | 2 +- .../components/chat/CopilotSidebar.mdx | 2 +- .../docs/reference/hooks/useCoAgent.mdx | 2 +- .../reference/hooks/useCoAgentStateRender.mdx | 2 +- .../useCopilotAdditionalInstructions.mdx | 2 +- .../docs/reference/hooks/useCopilotChat.mdx | 2 +- .../hooks/useCopilotChatHeadless_c.mdx | 2 +- .../hooks/useCopilotChatSuggestions.mdx | 2 +- .../reference/hooks/useCopilotReadable.mdx | 2 +- .../docs/reference/sdk/js/LangGraph.mdx | 2 +- .../docs/reference/sdk/python/CrewAI.mdx | 2 +- .../docs/reference/sdk/python/CrewAIAgent.mdx | 2 +- .../docs/reference/sdk/python/LangGraph.mdx | 2 +- .../reference/sdk/python/LangGraphAgent.mdx | 2 +- .../reference/sdk/python/RemoteEndpoints.mdx | 2 +- .../code-contributions/development.mdx | 2 +- .../customize-built-in-ui-components.mdx | 2 +- renovate.json | 2 +- {CopilotKit => src/v1.x}/.changeset/README.md | 0 .../v1.x}/.changeset/config.json | 0 {CopilotKit => src/v1.x}/.changeset/pre.json | 0 {CopilotKit => src/v1.x}/.eslintrc.js | 0 {CopilotKit => src/v1.x}/.gitignore | 0 {CopilotKit => src/v1.x}/.npmrc | 0 {CopilotKit => src/v1.x}/.prettierignore | 0 {CopilotKit => src/v1.x}/.prettierrc | 0 {CopilotKit => src/v1.x}/.vscode/cspell.json | 0 src/v1.x/.vscode/launch.json | 51 + .../v1.x}/.vscode/settings.json | 0 .../v1.x}/CopilotKit.code-workspace | 0 {CopilotKit => src/v1.x}/changelog.txt | 0 .../v1.x}/demo-suggestions-improvement.md | 0 .../examples/next-openai/.env.local.example | 0 .../v1.x}/examples/next-openai/.eslintrc.js | 0 .../v1.x}/examples/next-openai/.gitignore | 0 .../v1.x}/examples/next-openai/CHANGELOG.md | 0 src/v1.x/examples/next-openai/Dockerfile | 28 + .../v1.x}/examples/next-openai/README.md | 0 .../v1.x}/examples/next-openai/next-env.d.ts | 0 .../v1.x}/examples/next-openai/next.config.js | 0 .../v1.x}/examples/next-openai/package.json | 0 .../examples/next-openai/postcss.config.js | 0 .../next-openai/public/images/GoogleDocs.svg | 0 .../app/api/copilotkit/[[...slug]]/route.ts | 0 .../app/api/copilotkit/presentation/route.ts | 0 .../src/app/api/copilotkit/travel/route.ts | 0 .../src/app/api/copilotkit/travel/tavily.ts | 0 .../src/app/api/transcribe/route.ts | 0 .../next-openai/src/app/api/tts/route.ts | 0 .../src/app/components/ActionButton.tsx | 0 .../src/app/components/Presentation.tsx | 0 .../app/components/ServiceAdapterSelector.tsx | 0 .../next-openai/src/app/components/Slide.tsx | 0 .../src/app/components/destination-row.tsx | 0 .../src/app/components/destination-table.tsx | 0 .../src/app/components/vacation-list.tsx | 0 .../src/app/components/vacation-notes.tsx | 0 .../next-openai/src/app/headless/page.tsx | 0 .../examples/next-openai/src/app/layout.tsx | 0 .../next-openai/src/app/multi/page.tsx | 0 .../next-openai/src/app/multi/styles.css | 0 .../examples/next-openai/src/app/page.tsx | 0 .../presentation/actions/useAppendSlide.tsx | 0 .../presentation/actions/useUpdateSlide.tsx | 0 .../components/buttons/ActionButton.tsx | 0 .../components/buttons/AddSlideButton.tsx | 0 .../components/buttons/DeleteSlideButton.tsx | 0 .../buttons/GenerateSlideButton.tsx | 0 .../components/buttons/NavButton.tsx | 0 .../buttons/PerformResearchSwitch.tsx | 0 .../buttons/SpeakCurrentSlideButton.tsx | 0 .../presentation/components/main/Header.tsx | 0 .../components/main/Presentation.tsx | 0 .../presentation/components/main/Slide.tsx | 0 .../components/misc/SlideNumberIndicator.tsx | 0 .../components/misc/SlidePreview.tsx | 0 .../next-openai/src/app/presentation/page.tsx | 0 .../src/app/presentation/styles.css | 0 .../next-openai/src/app/presentation/types.ts | 0 .../app/presentation/utils/globalAudio.tsx | 0 .../next-openai/src/app/textarea/page.tsx | 0 .../next-openai/src/app/travel/page.tsx | 0 .../next-openai/src/app/travel/styles.css | 0 .../examples/next-openai/src/app/utils.ts | 0 .../src/lib/dynamic-service-adapter.ts | 0 .../next-openai/src/styles/globals.css | 0 .../examples/next-openai/tailwind.config.js | 0 .../v1.x}/examples/next-openai/tsconfig.json | 0 .../examples/next-pages-router/.eslintrc.js | 0 .../examples/next-pages-router/.gitignore | 0 .../examples/next-pages-router/CHANGELOG.md | 0 .../examples/next-pages-router/README.md | 0 .../next-pages-router/next.config.mjs | 0 .../examples/next-pages-router/package.json | 0 .../examples/next-pages-router/pages/_app.tsx | 0 .../next-pages-router/pages/_document.tsx | 0 .../next-pages-router/pages/api/copilotkit.ts | 0 .../next-pages-router/pages/api/hello.ts | 0 .../next-pages-router/pages/index.tsx | 0 .../next-pages-router/postcss.config.mjs | 0 .../next-pages-router/public/favicon.ico | Bin .../next-pages-router/public/next.svg | 0 .../next-pages-router/public/vercel.svg | 0 .../next-pages-router/styles/globals.css | 0 .../next-pages-router/tailwind.config.ts | 0 .../examples/next-pages-router/tsconfig.json | 0 .../v1.x}/examples/node-express/.eslintrc.js | 0 .../v1.x}/examples/node-express/CHANGELOG.md | 0 .../v1.x}/examples/node-express/package.json | 0 .../v1.x}/examples/node-express/src/index.ts | 0 .../v1.x}/examples/node-express/tsconfig.json | 0 .../v1.x}/examples/node-http/.eslintrc.js | 0 .../v1.x}/examples/node-http/CHANGELOG.md | 0 .../v1.x}/examples/node-http/package.json | 0 .../v1.x}/examples/node-http/src/index.ts | 0 .../v1.x}/examples/node-http/tsconfig.json | 0 {CopilotKit => src/v1.x}/package.json | 0 .../v1.x}/packages/a2ui-renderer/CHANGELOG.md | 0 .../packages/a2ui-renderer/eslint.config.js | 0 .../v1.x}/packages/a2ui-renderer/package.json | 0 .../a2ui-renderer/src/A2UIMessageRenderer.tsx | 0 .../packages/a2ui-renderer/src/A2UIViewer.tsx | 0 .../v1.x}/packages/a2ui-renderer/src/index.ts | 0 .../a2ui-renderer/src/styles/components.ts | 0 .../a2ui-renderer/src/styles/global.ts | 0 .../a2ui-renderer/src/theme/viewer-theme.ts | 0 .../a2ui-renderer/src/themed-surface.ts | 0 .../packages/a2ui-renderer/tsconfig.json | 0 .../v1.x}/packages/react-core/CHANGELOG.md | 0 .../v1.x}/packages/react-core/README.md | 0 .../v1.x}/packages/react-core/jest.config.js | 0 .../v1.x}/packages/react-core/package.json | 0 .../src/components/CopilotListeners.tsx | 0 .../__tests__/copilotkit-error.test.tsx | 0 .../copilot-provider/copilot-messages.tsx | 0 .../copilot-provider/copilotkit-props.tsx | 0 .../copilot-provider/copilotkit.tsx | 0 .../src/components/copilot-provider/index.ts | 0 .../dev-console/console-trigger.tsx | 0 .../dev-console/developer-console-modal.tsx | 0 .../src/components/dev-console/icons.tsx | 0 .../error-boundary/error-boundary.tsx | 0 .../components/error-boundary/error-utils.tsx | 0 .../react-core/src/components/index.ts | 0 .../toast/exclamation-mark-icon.tsx | 0 .../src/components/toast/toast-provider.tsx | 0 .../src/components/usage-banner.tsx | 0 .../context/coagent-state-renders-context.tsx | 0 .../src/context/copilot-context.tsx | 0 .../src/context/copilot-messages-context.tsx | 0 .../packages/react-core/src/context/index.ts | 0 .../src/context/threads-context.tsx | 0 .../__tests__/use-coagent-config.test.ts | 0 .../packages/react-core/src/hooks/index.ts | 0 .../src/hooks/use-agent-nodename.ts | 0 .../hooks/use-coagent-state-render-bridge.tsx | 0 .../src/hooks/use-coagent-state-render.ts | 0 .../react-core/src/hooks/use-coagent.ts | 0 .../hooks/use-configure-chat-suggestions.tsx | 86 + .../src/hooks/use-copilot-action.ts | 0 .../use-copilot-additional-instructions.ts | 0 .../hooks/use-copilot-authenticated-action.ts | 0 .../src/hooks/use-copilot-chat-headless_c.ts | 0 .../hooks/use-copilot-chat-suggestions.tsx | 0 .../react-core/src/hooks/use-copilot-chat.ts | 0 .../src/hooks/use-copilot-chat_internal.ts | 0 .../src/hooks/use-copilot-readable.ts | 0 .../src/hooks/use-copilot-runtime-client.ts | 0 .../react-core/src/hooks/use-default-tool.ts | 0 .../src/hooks/use-flat-category-store.ts | 0 .../react-core/src/hooks/use-frontend-tool.ts | 0 .../src/hooks/use-human-in-the-loop.ts | 0 .../hooks/use-langgraph-interrupt-render.ts | 0 .../src/hooks/use-langgraph-interrupt.ts | 0 .../src/hooks/use-lazy-tool-renderer.tsx | 0 .../use-make-copilot-document-readable.ts | 0 .../src/hooks/use-render-tool-call.ts | 0 .../packages/react-core/src/hooks/use-tree.ts | 0 .../v1.x}/packages/react-core/src/index.tsx | 0 .../react-core/src/lib/copilot-task.ts | 201 + .../packages/react-core/src/lib/index.ts | 0 .../react-core/src/lib/status-checker.ts | 0 .../packages/react-core/src/setupTests.ts | 0 .../types/chat-suggestion-configuration.ts | 0 .../react-core/src/types/coagent-action.ts | 0 .../react-core/src/types/coagent-state.ts | 0 .../packages/react-core/src/types/crew.ts | 0 .../react-core/src/types/document-pointer.ts | 0 .../react-core/src/types/frontend-action.ts | 0 .../packages/react-core/src/types/index.ts | 0 .../react-core/src/types/interrupt-action.ts | 0 .../react-core/src/types/system-message.ts | 0 .../react-core/src/utils/dev-console.ts | 0 .../packages/react-core/src/utils/index.ts | 0 .../src/utils/suggestions-constants.ts | 0 .../react-core/src/utils/utils.test.ts | 0 .../packages/react-core/src/utils/utils.ts | 0 .../packages/react-core/src/v2/index.css | 0 .../v1.x}/packages/react-core/src/v2/index.ts | 0 .../v1.x}/packages/react-core/tsconfig.json | 0 src/v1.x/packages/react-core/tsup.config.ts | 16 + .../v1.x}/packages/react-core/typedoc.json | 0 .../packages/react-textarea/CHANGELOG.md | 0 .../v1.x}/packages/react-textarea/README.md | 0 .../packages/react-textarea/jest.config.js | 0 .../packages/react-textarea/package.json | 0 .../packages/react-textarea/postcss.config.js | 0 .../base-copilot-textarea.css | 0 .../base-copilot-textarea.tsx | 0 .../base-copilot-textarea/render-element.tsx | 0 .../render-placeholder.tsx | 0 ...ck-cursor-moved-since-last-text-change.tsx | 0 .../use-add-branding-css.tsx | 0 .../copilot-textarea/copilot-textarea.tsx | 210 + .../hovering-editor-provider.tsx | 0 .../hovering-toolbar-components.tsx | 0 .../hovering-toolbar/hovering-toolbar.tsx | 0 .../hovering-insertion-prompt-box-core.tsx | 0 .../hovering-insertion-prompt-box.tsx | 0 .../included-files-preview.tsx | 0 .../text-insertion-prompt-box/index.ts | 0 .../react-textarea/src/components/index.ts | 0 .../components/manual-ui/chip-with-icon.tsx | 0 .../source-search-box/source-search-box.tsx | 0 .../src/components/ui/button.tsx | 0 .../src/components/ui/command.tsx | 0 .../src/components/ui/dialog.tsx | 0 .../src/components/ui/label.tsx | 0 .../react-textarea/src/context/index.ts | 0 .../use-autosuggestions.ts | 0 .../use-copilot-textarea-editor.tsx | 0 .../use-populate-copilot-textarea-ref.ts | 0 .../react-textarea/src/hooks/index.ts | 0 ...make-standard-autosuggestions-function.tsx | 0 .../use-make-standard-insertion-function.tsx | 0 .../src/hooks/misc/use-autosize-textarea.tsx | 0 .../packages/react-textarea/src/index.tsx | 0 .../react-textarea/src/lib/debouncer.ts | 0 .../react-textarea/src/lib/editor-to-text.ts | 0 .../src/lib/get-text-around-cursor.ts | 0 .../packages/react-textarea/src/lib/retry.tsx | 0 .../lib/slatejs-edits/add-autocompletions.ts | 0 .../slatejs-edits/clear-autocompletions.ts | 0 .../src/lib/slatejs-edits/replace-text.ts | 0 .../lib/slatejs-edits/with-partial-history.ts | 0 .../src/lib/stream-promise-flatten.ts | 0 .../react-textarea/src/lib/utils.test.ts | 0 .../packages/react-textarea/src/lib/utils.ts | 0 .../packages/react-textarea/src/styles.css | 0 .../autosuggestions-config-user-specified.tsx | 0 .../autosuggestions-config.tsx | 0 .../editing-api-config.tsx | 0 .../src/types/autosuggestions-config/index.ts | 0 .../insertions-api-config.tsx | 0 .../subtypes/make-system-prompt.ts | 0 .../suggestions-api-config.tsx | 0 .../src/types/base/autosuggestion-state.ts | 0 .../base/autosuggestions-bare-function.ts | 0 .../base/base-autosuggestions-config.tsx | 0 .../base/base-copilot-textarea-props.tsx | 0 .../src/types/base/custom-editor.tsx | 0 .../types/base/editor-autocomplete-state.ts | 0 .../react-textarea/src/types/base/index.ts | 0 .../types/html-copilot-textarea-element.ts | 0 .../react-textarea/src/types/index.ts | 0 .../react-textarea/tailwind.config.js | 0 .../packages/react-textarea/tsconfig.json | 0 .../packages/react-textarea/tsup.config.ts | 0 .../packages/react-textarea/typedoc.json | 0 .../v1.x}/packages/react-ui/CHANGELOG.md | 0 .../v1.x}/packages/react-ui/README.md | 0 .../v1.x}/packages/react-ui/jest.config.js | 0 .../v1.x}/packages/react-ui/package.json | 0 .../v1.x}/packages/react-ui/postcss.config.js | 0 .../react-ui/src/components/chat/Button.tsx | 0 .../react-ui/src/components/chat/Chat.tsx | 0 .../src/components/chat/ChatContext.tsx | 0 .../src/components/chat/CodeBlock.tsx | 0 .../react-ui/src/components/chat/Header.tsx | 0 .../react-ui/src/components/chat/Icons.tsx | 0 .../src/components/chat/ImageUploadQueue.tsx | 0 .../react-ui/src/components/chat/Input.tsx | 0 .../react-ui/src/components/chat/Markdown.tsx | 0 .../react-ui/src/components/chat/Messages.tsx | 0 .../react-ui/src/components/chat/Modal.tsx | 0 .../react-ui/src/components/chat/Popup.tsx | 0 .../src/components/chat/PoweredByTag.tsx | 0 .../react-ui/src/components/chat/Sidebar.tsx | 0 .../src/components/chat/Suggestion.tsx | 0 .../src/components/chat/Suggestions.tsx | 0 .../react-ui/src/components/chat/Textarea.tsx | 0 .../react-ui/src/components/chat/Window.tsx | 0 .../react-ui/src/components/chat/index.tsx | 0 .../chat/messages/AssistantMessage.tsx | 0 .../components/chat/messages/ErrorMessage.tsx | 0 .../chat/messages/ImageRenderer.tsx | 0 .../chat/messages/LegacyRenderMessage.tsx | 0 .../chat/messages/RenderMessage.tsx | 0 .../components/chat/messages/UserMessage.tsx | 0 .../react-ui/src/components/chat/props.ts | 0 .../src/components/dev-console/console.tsx | 0 .../src/components/dev-console/icons.tsx | 0 .../src/components/dev-console/index.tsx | 0 .../src/components/dev-console/types.ts | 0 .../src/components/dev-console/utils.ts | 0 .../src/components/help-modal/icons.tsx | 0 .../src/components/help-modal/index.tsx | 0 .../src/components/help-modal/modal.tsx | 0 .../packages/react-ui/src/components/index.ts | 0 .../packages/react-ui/src/context/index.ts | 0 .../packages/react-ui/src/css/animations.css | 0 .../packages/react-ui/src/css/button.css | 0 .../packages/react-ui/src/css/colors.css | 0 .../packages/react-ui/src/css/console.css | 0 .../v1.x}/packages/react-ui/src/css/crew.css | 0 .../packages/react-ui/src/css/header.css | 0 .../v1.x}/packages/react-ui/src/css/input.css | 0 .../packages/react-ui/src/css/markdown.css | 0 .../packages/react-ui/src/css/messages.css | 0 .../v1.x}/packages/react-ui/src/css/panel.css | 0 .../v1.x}/packages/react-ui/src/css/popup.css | 0 .../packages/react-ui/src/css/sidebar.css | 0 .../packages/react-ui/src/css/suggestions.css | 0 .../packages/react-ui/src/css/window.css | 0 .../packages/react-ui/src/hooks/index.ts | 0 .../hooks/use-copilot-chat-suggestions.tsx | 0 .../src/hooks/use-copy-to-clipboard.tsx | 0 .../react-ui/src/hooks/use-dark-mode.ts | 0 .../react-ui/src/hooks/use-push-to-talk.tsx | 0 .../v1.x}/packages/react-ui/src/index.tsx | 0 .../packages/react-ui/src/lib/utils.test.ts | 0 .../v1.x}/packages/react-ui/src/lib/utils.ts | 0 .../v1.x}/packages/react-ui/src/styles.css | 0 .../v1.x}/packages/react-ui/src/types/css.ts | 0 .../packages/react-ui/src/types/index.ts | 0 .../react-ui/src/types/suggestions.ts | 0 .../v1.x}/packages/react-ui/src/v2/index.ts | 0 .../v1.x}/packages/react-ui/src/v2/styles.css | 0 .../packages/react-ui/tailwind.config.js | 0 .../v1.x}/packages/react-ui/tsconfig.json | 0 src/v1.x/packages/react-ui/tsup.config.ts | 11 + .../v1.x}/packages/react-ui/typedoc.json | 0 .../packages/runtime-client-gql/CHANGELOG.md | 0 .../packages/runtime-client-gql/README.md | 0 .../packages/runtime-client-gql/codegen.ts | 0 .../runtime-client-gql/jest.config.js | 0 .../packages/runtime-client-gql/package.json | 0 .../src/client/CopilotRuntimeClient.ts | 0 .../src/client/conversion.ts | 0 .../runtime-client-gql/src/client/index.ts | 0 .../runtime-client-gql/src/client/types.ts | 0 .../src/graphql/definitions/mutations.ts | 0 .../src/graphql/definitions/queries.ts | 0 .../packages/runtime-client-gql/src/index.ts | 0 .../message-conversion/agui-to-gql.test.ts | 0 .../src/message-conversion/agui-to-gql.ts | 0 .../message-conversion/gql-to-agui.test.ts | 0 .../src/message-conversion/gql-to-agui.ts | 0 .../src/message-conversion/index.ts | 0 .../roundtrip-conversion.test.ts | 0 .../packages/runtime-client-gql/tsconfig.json | 0 .../runtime-client-gql/tsup.config.ts | 0 .../packages/runtime-client-gql/typedoc.json | 0 .../v1.x}/packages/runtime/.eslintrc.js | 0 .../v1.x}/packages/runtime/CHANGELOG.md | 0 .../v1.x}/packages/runtime/README.md | 0 .../v1.x}/packages/runtime/jest.config.js | 0 .../v1.x}/packages/runtime/package.json | 0 .../v1.x}/packages/runtime/pnpm-lock.yaml | 0 .../runtime/scripts/generate-gql-schema.ts | 0 .../src/agents/langgraph/event-source.ts | 0 .../runtime/src/agents/langgraph/events.ts | 0 .../src/graphql/inputs/action.input.ts | 0 .../src/graphql/inputs/agent-session.input.ts | 0 .../src/graphql/inputs/agent-state.input.ts | 0 .../graphql/inputs/cloud-guardrails.input.ts | 0 .../runtime/src/graphql/inputs/cloud.input.ts | 0 .../graphql/inputs/context-property.input.ts | 0 .../graphql/inputs/copilot-context.input.ts | 0 .../graphql/inputs/custom-property.input.ts | 0 .../src/graphql/inputs/extensions.input.ts | 0 .../inputs/forwarded-parameters.input.ts | 0 .../src/graphql/inputs/frontend.input.ts | 0 .../inputs/generate-copilot-response.input.ts | 0 .../graphql/inputs/load-agent-state.input.ts | 0 .../src/graphql/inputs/message.input.ts | 0 .../src/graphql/inputs/meta-event.input.ts | 0 .../message-conversion/agui-to-gql.test.ts | 0 .../graphql/message-conversion/agui-to-gql.ts | 0 .../message-conversion/gql-to-agui.test.ts | 0 .../graphql/message-conversion/gql-to-agui.ts | 0 .../src/graphql/message-conversion/index.ts | 0 .../roundtrip-conversion.test.ts | 0 .../src/graphql/resolvers/copilot.resolver.ts | 0 .../src/graphql/resolvers/state.resolver.ts | 0 .../src/graphql/types/agents-response.type.ts | 0 .../runtime/src/graphql/types/base/index.ts | 0 .../src/graphql/types/converted/index.ts | 0 .../graphql/types/copilot-response.type.ts | 0 .../runtime/src/graphql/types/enums.ts | 0 .../graphql/types/extensions-response.type.ts | 0 .../graphql/types/guardrails-result.type.ts | 0 .../types/load-agent-state-response.type.ts | 0 .../src/graphql/types/message-status.type.ts | 0 .../src/graphql/types/meta-events.type.ts | 0 .../src/graphql/types/response-status.type.ts | 0 .../v1.x}/packages/runtime/src/index.ts | 0 .../v1.x}/packages/runtime/src/langgraph.ts | 0 .../packages/runtime/src/lib/cloud/index.ts | 0 .../runtime/src/lib/error-messages.ts | 0 .../v1.x}/packages/runtime/src/lib/index.ts | 0 .../runtime/src/lib/integrations/index.ts | 0 .../src/lib/integrations/nest/index.ts | 0 .../src/lib/integrations/nextjs/app-router.ts | 0 .../lib/integrations/nextjs/pages-router.ts | 0 .../lib/integrations/node-express/index.ts | 0 .../src/lib/integrations/node-http/index.ts | 0 .../runtime/src/lib/integrations/shared.ts | 0 .../v1.x}/packages/runtime/src/lib/logger.ts | 0 .../packages/runtime/src/lib/observability.ts | 0 .../__tests__/copilot-runtime-error.test.ts | 0 .../runtime/__tests__/mcp-tools-utils.test.ts | 0 .../agent-integrations/langgraph/agent.ts | 0 .../agent-integrations/langgraph/consts.ts | 0 .../agent-integrations/langgraph/index.ts | 0 .../src/lib/runtime/copilot-runtime.ts | 0 .../src/lib/runtime/mcp-tools-utils.ts | 0 .../runtime/src/lib/runtime/retry-utils.ts | 0 .../src/lib/runtime/telemetry-agent-runner.ts | 0 .../packages/runtime/src/lib/runtime/types.ts | 0 .../packages/runtime/src/lib/runtime/utils.ts | 0 .../packages/runtime/src/lib/streaming.ts | 0 .../runtime/src/lib/telemetry-client.ts | 0 .../anthropic/anthropic-adapter.ts | 0 .../src/service-adapters/anthropic/utils.ts | 0 .../bedrock/bedrock-adapter.ts | 0 .../src/service-adapters/conversion.ts | 0 .../service-adapters/empty/empty-adapter.ts | 0 .../runtime/src/service-adapters/events.ts | 0 .../experimental/ollama/ollama-adapter.ts | 0 .../google/google-genai-adapter.test.ts | 0 .../google/google-genai-adapter.ts | 0 .../src/service-adapters/groq/groq-adapter.ts | 0 .../runtime/src/service-adapters/index.ts | 0 .../langchain/langchain-adapter.ts | 0 .../service-adapters/langchain/langserve.ts | 0 .../src/service-adapters/langchain/types.ts | 0 .../src/service-adapters/langchain/utils.ts | 0 .../service-adapters/openai/openai-adapter.ts | 0 .../openai/openai-assistant-adapter.ts | 0 .../src/service-adapters/openai/utils.ts | 0 .../src/service-adapters/service-adapter.ts | 0 .../service-adapters/shared/error-utils.ts | 0 .../src/service-adapters/shared/index.ts | 0 .../service-adapters/unify/unify-adapter.ts | 0 .../utils/failed-response-status-reasons.ts | 0 .../v1.x}/packages/runtime/src/utils/index.ts | 0 .../v1.x}/packages/runtime/src/v2/index.ts | 0 .../v1.x}/packages/runtime/tests/global.d.ts | 0 .../anthropic/allowlist-approach.test.ts | 0 .../anthropic/anthropic-adapter.test.ts | 0 .../openai/allowlist-approach.test.ts | 0 .../openai/openai-adapter.test.ts | 0 .../packages/runtime/tests/setup.jest.ts | 0 .../packages/runtime/tests/tsconfig.json | 0 .../v1.x}/packages/runtime/tsconfig.json | 0 .../v1.x}/packages/runtime/tsup.config.ts | 0 .../v1.x}/packages/runtime/typedoc.json | 0 .../v1.x}/packages/sdk-js/CHANGELOG.md | 0 .../v1.x}/packages/sdk-js/README.md | 0 .../v1.x}/packages/sdk-js/jest.config.js | 0 .../v1.x}/packages/sdk-js/package.json | 0 .../src/__tests__/error-handling.test.ts | 0 .../v1.x}/packages/sdk-js/src/index.ts | 0 .../v1.x}/packages/sdk-js/src/langchain.ts | 0 .../v1.x}/packages/sdk-js/src/langgraph.ts | 0 .../v1.x}/packages/sdk-js/tsconfig.json | 0 .../v1.x}/packages/sdk-js/tsup.config.ts | 0 .../v1.x}/packages/sdk-js/typedoc.json | 0 .../v1.x}/packages/shared/CHANGELOG.md | 0 .../v1.x}/packages/shared/README.md | 0 .../v1.x}/packages/shared/jest.config.js | 0 .../v1.x}/packages/shared/package.json | 0 .../packages/shared/src/constants/index.ts | 0 .../v1.x}/packages/shared/src/index.ts | 0 .../packages/shared/src/telemetry/events.ts | 0 .../packages/shared/src/telemetry/index.ts | 0 .../shared/src/telemetry/scarf-client.ts | 0 .../shared/src/telemetry/security-check.ts | 0 .../shared/src/telemetry/telemetry-client.ts | 0 .../packages/shared/src/telemetry/utils.ts | 0 .../v1.x}/packages/shared/src/types/action.ts | 0 .../shared/src/types/copilot-cloud-config.ts | 0 .../v1.x}/packages/shared/src/types/error.ts | 0 .../v1.x}/packages/shared/src/types/index.ts | 0 .../packages/shared/src/types/message.ts | 0 .../shared/src/types/openai-assistant.ts | 0 .../packages/shared/src/types/utility.ts | 0 .../packages/shared/src/utils/conditions.ts | 0 .../shared/src/utils/console-styling.ts | 0 .../v1.x}/packages/shared/src/utils/errors.ts | 0 .../v1.x}/packages/shared/src/utils/index.ts | 0 .../shared/src/utils/json-schema.test.ts | 0 .../packages/shared/src/utils/json-schema.ts | 0 .../packages/shared/src/utils/random-id.ts | 0 .../packages/shared/src/utils/requests.ts | 0 .../v1.x}/packages/shared/src/utils/types.ts | 0 .../v1.x}/packages/shared/tsconfig.json | 0 .../v1.x}/packages/shared/tsup.config.ts | 0 .../v1.x}/packages/shared/typedoc.json | 0 src/v1.x/pnpm-lock.yaml | 26806 +++++++++++++++ {CopilotKit => src/v1.x}/pnpm-workspace.yaml | 0 .../v1.x}/scripts/develop/example.sh | 0 {CopilotKit => src/v1.x}/scripts/docs/gen.ts | 0 .../v1.x}/scripts/docs/lib/comments.ts | 0 src/v1.x/scripts/docs/lib/files.ts | 205 + .../v1.x}/scripts/docs/lib/python.ts | 0 src/v1.x/scripts/docs/lib/reference-doc.ts | 220 + .../v1.x}/scripts/docs/lib/source.ts | 0 .../v1.x}/scripts/qa/actions.sh | 0 {CopilotKit => src/v1.x}/scripts/qa/css.sh | 0 .../v1.x}/scripts/qa/firebase.sh | 0 .../v1.x}/scripts/qa/langchain.sh | 0 .../v1.x}/scripts/qa/langserve.sh | 0 .../v1.x}/scripts/qa/lib/actions/bad/array.ts | 0 .../v1.x}/scripts/qa/lib/actions/bad/enum.ts | 0 .../scripts/qa/lib/actions/bad/noargs.ts | 0 .../scripts/qa/lib/actions/bad/object.ts | 0 .../scripts/qa/lib/actions/bad/optional.ts | 0 .../scripts/qa/lib/actions/good/array.ts | 0 .../v1.x}/scripts/qa/lib/actions/good/enum.ts | 0 .../scripts/qa/lib/actions/good/noargs.ts | 0 .../scripts/qa/lib/actions/good/object.ts | 0 .../scripts/qa/lib/actions/good/optional.ts | 0 .../v1.x}/scripts/qa/lib/bash/packages.sh | 0 .../v1.x}/scripts/qa/lib/bash/prelude.sh | 0 .../v1.x}/scripts/qa/lib/bash/qa.sh | 0 .../v1.x}/scripts/qa/lib/css/page.tsx | 0 .../v1.x}/scripts/qa/lib/css/route.ts | 0 .../v1.x}/scripts/qa/lib/firebase/.firebaserc | 0 .../scripts/qa/lib/firebase/firebase.json | 0 .../v1.x}/scripts/qa/lib/firebase/index.ts | 0 .../scripts/qa/lib/firebase/package.json | 0 .../v1.x}/scripts/qa/lib/firebase/page.tsx | 0 .../scripts/qa/lib/firebase/tsconfig.json | 0 .../v1.x}/scripts/qa/lib/langchain/page.tsx | 0 .../v1.x}/scripts/qa/lib/langchain/route.ts | 0 .../scripts/qa/lib/langserve/app/server.py | 0 .../scripts/qa/lib/langserve/next/page.tsx | 0 .../scripts/qa/lib/langserve/next/route.ts | 0 .../scripts/qa/lib/langserve/requirements.txt | 0 .../scripts/qa/lib/next-pages/copilotkit.ts | 0 .../v1.x}/scripts/qa/lib/next-pages/index.tsx | 0 .../v1.x}/scripts/qa/lib/next/page.tsx | 0 .../v1.x}/scripts/qa/lib/next/route.ts | 0 .../v1.x}/scripts/qa/lib/node/express.ts | 0 .../v1.x}/scripts/qa/lib/node/page.tsx | 0 .../v1.x}/scripts/qa/lib/node/server.ts | 0 .../v1.x}/scripts/qa/lib/remix/_index.tsx | 0 .../v1.x}/scripts/qa/lib/remix/copilotkit.tsx | 0 .../qa/lib/upgrade-express/new/page.tsx | 0 .../qa/lib/upgrade-express/new/server.ts | 0 .../qa/lib/upgrade-express/old/page.tsx | 0 .../qa/lib/upgrade-express/old/server.ts | 0 .../qa/lib/upgrade-next-app/new/page.tsx | 0 .../qa/lib/upgrade-next-app/new/route.ts | 0 .../qa/lib/upgrade-next-app/old/page.tsx | 0 .../qa/lib/upgrade-next-app/old/route.ts | 0 .../qa/lib/upgrade-next-pages/new/page.tsx | 0 .../qa/lib/upgrade-next-pages/new/route.ts | 0 .../qa/lib/upgrade-next-pages/old/page.tsx | 0 .../qa/lib/upgrade-next-pages/old/route.ts | 0 .../scripts/qa/lib/upgrade-node/new/page.tsx | 0 .../scripts/qa/lib/upgrade-node/new/server.ts | 0 .../scripts/qa/lib/upgrade-node/old/page.tsx | 0 .../scripts/qa/lib/upgrade-node/old/server.ts | 0 .../v1.x}/scripts/qa/next-pages.sh | 0 {CopilotKit => src/v1.x}/scripts/qa/next.sh | 0 {CopilotKit => src/v1.x}/scripts/qa/node.sh | 0 {CopilotKit => src/v1.x}/scripts/qa/remix.sh | 0 .../v1.x}/scripts/qa/upgrade-express.sh | 0 .../v1.x}/scripts/qa/upgrade-next-app.sh | 0 .../v1.x}/scripts/qa/upgrade-next-pages.sh | 0 .../v1.x}/scripts/qa/upgrade-node.sh | 0 .../scripts/release/generate-changelog.js | 0 .../release/prepare-custom-prerelease.sh | 0 .../v1.x}/scripts/release/prerelease.sh | 0 .../release/publish-snapshot-release.sh | 0 .../v1.x}/scripts/release/release.sh | 0 {CopilotKit => src/v1.x}/turbo.json | 0 {CopilotKit => src/v1.x}/typedoc.base.json | 0 {CopilotKit => src/v1.x}/typedoc.json | 0 .../eslint-config-custom/CHANGELOG.md | 0 .../utilities/eslint-config-custom/index.js | 0 .../eslint-config-custom/package.json | 0 .../eslint-config-custom/typedoc.json | 0 .../utilities/tailwind-config/CHANGELOG.md | 0 .../utilities/tailwind-config/package.json | 0 .../tailwind-config/tailwind.config.js | 0 .../utilities/tailwind-config/typedoc.json | 0 .../v1.x}/utilities/tsconfig/CHANGELOG.md | 0 .../v1.x}/utilities/tsconfig/base.json | 0 .../v1.x}/utilities/tsconfig/nextjs.json | 0 .../v1.x}/utilities/tsconfig/package.json | 0 .../utilities/tsconfig/react-library.json | 0 .../v1.x}/utilities/tsconfig/typedoc.json | 0 src/v2.x/.cursor/rules/00-base.always.mdc | 18 + .../rules/10-repo-conventions.always.mdc | 10 + src/v2.x/.cursor/rules/20-typescript.auto.mdc | 10 + src/v2.x/.cursor/rules/30-pnpm.auto.mdc | 11 + src/v2.x/.cursor/rules/40-turborepo.auto.mdc | 9 + src/v2.x/.cursor/rules/50-vitest.auto.mdc | 12 + src/v2.x/.github/workflows/claude.yml | 64 + src/v2.x/.github/workflows/test.yml | 33 + src/v2.x/.gitignore | 50 + src/v2.x/.prettierrc | 13 + src/v2.x/CLAUDE.md | 210 + src/v2.x/LICENSE | 11 + src/v2.x/PLAN.md | 90 + src/v2.x/README.md | 62 + src/v2.x/apps/angular/demo-server/README.md | 50 + .../apps/angular/demo-server/package.json | 25 + .../apps/angular/demo-server/src/index.ts | 44 + .../apps/angular/demo-server/tsconfig.json | 14 + src/v2.x/apps/angular/demo/angular.json | 34 + src/v2.x/apps/angular/demo/package.json | 49 + .../angular/demo/src/app/app.component.ts | 47 + .../apps/angular/demo/src/app/app.config.ts | 29 + .../wildcard-tool-render.component.ts | 31 + .../custom-chat-input.component.ts | 157 + .../custom-input-chat.component.ts | 30 + .../routes/default/default-chat.component.ts | 11 + .../headless/headless-chat.component.ts | 147 + .../ukg-port/co-pilot-port.component.ts | 27 + .../apps/angular/demo/src/assets/.gitkeep | 1 + src/v2.x/apps/angular/demo/src/favicon.ico | 1 + src/v2.x/apps/angular/demo/src/index.html | 14 + src/v2.x/apps/angular/demo/src/main.ts | 6 + src/v2.x/apps/angular/demo/tsconfig.app.json | 14 + src/v2.x/apps/angular/demo/tsconfig.json | 42 + .../apps/angular/storybook/.storybook/main.ts | 26 + .../angular/storybook/.storybook/preview.css | 73 + .../angular/storybook/.storybook/preview.ts | 49 + .../angular/storybook/.storybook/styles.css | 18 + src/v2.x/apps/angular/storybook/angular.json | 82 + .../components/custom-disclaimer.component.ts | 37 + .../components/custom-input.component.ts | 64 + .../custom-scroll-button.component.ts | 59 + .../custom-send-button.component.ts | 27 + src/v2.x/apps/angular/storybook/package.json | 48 + .../apps/angular/storybook/postcss.config.js | 5 + .../CopilotChatAssistantMessage.stories.ts | 733 + .../stories/CopilotChatInput.stories.ts | 1358 + .../stories/CopilotChatMessageView.stories.ts | 996 + .../stories/CopilotChatUserMessage.stories.ts | 576 + .../CopilotChatView-Actions.stories.ts | 216 + .../stories/CopilotChatView-CSS.stories.ts | 180 + .../CopilotChatView-Components.stories.ts | 836 + .../CopilotChatView-Templates.stories.ts | 459 + .../stories/CopilotChatView.stories.ts | 353 + .../angular/storybook/stories/Welcome.mdx | 120 + src/v2.x/apps/angular/storybook/tsconfig.json | 27 + src/v2.x/apps/docs/docs.json | 83 + src/v2.x/apps/docs/introduction.mdx | 68 + src/v2.x/apps/docs/package.json | 16 + .../copilot-chat-configuration-provider.mdx | 179 + src/v2.x/apps/docs/reference/copilot-chat.mdx | 116 + .../apps/docs/reference/copilot-runtime.mdx | 277 + .../apps/docs/reference/copilotkit-core.mdx | 549 + .../docs/reference/copilotkit-provider.mdx | 261 + .../apps/docs/reference/frontend-tool.mdx | 150 + .../proxied-copilot-runtime-agent.mdx | 80 + .../apps/docs/reference/use-agent-context.mdx | 384 + src/v2.x/apps/docs/reference/use-agent.mdx | 486 + .../apps/docs/reference/use-copilotkit.mdx | 144 + .../apps/docs/reference/use-frontend-tool.mdx | 278 + .../docs/reference/use-human-in-the-loop.mdx | 223 + .../docs/reference/use-render-tool-call.mdx | 262 + src/v2.x/apps/node/package.json | 22 + src/v2.x/apps/node/src/index.ts | 23 + src/v2.x/apps/node/tsconfig.json | 21 + src/v2.x/apps/react/demo/.gitignore | 41 + src/v2.x/apps/react/demo/README.md | 36 + src/v2.x/apps/react/demo/eslint.config.mjs | 16 + .../react/demo/mcp-apps/package-lock.json | 2889 ++ .../apps/react/demo/mcp-apps/package.json | 26 + src/v2.x/apps/react/demo/mcp-apps/server.ts | 234 + .../apps/react/demo/mcp-apps/src/ui-raw.ts | 344 + .../apps/react/demo/mcp-apps/tsconfig.json | 13 + src/v2.x/apps/react/demo/mcp-apps/ui-raw.html | 12 + .../apps/react/demo/mcp-apps/vite.config.ts | 23 + src/v2.x/apps/react/demo/next.config.ts | 8 + src/v2.x/apps/react/demo/package.json | 48 + src/v2.x/apps/react/demo/postcss.config.mjs | 5 + src/v2.x/apps/react/demo/public/file.svg | 1 + src/v2.x/apps/react/demo/public/globe.svg | 1 + src/v2.x/apps/react/demo/public/next.svg | 1 + src/v2.x/apps/react/demo/public/sandbox.html | 95 + src/v2.x/apps/react/demo/public/vercel.svg | 1 + src/v2.x/apps/react/demo/public/window.svg | 1 + .../api/copilotkit-mcp/[[...slug]]/route.ts | 42 + .../src/app/api/copilotkit-single/route.ts | 36 + .../app/api/copilotkit/[[...slug]]/route.ts | 37 + src/v2.x/apps/react/demo/src/app/favicon.ico | Bin 0 -> 25931 bytes src/v2.x/apps/react/demo/src/app/globals.css | 26 + src/v2.x/apps/react/demo/src/app/layout.tsx | 35 + .../apps/react/demo/src/app/mcp-apps/page.tsx | 55 + src/v2.x/apps/react/demo/src/app/page.tsx | 157 + .../apps/react/demo/src/app/popup/page.tsx | 99 + .../apps/react/demo/src/app/sidebar/page.tsx | 84 + .../apps/react/demo/src/app/single/page.tsx | 101 + src/v2.x/apps/react/demo/tsconfig.json | 27 + src/v2.x/apps/react/demo/vitest.config.mjs | 14 + .../apps/react/storybook/.storybook/main.ts | 25 + .../react/storybook/.storybook/preview.css | 15 + .../react/storybook/.storybook/preview.ts | 38 + src/v2.x/apps/react/storybook/package.json | 34 + .../react/storybook/stories/Button.stories.ts | 54 + .../apps/react/storybook/stories/Button.tsx | 35 + .../react/storybook/stories/Configure.mdx | 364 + .../CopilotAssistantMessage.stories.tsx | 376 + .../stories/CopilotChatInput.stories.tsx | 381 + .../CopilotChatMessageView.stories.tsx | 640 + .../CopilotChatSuggestionPill.stories.tsx | 39 + .../CopilotChatSuggestionView.stories.tsx | 46 + .../CopilotChatToggleButton.stories.tsx | 67 + .../CopilotChatUserMessage.stories.tsx | 286 + .../stories/CopilotChatView.stories.tsx | 174 + .../stories/CopilotKitInspector.stories.tsx | 15 + .../stories/CopilotPopup.stories.tsx | 87 + .../stories/CopilotSidebarView.stories.tsx | 57 + .../storybook/stories/CopilotStoryLayout.tsx | 60 + .../react/storybook/stories/Header.stories.ts | 34 + .../apps/react/storybook/stories/Header.tsx | 54 + .../react/storybook/stories/Page.stories.ts | 33 + .../apps/react/storybook/stories/Page.tsx | 73 + .../stories/assets/accessibility.png | Bin 0 -> 42336 bytes .../stories/assets/accessibility.svg | 1 + .../stories/assets/addon-library.png | Bin 0 -> 467366 bytes .../react/storybook/stories/assets/assets.png | Bin 0 -> 3899 bytes .../stories/assets/avif-test-image.avif | Bin 0 -> 829 bytes .../storybook/stories/assets/context.png | Bin 0 -> 6119 bytes .../storybook/stories/assets/discord.svg | 1 + .../react/storybook/stories/assets/docs.png | Bin 0 -> 27875 bytes .../storybook/stories/assets/figma-plugin.png | Bin 0 -> 44246 bytes .../react/storybook/stories/assets/github.svg | 1 + .../react/storybook/stories/assets/share.png | Bin 0 -> 40767 bytes .../storybook/stories/assets/styling.png | Bin 0 -> 7237 bytes .../storybook/stories/assets/testing.png | Bin 0 -> 49313 bytes .../storybook/stories/assets/theming.png | Bin 0 -> 44374 bytes .../storybook/stories/assets/tutorials.svg | 1 + .../storybook/stories/assets/youtube.svg | 1 + .../apps/react/storybook/stories/button.css | 30 + .../apps/react/storybook/stories/header.css | 32 + .../apps/react/storybook/stories/page.css | 68 + .../apps/react/storybook/tailwind.config.js | 11 + src/v2.x/apps/react/storybook/tsconfig.json | 13 + src/v2.x/docs/ANGULAR_DESIGN.md | 518 + src/v2.x/docs/REACT_API.md | 182 + src/v2.x/package.json | 56 + src/v2.x/packages/agent/package.json | 52 + .../agent/src/__tests__/basic-agent.test.ts | 501 + .../__tests__/config-tools-execution.test.ts | 503 + .../src/__tests__/property-overrides.test.ts | 559 + .../agent/src/__tests__/state-tools.test.ts | 391 + .../agent/src/__tests__/test-helpers.ts | 117 + .../agent/src/__tests__/utils.test.ts | 438 + src/v2.x/packages/agent/src/index.ts | 1001 + src/v2.x/packages/agent/tsconfig.json | 13 + src/v2.x/packages/agent/tsup.config.ts | 11 + src/v2.x/packages/agent/vitest.config.ts | 13 + src/v2.x/packages/angular/.npmignore | 52 + src/v2.x/packages/angular/LICENSE | 10 + src/v2.x/packages/angular/README.md | 530 + src/v2.x/packages/angular/eslint.config.mjs | 20 + src/v2.x/packages/angular/ng-package.json | 21 + src/v2.x/packages/angular/package.json | 100 + src/v2.x/packages/angular/src/index.ts | 1 + .../angular/src/lib/agent-context.spec.ts | 68 + .../packages/angular/src/lib/agent-context.ts | 36 + .../packages/angular/src/lib/agent.spec.ts | 178 + src/v2.x/packages/angular/src/lib/agent.ts | 98 + .../packages/angular/src/lib/chat-config.ts | 65 + .../packages/angular/src/lib/chat-state.ts | 19 + ...t-chat-assistant-message.component.spec.ts | 64 + .../copilot-chat-input.component.spec.ts | 75 + ...opilot-chat-message-view.component.spec.ts | 52 + ...opilot-chat-user-message.component.spec.ts | 51 + .../copilot-chat-assistant-message-buttons.ts | 291 + ...copilot-chat-assistant-message-renderer.ts | 439 + .../copilot-chat-assistant-message-toolbar.ts | 20 + .../chat/copilot-chat-assistant-message.ts | 501 + .../copilot-chat-assistant-message.types.ts | 51 + .../chat/copilot-chat-audio-recorder.ts | 245 + .../components/chat/copilot-chat-buttons.ts | 307 + .../chat/copilot-chat-input-defaults.ts | 45 + .../lib/components/chat/copilot-chat-input.ts | 526 + .../chat/copilot-chat-input.types.ts | 148 + .../chat/copilot-chat-message-view-cursor.ts | 33 + .../chat/copilot-chat-message-view.ts | 222 + .../chat/copilot-chat-message-view.types.ts | 39 + .../components/chat/copilot-chat-textarea.ts | 186 + .../chat/copilot-chat-tool-calls-view.ts | 24 + .../components/chat/copilot-chat-toolbar.ts | 31 + .../chat/copilot-chat-tools-menu.ts | 184 + ...lot-chat-user-message-branch-navigation.ts | 88 + .../chat/copilot-chat-user-message-buttons.ts | 161 + .../copilot-chat-user-message-renderer.ts | 32 + .../chat/copilot-chat-user-message-toolbar.ts | 31 + .../chat/copilot-chat-user-message.ts | 261 + .../chat/copilot-chat-user-message.types.ts | 36 + .../chat/copilot-chat-view-disclaimer.ts | 50 + .../chat/copilot-chat-view-feather.ts | 42 + .../chat/copilot-chat-view-handlers.ts | 14 + .../chat/copilot-chat-view-input-container.ts | 80 + ...pilot-chat-view-scroll-to-bottom-button.ts | 80 + .../chat/copilot-chat-view-scroll-view.ts | 343 + .../lib/components/chat/copilot-chat-view.ts | 409 + .../chat/copilot-chat-view.types.ts | 52 + .../src/lib/components/chat/copilot-chat.ts | 178 + src/v2.x/packages/angular/src/lib/config.ts | 31 + .../angular/src/lib/copilotkit.spec.ts | 240 + .../packages/angular/src/lib/copilotkit.ts | 154 + ...copilotkit-agent-context.directive.spec.ts | 84 + .../copilotkit-chat-config.directive.spec.ts | 44 + .../copilotkit-config.directive.spec.ts | 30 + ...end-tool-simple.directive.spec.ts.disabled | 68 + ...copilotkit-frontend-tool.directive.spec.ts | 108 + ...lotkit-human-in-the-loop.directive.spec.ts | 42 + .../directives/copilotkit-agent-context.ts | 134 + .../src/lib/directives/stick-to-bottom.ts | 218 + .../angular/src/lib/directives/tooltip.ts | 307 + .../angular/src/lib/human-in-the-loop.ts | 27 + .../angular/src/lib/render-tool-calls.ts | 157 + .../angular/src/lib/resize-observer.ts | 187 + .../angular/src/lib/scroll-position.ts | 173 + .../lib/slots/__tests__/slot.utils.spec.ts | 217 + .../angular/src/lib/slots/copilot-slot.ts | 150 + .../packages/angular/src/lib/slots/index.ts | 3 + .../angular/src/lib/slots/slot.types.ts | 64 + .../angular/src/lib/slots/slot.utils.ts | 306 + src/v2.x/packages/angular/src/lib/tools.ts | 123 + src/v2.x/packages/angular/src/lib/utils.ts | 10 + src/v2.x/packages/angular/src/public-api.ts | 50 + .../packages/angular/src/styles/globals.css | 266 + .../packages/angular/src/styles/index.css | 3 + src/v2.x/packages/angular/src/test-setup.ts | 106 + src/v2.x/packages/angular/tsconfig.json | 33 + src/v2.x/packages/angular/tsconfig.spec.json | 14 + src/v2.x/packages/angular/vitest.config.mts | 45 + src/v2.x/packages/core/.npmignore | 36 + src/v2.x/packages/core/eslint.config.mjs | 3 + src/v2.x/packages/core/package.json | 48 + .../__tests__/core-agent-constraints.test.ts | 90 + .../core-agent-id-validation.test.ts | 132 + .../core-basic-functionality.test.ts | 145 + .../core/src/__tests__/core-basic.test.ts | 27 + .../src/__tests__/core-edge-cases.test.ts | 173 + .../src/__tests__/core-error-handling.test.ts | 238 + .../core/src/__tests__/core-follow-up.test.ts | 223 + .../core/src/__tests__/core-full.test.ts | 335 + .../core/src/__tests__/core-headers.test.ts | 266 + .../core/src/__tests__/core-simple.test.ts | 25 + .../__tests__/core-suggestions-e2e.test.ts | 1482 + .../src/__tests__/core-suggestions.test.ts | 448 + .../src/__tests__/core-tool-minimal.test.ts | 48 + .../src/__tests__/core-tool-simple.test.ts | 46 + .../core/src/__tests__/core-wildcard.test.ts | 46 + .../core/src/__tests__/import.test.ts | 13 + .../proxied-runtime-transport.test.ts | 381 + .../core/src/__tests__/simple.test.ts | 7 + .../core/src/__tests__/state-manager.test.ts | 779 + .../packages/core/src/__tests__/test-utils.ts | 260 + src/v2.x/packages/core/src/agent.ts | 175 + .../core/__tests__/run-handler-schema.test.ts | 59 + .../packages/core/src/core/agent-registry.ts | 323 + .../packages/core/src/core/context-store.ts | 54 + src/v2.x/packages/core/src/core/core.ts | 442 + src/v2.x/packages/core/src/core/index.ts | 6 + .../packages/core/src/core/run-handler.ts | 601 + .../packages/core/src/core/state-manager.ts | 248 + .../core/src/core/suggestion-engine.ts | 444 + src/v2.x/packages/core/src/index.ts | 4 + src/v2.x/packages/core/src/types.ts | 86 + src/v2.x/packages/core/src/utils/markdown.ts | 271 + src/v2.x/packages/core/tsconfig.json | 12 + src/v2.x/packages/core/tsup.config.ts | 11 + src/v2.x/packages/core/vitest.config.mjs | 15 + src/v2.x/packages/demo-agents/package.json | 33 + src/v2.x/packages/demo-agents/src/index.ts | 2 + src/v2.x/packages/demo-agents/src/openai.ts | 99 + .../src/slow-tool-call-streaming.ts | 127 + src/v2.x/packages/demo-agents/tsconfig.json | 9 + src/v2.x/packages/demo-agents/tsup.config.ts | 10 + .../packages/demo-agents/vitest.config.ts | 8 + src/v2.x/packages/eslint-config/README.md | 3 + src/v2.x/packages/eslint-config/base.js | 32 + src/v2.x/packages/eslint-config/next.js | 49 + src/v2.x/packages/eslint-config/package.json | 24 + .../packages/eslint-config/react-internal.js | 39 + src/v2.x/packages/react/.npmignore | 40 + src/v2.x/packages/react/components.json | 20 + src/v2.x/packages/react/eslint.config.mjs | 11 + src/v2.x/packages/react/package.json | 83 + src/v2.x/packages/react/postcss.config.js | 7 + .../packages/react/src/__tests__/setup.ts | 89 + .../src/__tests__/utils/test-helpers.tsx | 323 + .../src/components/CopilotKitInspector.tsx | 46 + .../components/MCPAppsActivityRenderer.tsx | 390 + .../src/components/WildcardToolCallRender.tsx | 86 + .../react/src/components/chat/CopilotChat.tsx | 168 + .../chat/CopilotChatAssistantMessage.tsx | 362 + .../chat/CopilotChatAudioRecorder.tsx | 157 + .../src/components/chat/CopilotChatInput.tsx | 1097 + .../chat/CopilotChatMessageView.tsx | 291 + .../chat/CopilotChatSuggestionPill.tsx | 53 + .../chat/CopilotChatSuggestionView.tsx | 129 + .../chat/CopilotChatToggleButton.tsx | 157 + .../chat/CopilotChatToolCallsView.tsx | 40 + .../chat/CopilotChatUserMessage.tsx | 373 + .../src/components/chat/CopilotChatView.tsx | 394 + .../components/chat/CopilotModalHeader.tsx | 109 + .../src/components/chat/CopilotPopup.tsx | 58 + .../src/components/chat/CopilotPopupView.tsx | 206 + .../src/components/chat/CopilotSidebar.tsx | 41 + .../components/chat/CopilotSidebarView.tsx | 129 + .../chat/__tests__/CopilotChat.e2e.test.tsx | 758 + .../CopilotChatActivityRendering.e2e.test.tsx | 146 + .../CopilotChatAssistantMessage.test.tsx | 694 + .../chat/__tests__/CopilotChatInput.test.tsx | 917 + .../__tests__/CopilotChatMessageView.test.tsx | 69 + .../CopilotChatToolRendering.e2e.test.tsx | 778 + .../CopilotChatToolRerenders.e2e.test.tsx | 2366 ++ .../src/components/chat/__tests__/setup.ts | 1 + .../react/src/components/chat/index.ts | 80 + .../packages/react/src/components/index.ts | 7 + .../react/src/components/ui/button.tsx | 123 + .../react/src/components/ui/dropdown-menu.tsx | 257 + .../react/src/components/ui/tooltip.tsx | 59 + .../__tests__/use-agent-context.test.tsx | 397 + .../hooks/__tests__/use-agent.e2e.test.tsx | 146 + .../use-configure-suggestions.e2e.test.tsx | 637 + .../__tests__/use-frontend-tool.e2e.test.tsx | 2109 ++ .../use-human-in-the-loop.e2e.test.tsx | 651 + .../__tests__/use-suggestions.e2e.test.tsx | 465 + src/v2.x/packages/react/src/hooks/index.ts | 11 + .../react/src/hooks/use-agent-context.tsx | 45 + .../packages/react/src/hooks/use-agent.tsx | 122 + .../src/hooks/use-configure-suggestions.tsx | 160 + .../react/src/hooks/use-frontend-tool.tsx | 58 + .../react/src/hooks/use-human-in-the-loop.tsx | 87 + .../react/src/hooks/use-keyboard-height.tsx | 67 + .../src/hooks/use-render-activity-message.tsx | 58 + .../src/hooks/use-render-custom-messages.tsx | 74 + .../react/src/hooks/use-render-tool-call.tsx | 189 + .../react/src/hooks/use-suggestions.tsx | 86 + src/v2.x/packages/react/src/index.ts | 13 + .../__tests__/completePartialMarkdown.test.ts | 495 + .../src/lib/__tests__/renderSlot.test.tsx | 586 + src/v2.x/packages/react/src/lib/react-core.ts | 67 + src/v2.x/packages/react/src/lib/slots.tsx | 109 + src/v2.x/packages/react/src/lib/utils.ts | 6 + .../CopilotChatConfigurationProvider.tsx | 119 + .../src/providers/CopilotKitProvider.tsx | 349 + .../CopilotChatConfigurationProvider.test.tsx | 257 + ...Provider.renderCustomMessages.e2e.test.tsx | 690 + .../__tests__/CopilotKitProvider.test.tsx | 494 + .../CopilotKitProvider.wildcard.test.tsx | 264 + .../packages/react/src/providers/index.ts | 14 + .../packages/react/src/styles/globals.css | 208 + .../__tests__/defineToolCallRenderer.test.tsx | 524 + .../react/src/types/defineToolCallRenderer.ts | 64 + .../packages/react/src/types/frontend-tool.ts | 8 + .../react/src/types/human-in-the-loop.ts | 33 + src/v2.x/packages/react/src/types/index.ts | 6 + .../types/react-activity-message-renderer.ts | 27 + .../types/react-custom-message-renderer.ts | 17 + .../src/types/react-tool-call-renderer.ts | 32 + src/v2.x/packages/react/tailwind.config.js | 9 + src/v2.x/packages/react/test.css | 2355 ++ src/v2.x/packages/react/tsconfig.json | 27 + src/v2.x/packages/react/tsup.config.ts | 19 + src/v2.x/packages/react/vitest.config.mjs | 20 + .../runtime/.cursor/rules/runtime.always.mdc | 9 + src/v2.x/packages/runtime/.npmignore | 36 + src/v2.x/packages/runtime/eslint.config.mjs | 3 + src/v2.x/packages/runtime/package.json | 63 + .../src/__tests__/express-body-order.test.ts | 70 + .../src/__tests__/get-runtime-info.test.ts | 117 + .../runtime/src/__tests__/handle-run.test.ts | 161 + .../src/__tests__/handle-transcribe.test.ts | 289 + .../in-process-agent-runner-messages.test.ts | 215 + .../__tests__/in-process-agent-runner.test.ts | 889 + .../src/__tests__/middleware-express.test.ts | 173 + .../middleware-single-express.test.ts | 173 + .../src/__tests__/middleware-single.test.ts | 172 + .../runtime/src/__tests__/middleware.test.ts | 223 + .../src/__tests__/routing-express.test.ts | 168 + .../__tests__/routing-single-express.test.ts | 160 + .../src/__tests__/routing-single.test.ts | 181 + .../runtime/src/__tests__/routing.test.ts | 257 + .../runtime/src/endpoints/express-single.ts | 160 + .../runtime/src/endpoints/express-utils.ts | 160 + .../packages/runtime/src/endpoints/express.ts | 146 + .../runtime/src/endpoints/hono-single.ts | 160 + .../packages/runtime/src/endpoints/hono.ts | 154 + .../packages/runtime/src/endpoints/index.ts | 2 + .../src/endpoints/single-route-helpers.ts | 115 + src/v2.x/packages/runtime/src/express.ts | 3 + src/v2.x/packages/runtime/src/handler.ts | 3 + .../runtime/src/handlers/get-runtime-info.ts | 50 + .../runtime/src/handlers/handle-connect.ts | 144 + .../runtime/src/handlers/handle-run.ts | 177 + .../runtime/src/handlers/handle-stop.ts | 76 + .../runtime/src/handlers/handle-transcribe.ts | 126 + src/v2.x/packages/runtime/src/index.ts | 5 + src/v2.x/packages/runtime/src/middleware.ts | 94 + .../runner/__tests__/finalize-events.test.ts | 99 + .../__tests__/in-memory-runner.e2e.test.ts | 750 + .../runner/__tests__/in-memory-runner.test.ts | 273 + .../runtime/src/runner/agent-runner.ts | 27 + .../packages/runtime/src/runner/in-memory.ts | 329 + src/v2.x/packages/runtime/src/runner/index.ts | 3 + src/v2.x/packages/runtime/src/runtime.ts | 53 + .../transcription-service-openai.ts | 29 + .../transcription-service.ts | 11 + src/v2.x/packages/runtime/tsconfig.json | 20 + src/v2.x/packages/runtime/tsup.config.ts | 13 + src/v2.x/packages/runtime/vitest.config.mjs | 15 + src/v2.x/packages/shared/.npmignore | 35 + src/v2.x/packages/shared/eslint.config.mjs | 3 + src/v2.x/packages/shared/package.json | 41 + src/v2.x/packages/shared/src/constants.ts | 1 + .../packages/shared/src/finalize-events.ts | 154 + src/v2.x/packages/shared/src/index.ts | 12 + src/v2.x/packages/shared/src/logger.ts | 1 + src/v2.x/packages/shared/src/types.ts | 26 + src/v2.x/packages/shared/src/utils.ts | 14 + src/v2.x/packages/shared/tsconfig.json | 12 + src/v2.x/packages/shared/tsup.config.ts | 11 + .../packages/sqlite-runner/eslint.config.mjs | 3 + src/v2.x/packages/sqlite-runner/package.json | 55 + .../src/__tests__/sqlite-runner.e2e.test.ts | 765 + .../src/__tests__/sqlite-runner.test.ts | 402 + src/v2.x/packages/sqlite-runner/src/index.ts | 1 + .../sqlite-runner/src/sqlite-runner.ts | 497 + src/v2.x/packages/sqlite-runner/tsconfig.json | 13 + .../packages/sqlite-runner/tsup.config.ts | 11 + .../packages/sqlite-runner/vitest.config.mjs | 15 + src/v2.x/packages/typescript-config/base.json | 19 + .../packages/typescript-config/nextjs.json | 12 + .../packages/typescript-config/package.json | 14 + .../typescript-config/react-library.json | 7 + .../packages/web-inspector/eslint.config.mjs | 3 + src/v2.x/packages/web-inspector/package.json | 50 + .../src/__tests__/web-inspector.spec.ts | 171 + .../src/assets/inspector-logo-icon.svg | 8 + .../src/assets/inspector-logo.svg | 40 + .../web-inspector/src/components.d.ts | 24 + src/v2.x/packages/web-inspector/src/index.ts | 3659 ++ .../web-inspector/src/lib/context-helpers.ts | 125 + .../web-inspector/src/lib/persistence.ts | 104 + .../packages/web-inspector/src/lib/types.ts | 19 + .../web-inspector/src/styles/tailwind.css | 23 + .../packages/web-inspector/src/types/css.d.ts | 4 + .../packages/web-inspector/src/types/svg.d.ts | 4 + src/v2.x/packages/web-inspector/tsconfig.json | 14 + .../packages/web-inspector/tsup.config.ts | 15 + .../packages/web-inspector/vitest.config.ts | 8 + src/v2.x/pnpm-lock.yaml | 27771 ++++++++++++++++ src/v2.x/pnpm-workspace.yaml | 6 + src/v2.x/turbo.json | 102 + 1118 files changed, 133836 insertions(+), 27863 deletions(-) delete mode 100644 CopilotKit/.vscode/launch.json delete mode 100644 CopilotKit/examples/next-openai/Dockerfile delete mode 100644 CopilotKit/packages/react-core/src/lib/copilot-task.ts delete mode 100644 CopilotKit/packages/react-core/tsup.config.ts delete mode 100644 CopilotKit/packages/react-textarea/src/components/copilot-textarea/copilot-textarea.tsx delete mode 100644 CopilotKit/packages/react-ui/tsup.config.ts delete mode 100644 CopilotKit/pnpm-lock.yaml delete mode 100644 CopilotKit/scripts/docs/lib/files.ts delete mode 100644 CopilotKit/scripts/docs/lib/reference-doc.ts rename {CopilotKit => src/v1.x}/.changeset/README.md (100%) rename {CopilotKit => src/v1.x}/.changeset/config.json (100%) rename {CopilotKit => src/v1.x}/.changeset/pre.json (100%) rename {CopilotKit => src/v1.x}/.eslintrc.js (100%) rename {CopilotKit => src/v1.x}/.gitignore (100%) rename {CopilotKit => src/v1.x}/.npmrc (100%) rename {CopilotKit => src/v1.x}/.prettierignore (100%) rename {CopilotKit => src/v1.x}/.prettierrc (100%) rename {CopilotKit => src/v1.x}/.vscode/cspell.json (100%) create mode 100644 src/v1.x/.vscode/launch.json rename {CopilotKit => src/v1.x}/.vscode/settings.json (100%) rename {CopilotKit => src/v1.x}/CopilotKit.code-workspace (100%) rename {CopilotKit => src/v1.x}/changelog.txt (100%) rename {CopilotKit => src/v1.x}/demo-suggestions-improvement.md (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/.env.local.example (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/.eslintrc.js (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/.gitignore (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/CHANGELOG.md (100%) create mode 100644 src/v1.x/examples/next-openai/Dockerfile rename {CopilotKit => src/v1.x}/examples/next-openai/README.md (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/next-env.d.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/next.config.js (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/package.json (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/postcss.config.js (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/public/images/GoogleDocs.svg (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/api/copilotkit/[[...slug]]/route.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/api/copilotkit/presentation/route.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/api/copilotkit/travel/route.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/api/copilotkit/travel/tavily.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/api/transcribe/route.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/api/tts/route.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/ActionButton.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/Presentation.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/ServiceAdapterSelector.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/Slide.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/destination-row.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/destination-table.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/vacation-list.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/components/vacation-notes.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/headless/page.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/layout.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/multi/page.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/multi/styles.css (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/page.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/actions/useAppendSlide.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/actions/useUpdateSlide.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/buttons/ActionButton.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/buttons/AddSlideButton.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/buttons/DeleteSlideButton.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/buttons/GenerateSlideButton.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/buttons/NavButton.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/buttons/PerformResearchSwitch.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/buttons/SpeakCurrentSlideButton.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/main/Header.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/main/Presentation.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/main/Slide.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/misc/SlideNumberIndicator.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/components/misc/SlidePreview.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/page.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/styles.css (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/types.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/presentation/utils/globalAudio.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/textarea/page.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/travel/page.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/travel/styles.css (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/app/utils.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/lib/dynamic-service-adapter.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/src/styles/globals.css (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/tailwind.config.js (100%) rename {CopilotKit => src/v1.x}/examples/next-openai/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/.eslintrc.js (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/.gitignore (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/README.md (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/next.config.mjs (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/package.json (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/pages/_app.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/pages/_document.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/pages/api/copilotkit.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/pages/api/hello.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/pages/index.tsx (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/postcss.config.mjs (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/public/favicon.ico (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/public/next.svg (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/public/vercel.svg (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/styles/globals.css (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/tailwind.config.ts (100%) rename {CopilotKit => src/v1.x}/examples/next-pages-router/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/examples/node-express/.eslintrc.js (100%) rename {CopilotKit => src/v1.x}/examples/node-express/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/examples/node-express/package.json (100%) rename {CopilotKit => src/v1.x}/examples/node-express/src/index.ts (100%) rename {CopilotKit => src/v1.x}/examples/node-express/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/examples/node-http/.eslintrc.js (100%) rename {CopilotKit => src/v1.x}/examples/node-http/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/examples/node-http/package.json (100%) rename {CopilotKit => src/v1.x}/examples/node-http/src/index.ts (100%) rename {CopilotKit => src/v1.x}/examples/node-http/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/package.json (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/eslint.config.js (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/package.json (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/src/A2UIMessageRenderer.tsx (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/src/A2UIViewer.tsx (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/src/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/src/styles/components.ts (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/src/styles/global.ts (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/src/theme/viewer-theme.ts (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/src/themed-surface.ts (100%) rename {CopilotKit => src/v1.x}/packages/a2ui-renderer/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/packages/react-core/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/react-core/README.md (100%) rename {CopilotKit => src/v1.x}/packages/react-core/jest.config.js (100%) rename {CopilotKit => src/v1.x}/packages/react-core/package.json (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/CopilotListeners.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/copilot-provider/__tests__/copilotkit-error.test.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/copilot-provider/copilot-messages.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/copilot-provider/copilotkit-props.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/copilot-provider/copilotkit.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/copilot-provider/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/dev-console/console-trigger.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/dev-console/developer-console-modal.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/dev-console/icons.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/error-boundary/error-boundary.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/error-boundary/error-utils.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/toast/exclamation-mark-icon.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/toast/toast-provider.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/components/usage-banner.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/context/coagent-state-renders-context.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/context/copilot-context.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/context/copilot-messages-context.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/context/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/context/threads-context.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/__tests__/use-coagent-config.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-agent-nodename.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-coagent-state-render-bridge.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-coagent-state-render.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-coagent.ts (100%) create mode 100644 src/v1.x/packages/react-core/src/hooks/use-configure-chat-suggestions.tsx rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-action.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-additional-instructions.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-authenticated-action.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-chat-headless_c.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-chat-suggestions.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-chat.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-chat_internal.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-readable.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-copilot-runtime-client.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-default-tool.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-flat-category-store.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-frontend-tool.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-human-in-the-loop.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-langgraph-interrupt-render.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-langgraph-interrupt.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-lazy-tool-renderer.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-make-copilot-document-readable.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-render-tool-call.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/hooks/use-tree.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/index.tsx (100%) create mode 100644 src/v1.x/packages/react-core/src/lib/copilot-task.ts rename {CopilotKit => src/v1.x}/packages/react-core/src/lib/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/lib/status-checker.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/setupTests.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/chat-suggestion-configuration.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/coagent-action.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/coagent-state.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/crew.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/document-pointer.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/frontend-action.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/interrupt-action.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/types/system-message.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/utils/dev-console.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/utils/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/utils/suggestions-constants.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/utils/utils.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/utils/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/v2/index.css (100%) rename {CopilotKit => src/v1.x}/packages/react-core/src/v2/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-core/tsconfig.json (100%) create mode 100644 src/v1.x/packages/react-core/tsup.config.ts rename {CopilotKit => src/v1.x}/packages/react-core/typedoc.json (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/README.md (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/jest.config.js (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/package.json (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/postcss.config.js (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/base-copilot-textarea/base-copilot-textarea.css (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/base-copilot-textarea/base-copilot-textarea.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/base-copilot-textarea/render-element.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/base-copilot-textarea/render-placeholder.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/base-copilot-textarea/track-cursor-moved-since-last-text-change.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/base-copilot-textarea/use-add-branding-css.tsx (100%) create mode 100644 src/v1.x/packages/react-textarea/src/components/copilot-textarea/copilot-textarea.tsx rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/hovering-toolbar/hovering-editor-provider.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/hovering-toolbar/hovering-toolbar-components.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/hovering-toolbar/hovering-toolbar.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/hovering-toolbar/text-insertion-prompt-box/hovering-insertion-prompt-box-core.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/hovering-toolbar/text-insertion-prompt-box/hovering-insertion-prompt-box.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/hovering-toolbar/text-insertion-prompt-box/included-files-preview.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/hovering-toolbar/text-insertion-prompt-box/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/manual-ui/chip-with-icon.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/source-search-box/source-search-box.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/ui/button.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/ui/command.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/ui/dialog.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/components/ui/label.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/context/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/hooks/base-copilot-textarea-implementation/use-autosuggestions.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/hooks/base-copilot-textarea-implementation/use-copilot-textarea-editor.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/hooks/base-copilot-textarea-implementation/use-populate-copilot-textarea-ref.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/hooks/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/hooks/make-autosuggestions-function/use-make-standard-autosuggestions-function.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/hooks/make-autosuggestions-function/use-make-standard-insertion-function.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/hooks/misc/use-autosize-textarea.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/index.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/debouncer.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/editor-to-text.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/get-text-around-cursor.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/retry.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/slatejs-edits/add-autocompletions.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/slatejs-edits/clear-autocompletions.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/slatejs-edits/replace-text.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/slatejs-edits/with-partial-history.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/stream-promise-flatten.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/utils.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/lib/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/styles.css (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/autosuggestions-config/autosuggestions-config-user-specified.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/autosuggestions-config/autosuggestions-config.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/autosuggestions-config/editing-api-config.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/autosuggestions-config/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/autosuggestions-config/insertions-api-config.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/autosuggestions-config/subtypes/make-system-prompt.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/autosuggestions-config/suggestions-api-config.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/base/autosuggestion-state.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/base/autosuggestions-bare-function.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/base/base-autosuggestions-config.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/base/base-copilot-textarea-props.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/base/custom-editor.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/base/editor-autocomplete-state.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/base/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/html-copilot-textarea-element.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/src/types/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/tailwind.config.js (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/tsup.config.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-textarea/typedoc.json (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/README.md (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/jest.config.js (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/package.json (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/postcss.config.js (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Button.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Chat.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/ChatContext.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/CodeBlock.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Header.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Icons.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/ImageUploadQueue.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Input.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Markdown.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Messages.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Modal.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Popup.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/PoweredByTag.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Sidebar.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Suggestion.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Suggestions.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Textarea.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/Window.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/index.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/messages/AssistantMessage.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/messages/ErrorMessage.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/messages/ImageRenderer.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/messages/LegacyRenderMessage.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/messages/RenderMessage.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/messages/UserMessage.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/chat/props.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/dev-console/console.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/dev-console/icons.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/dev-console/index.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/dev-console/types.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/dev-console/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/help-modal/icons.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/help-modal/index.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/help-modal/modal.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/components/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/context/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/animations.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/button.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/colors.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/console.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/crew.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/header.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/input.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/markdown.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/messages.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/panel.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/popup.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/sidebar.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/suggestions.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/css/window.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/hooks/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/hooks/use-copilot-chat-suggestions.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/hooks/use-copy-to-clipboard.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/hooks/use-dark-mode.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/hooks/use-push-to-talk.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/index.tsx (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/lib/utils.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/lib/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/styles.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/types/css.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/types/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/types/suggestions.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/v2/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/src/v2/styles.css (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/tailwind.config.js (100%) rename {CopilotKit => src/v1.x}/packages/react-ui/tsconfig.json (100%) create mode 100644 src/v1.x/packages/react-ui/tsup.config.ts rename {CopilotKit => src/v1.x}/packages/react-ui/typedoc.json (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/README.md (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/codegen.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/jest.config.js (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/package.json (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/client/CopilotRuntimeClient.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/client/conversion.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/client/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/client/types.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/graphql/definitions/mutations.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/graphql/definitions/queries.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/message-conversion/agui-to-gql.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/message-conversion/agui-to-gql.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/message-conversion/gql-to-agui.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/message-conversion/gql-to-agui.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/message-conversion/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/src/message-conversion/roundtrip-conversion.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/tsup.config.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime-client-gql/typedoc.json (100%) rename {CopilotKit => src/v1.x}/packages/runtime/.eslintrc.js (100%) rename {CopilotKit => src/v1.x}/packages/runtime/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/runtime/README.md (100%) rename {CopilotKit => src/v1.x}/packages/runtime/jest.config.js (100%) rename {CopilotKit => src/v1.x}/packages/runtime/package.json (100%) rename {CopilotKit => src/v1.x}/packages/runtime/pnpm-lock.yaml (100%) rename {CopilotKit => src/v1.x}/packages/runtime/scripts/generate-gql-schema.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/agents/langgraph/event-source.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/agents/langgraph/events.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/action.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/agent-session.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/agent-state.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/cloud-guardrails.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/cloud.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/context-property.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/copilot-context.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/custom-property.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/extensions.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/forwarded-parameters.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/frontend.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/generate-copilot-response.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/load-agent-state.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/message.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/inputs/meta-event.input.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/message-conversion/agui-to-gql.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/message-conversion/agui-to-gql.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/message-conversion/gql-to-agui.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/message-conversion/gql-to-agui.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/message-conversion/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/message-conversion/roundtrip-conversion.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/resolvers/copilot.resolver.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/resolvers/state.resolver.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/agents-response.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/base/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/converted/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/copilot-response.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/enums.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/extensions-response.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/guardrails-result.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/load-agent-state-response.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/message-status.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/meta-events.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/graphql/types/response-status.type.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/langgraph.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/cloud/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/error-messages.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/integrations/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/integrations/nest/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/integrations/nextjs/app-router.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/integrations/nextjs/pages-router.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/integrations/node-express/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/integrations/node-http/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/integrations/shared.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/logger.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/observability.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/__tests__/copilot-runtime-error.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/__tests__/mcp-tools-utils.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/agent-integrations/langgraph/agent.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/agent-integrations/langgraph/consts.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/agent-integrations/langgraph/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/copilot-runtime.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/mcp-tools-utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/retry-utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/telemetry-agent-runner.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/types.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/runtime/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/streaming.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/lib/telemetry-client.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/anthropic/anthropic-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/anthropic/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/bedrock/bedrock-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/conversion.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/empty/empty-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/events.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/experimental/ollama/ollama-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/google/google-genai-adapter.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/google/google-genai-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/groq/groq-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/langchain/langchain-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/langchain/langserve.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/langchain/types.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/langchain/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/openai/openai-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/openai/openai-assistant-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/openai/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/service-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/shared/error-utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/shared/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/service-adapters/unify/unify-adapter.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/utils/failed-response-status-reasons.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/utils/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/src/v2/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tests/global.d.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tests/service-adapters/anthropic/allowlist-approach.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tests/service-adapters/anthropic/anthropic-adapter.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tests/service-adapters/openai/allowlist-approach.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tests/service-adapters/openai/openai-adapter.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tests/setup.jest.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tests/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/packages/runtime/tsup.config.ts (100%) rename {CopilotKit => src/v1.x}/packages/runtime/typedoc.json (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/README.md (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/jest.config.js (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/package.json (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/src/__tests__/error-handling.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/src/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/src/langchain.ts (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/src/langgraph.ts (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/tsup.config.ts (100%) rename {CopilotKit => src/v1.x}/packages/sdk-js/typedoc.json (100%) rename {CopilotKit => src/v1.x}/packages/shared/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/packages/shared/README.md (100%) rename {CopilotKit => src/v1.x}/packages/shared/jest.config.js (100%) rename {CopilotKit => src/v1.x}/packages/shared/package.json (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/constants/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/telemetry/events.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/telemetry/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/telemetry/scarf-client.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/telemetry/security-check.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/telemetry/telemetry-client.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/telemetry/utils.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/types/action.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/types/copilot-cloud-config.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/types/error.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/types/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/types/message.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/types/openai-assistant.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/types/utility.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/conditions.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/console-styling.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/errors.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/index.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/json-schema.test.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/json-schema.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/random-id.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/requests.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/src/utils/types.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/packages/shared/tsup.config.ts (100%) rename {CopilotKit => src/v1.x}/packages/shared/typedoc.json (100%) create mode 100644 src/v1.x/pnpm-lock.yaml rename {CopilotKit => src/v1.x}/pnpm-workspace.yaml (100%) rename {CopilotKit => src/v1.x}/scripts/develop/example.sh (100%) rename {CopilotKit => src/v1.x}/scripts/docs/gen.ts (100%) rename {CopilotKit => src/v1.x}/scripts/docs/lib/comments.ts (100%) create mode 100644 src/v1.x/scripts/docs/lib/files.ts rename {CopilotKit => src/v1.x}/scripts/docs/lib/python.ts (100%) create mode 100644 src/v1.x/scripts/docs/lib/reference-doc.ts rename {CopilotKit => src/v1.x}/scripts/docs/lib/source.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/actions.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/css.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/firebase.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/langchain.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/langserve.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/bad/array.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/bad/enum.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/bad/noargs.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/bad/object.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/bad/optional.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/good/array.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/good/enum.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/good/noargs.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/good/object.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/actions/good/optional.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/bash/packages.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/bash/prelude.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/bash/qa.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/css/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/css/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/firebase/.firebaserc (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/firebase/firebase.json (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/firebase/index.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/firebase/package.json (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/firebase/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/firebase/tsconfig.json (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/langchain/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/langchain/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/langserve/app/server.py (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/langserve/next/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/langserve/next/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/langserve/requirements.txt (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/next-pages/copilotkit.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/next-pages/index.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/next/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/next/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/node/express.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/node/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/node/server.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/remix/_index.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/remix/copilotkit.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-express/new/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-express/new/server.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-express/old/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-express/old/server.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-app/new/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-app/new/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-app/old/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-app/old/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-pages/new/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-pages/new/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-pages/old/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-next-pages/old/route.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-node/new/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-node/new/server.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-node/old/page.tsx (100%) rename {CopilotKit => src/v1.x}/scripts/qa/lib/upgrade-node/old/server.ts (100%) rename {CopilotKit => src/v1.x}/scripts/qa/next-pages.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/next.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/node.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/remix.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/upgrade-express.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/upgrade-next-app.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/upgrade-next-pages.sh (100%) rename {CopilotKit => src/v1.x}/scripts/qa/upgrade-node.sh (100%) rename {CopilotKit => src/v1.x}/scripts/release/generate-changelog.js (100%) rename {CopilotKit => src/v1.x}/scripts/release/prepare-custom-prerelease.sh (100%) rename {CopilotKit => src/v1.x}/scripts/release/prerelease.sh (100%) rename {CopilotKit => src/v1.x}/scripts/release/publish-snapshot-release.sh (100%) rename {CopilotKit => src/v1.x}/scripts/release/release.sh (100%) rename {CopilotKit => src/v1.x}/turbo.json (100%) rename {CopilotKit => src/v1.x}/typedoc.base.json (100%) rename {CopilotKit => src/v1.x}/typedoc.json (100%) rename {CopilotKit => src/v1.x}/utilities/eslint-config-custom/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/utilities/eslint-config-custom/index.js (100%) rename {CopilotKit => src/v1.x}/utilities/eslint-config-custom/package.json (100%) rename {CopilotKit => src/v1.x}/utilities/eslint-config-custom/typedoc.json (100%) rename {CopilotKit => src/v1.x}/utilities/tailwind-config/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/utilities/tailwind-config/package.json (100%) rename {CopilotKit => src/v1.x}/utilities/tailwind-config/tailwind.config.js (100%) rename {CopilotKit => src/v1.x}/utilities/tailwind-config/typedoc.json (100%) rename {CopilotKit => src/v1.x}/utilities/tsconfig/CHANGELOG.md (100%) rename {CopilotKit => src/v1.x}/utilities/tsconfig/base.json (100%) rename {CopilotKit => src/v1.x}/utilities/tsconfig/nextjs.json (100%) rename {CopilotKit => src/v1.x}/utilities/tsconfig/package.json (100%) rename {CopilotKit => src/v1.x}/utilities/tsconfig/react-library.json (100%) rename {CopilotKit => src/v1.x}/utilities/tsconfig/typedoc.json (100%) create mode 100644 src/v2.x/.cursor/rules/00-base.always.mdc create mode 100644 src/v2.x/.cursor/rules/10-repo-conventions.always.mdc create mode 100644 src/v2.x/.cursor/rules/20-typescript.auto.mdc create mode 100644 src/v2.x/.cursor/rules/30-pnpm.auto.mdc create mode 100644 src/v2.x/.cursor/rules/40-turborepo.auto.mdc create mode 100644 src/v2.x/.cursor/rules/50-vitest.auto.mdc create mode 100644 src/v2.x/.github/workflows/claude.yml create mode 100644 src/v2.x/.github/workflows/test.yml create mode 100644 src/v2.x/.gitignore create mode 100644 src/v2.x/.prettierrc create mode 100644 src/v2.x/CLAUDE.md create mode 100644 src/v2.x/LICENSE create mode 100644 src/v2.x/PLAN.md create mode 100644 src/v2.x/README.md create mode 100644 src/v2.x/apps/angular/demo-server/README.md create mode 100644 src/v2.x/apps/angular/demo-server/package.json create mode 100644 src/v2.x/apps/angular/demo-server/src/index.ts create mode 100644 src/v2.x/apps/angular/demo-server/tsconfig.json create mode 100644 src/v2.x/apps/angular/demo/angular.json create mode 100644 src/v2.x/apps/angular/demo/package.json create mode 100644 src/v2.x/apps/angular/demo/src/app/app.component.ts create mode 100644 src/v2.x/apps/angular/demo/src/app/app.config.ts create mode 100644 src/v2.x/apps/angular/demo/src/app/components/wildcard-tool-render.component.ts create mode 100644 src/v2.x/apps/angular/demo/src/app/routes/custom-input/custom-chat-input.component.ts create mode 100644 src/v2.x/apps/angular/demo/src/app/routes/custom-input/custom-input-chat.component.ts create mode 100644 src/v2.x/apps/angular/demo/src/app/routes/default/default-chat.component.ts create mode 100644 src/v2.x/apps/angular/demo/src/app/routes/headless/headless-chat.component.ts create mode 100644 src/v2.x/apps/angular/demo/src/app/routes/ukg-port/co-pilot-port.component.ts create mode 100644 src/v2.x/apps/angular/demo/src/assets/.gitkeep create mode 100644 src/v2.x/apps/angular/demo/src/favicon.ico create mode 100644 src/v2.x/apps/angular/demo/src/index.html create mode 100644 src/v2.x/apps/angular/demo/src/main.ts create mode 100644 src/v2.x/apps/angular/demo/tsconfig.app.json create mode 100644 src/v2.x/apps/angular/demo/tsconfig.json create mode 100644 src/v2.x/apps/angular/storybook/.storybook/main.ts create mode 100644 src/v2.x/apps/angular/storybook/.storybook/preview.css create mode 100644 src/v2.x/apps/angular/storybook/.storybook/preview.ts create mode 100644 src/v2.x/apps/angular/storybook/.storybook/styles.css create mode 100644 src/v2.x/apps/angular/storybook/angular.json create mode 100644 src/v2.x/apps/angular/storybook/components/custom-disclaimer.component.ts create mode 100644 src/v2.x/apps/angular/storybook/components/custom-input.component.ts create mode 100644 src/v2.x/apps/angular/storybook/components/custom-scroll-button.component.ts create mode 100644 src/v2.x/apps/angular/storybook/components/custom-send-button.component.ts create mode 100644 src/v2.x/apps/angular/storybook/package.json create mode 100644 src/v2.x/apps/angular/storybook/postcss.config.js create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatAssistantMessage.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatInput.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatMessageView.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatUserMessage.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatView-Actions.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatView-CSS.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatView-Components.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatView-Templates.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/CopilotChatView.stories.ts create mode 100644 src/v2.x/apps/angular/storybook/stories/Welcome.mdx create mode 100644 src/v2.x/apps/angular/storybook/tsconfig.json create mode 100644 src/v2.x/apps/docs/docs.json create mode 100644 src/v2.x/apps/docs/introduction.mdx create mode 100644 src/v2.x/apps/docs/package.json create mode 100644 src/v2.x/apps/docs/reference/copilot-chat-configuration-provider.mdx create mode 100644 src/v2.x/apps/docs/reference/copilot-chat.mdx create mode 100644 src/v2.x/apps/docs/reference/copilot-runtime.mdx create mode 100644 src/v2.x/apps/docs/reference/copilotkit-core.mdx create mode 100644 src/v2.x/apps/docs/reference/copilotkit-provider.mdx create mode 100644 src/v2.x/apps/docs/reference/frontend-tool.mdx create mode 100644 src/v2.x/apps/docs/reference/proxied-copilot-runtime-agent.mdx create mode 100644 src/v2.x/apps/docs/reference/use-agent-context.mdx create mode 100644 src/v2.x/apps/docs/reference/use-agent.mdx create mode 100644 src/v2.x/apps/docs/reference/use-copilotkit.mdx create mode 100644 src/v2.x/apps/docs/reference/use-frontend-tool.mdx create mode 100644 src/v2.x/apps/docs/reference/use-human-in-the-loop.mdx create mode 100644 src/v2.x/apps/docs/reference/use-render-tool-call.mdx create mode 100644 src/v2.x/apps/node/package.json create mode 100644 src/v2.x/apps/node/src/index.ts create mode 100644 src/v2.x/apps/node/tsconfig.json create mode 100644 src/v2.x/apps/react/demo/.gitignore create mode 100644 src/v2.x/apps/react/demo/README.md create mode 100644 src/v2.x/apps/react/demo/eslint.config.mjs create mode 100644 src/v2.x/apps/react/demo/mcp-apps/package-lock.json create mode 100644 src/v2.x/apps/react/demo/mcp-apps/package.json create mode 100644 src/v2.x/apps/react/demo/mcp-apps/server.ts create mode 100644 src/v2.x/apps/react/demo/mcp-apps/src/ui-raw.ts create mode 100644 src/v2.x/apps/react/demo/mcp-apps/tsconfig.json create mode 100644 src/v2.x/apps/react/demo/mcp-apps/ui-raw.html create mode 100644 src/v2.x/apps/react/demo/mcp-apps/vite.config.ts create mode 100644 src/v2.x/apps/react/demo/next.config.ts create mode 100644 src/v2.x/apps/react/demo/package.json create mode 100644 src/v2.x/apps/react/demo/postcss.config.mjs create mode 100644 src/v2.x/apps/react/demo/public/file.svg create mode 100644 src/v2.x/apps/react/demo/public/globe.svg create mode 100644 src/v2.x/apps/react/demo/public/next.svg create mode 100644 src/v2.x/apps/react/demo/public/sandbox.html create mode 100644 src/v2.x/apps/react/demo/public/vercel.svg create mode 100644 src/v2.x/apps/react/demo/public/window.svg create mode 100644 src/v2.x/apps/react/demo/src/app/api/copilotkit-mcp/[[...slug]]/route.ts create mode 100644 src/v2.x/apps/react/demo/src/app/api/copilotkit-single/route.ts create mode 100644 src/v2.x/apps/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts create mode 100644 src/v2.x/apps/react/demo/src/app/favicon.ico create mode 100644 src/v2.x/apps/react/demo/src/app/globals.css create mode 100644 src/v2.x/apps/react/demo/src/app/layout.tsx create mode 100644 src/v2.x/apps/react/demo/src/app/mcp-apps/page.tsx create mode 100644 src/v2.x/apps/react/demo/src/app/page.tsx create mode 100644 src/v2.x/apps/react/demo/src/app/popup/page.tsx create mode 100644 src/v2.x/apps/react/demo/src/app/sidebar/page.tsx create mode 100644 src/v2.x/apps/react/demo/src/app/single/page.tsx create mode 100644 src/v2.x/apps/react/demo/tsconfig.json create mode 100644 src/v2.x/apps/react/demo/vitest.config.mjs create mode 100644 src/v2.x/apps/react/storybook/.storybook/main.ts create mode 100644 src/v2.x/apps/react/storybook/.storybook/preview.css create mode 100644 src/v2.x/apps/react/storybook/.storybook/preview.ts create mode 100644 src/v2.x/apps/react/storybook/package.json create mode 100644 src/v2.x/apps/react/storybook/stories/Button.stories.ts create mode 100644 src/v2.x/apps/react/storybook/stories/Button.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/Configure.mdx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotAssistantMessage.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotChatInput.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotChatMessageView.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotChatSuggestionPill.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotChatSuggestionView.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotChatToggleButton.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotChatUserMessage.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotChatView.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotKitInspector.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotPopup.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotSidebarView.stories.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/CopilotStoryLayout.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/Header.stories.ts create mode 100644 src/v2.x/apps/react/storybook/stories/Header.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/Page.stories.ts create mode 100644 src/v2.x/apps/react/storybook/stories/Page.tsx create mode 100644 src/v2.x/apps/react/storybook/stories/assets/accessibility.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/accessibility.svg create mode 100644 src/v2.x/apps/react/storybook/stories/assets/addon-library.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/assets.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/avif-test-image.avif create mode 100644 src/v2.x/apps/react/storybook/stories/assets/context.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/discord.svg create mode 100644 src/v2.x/apps/react/storybook/stories/assets/docs.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/figma-plugin.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/github.svg create mode 100644 src/v2.x/apps/react/storybook/stories/assets/share.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/styling.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/testing.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/theming.png create mode 100644 src/v2.x/apps/react/storybook/stories/assets/tutorials.svg create mode 100644 src/v2.x/apps/react/storybook/stories/assets/youtube.svg create mode 100644 src/v2.x/apps/react/storybook/stories/button.css create mode 100644 src/v2.x/apps/react/storybook/stories/header.css create mode 100644 src/v2.x/apps/react/storybook/stories/page.css create mode 100644 src/v2.x/apps/react/storybook/tailwind.config.js create mode 100644 src/v2.x/apps/react/storybook/tsconfig.json create mode 100644 src/v2.x/docs/ANGULAR_DESIGN.md create mode 100644 src/v2.x/docs/REACT_API.md create mode 100644 src/v2.x/package.json create mode 100644 src/v2.x/packages/agent/package.json create mode 100644 src/v2.x/packages/agent/src/__tests__/basic-agent.test.ts create mode 100644 src/v2.x/packages/agent/src/__tests__/config-tools-execution.test.ts create mode 100644 src/v2.x/packages/agent/src/__tests__/property-overrides.test.ts create mode 100644 src/v2.x/packages/agent/src/__tests__/state-tools.test.ts create mode 100644 src/v2.x/packages/agent/src/__tests__/test-helpers.ts create mode 100644 src/v2.x/packages/agent/src/__tests__/utils.test.ts create mode 100644 src/v2.x/packages/agent/src/index.ts create mode 100644 src/v2.x/packages/agent/tsconfig.json create mode 100644 src/v2.x/packages/agent/tsup.config.ts create mode 100644 src/v2.x/packages/agent/vitest.config.ts create mode 100644 src/v2.x/packages/angular/.npmignore create mode 100644 src/v2.x/packages/angular/LICENSE create mode 100644 src/v2.x/packages/angular/README.md create mode 100644 src/v2.x/packages/angular/eslint.config.mjs create mode 100644 src/v2.x/packages/angular/ng-package.json create mode 100644 src/v2.x/packages/angular/package.json create mode 100644 src/v2.x/packages/angular/src/index.ts create mode 100644 src/v2.x/packages/angular/src/lib/agent-context.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/agent-context.ts create mode 100644 src/v2.x/packages/angular/src/lib/agent.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/agent.ts create mode 100644 src/v2.x/packages/angular/src/lib/chat-config.ts create mode 100644 src/v2.x/packages/angular/src/lib/chat-state.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/__tests__/copilot-chat-assistant-message.component.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/__tests__/copilot-chat-input.component.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/__tests__/copilot-chat-message-view.component.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/__tests__/copilot-chat-user-message.component.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-buttons.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-renderer.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-toolbar.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-assistant-message.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-assistant-message.types.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-audio-recorder.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-buttons.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-input-defaults.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-input.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-input.types.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.types.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-buttons.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-renderer.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-toolbar.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.types.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-disclaimer.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-feather.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-handlers.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-input-container.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-to-bottom-button.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.types.ts create mode 100644 src/v2.x/packages/angular/src/lib/components/chat/copilot-chat.ts create mode 100644 src/v2.x/packages/angular/src/lib/config.ts create mode 100644 src/v2.x/packages/angular/src/lib/copilotkit.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/copilotkit.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-agent-context.directive.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-chat-config.directive.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-config.directive.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool-simple.directive.spec.ts.disabled create mode 100644 src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool.directive.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-human-in-the-loop.directive.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/copilotkit-agent-context.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/stick-to-bottom.ts create mode 100644 src/v2.x/packages/angular/src/lib/directives/tooltip.ts create mode 100644 src/v2.x/packages/angular/src/lib/human-in-the-loop.ts create mode 100644 src/v2.x/packages/angular/src/lib/render-tool-calls.ts create mode 100644 src/v2.x/packages/angular/src/lib/resize-observer.ts create mode 100644 src/v2.x/packages/angular/src/lib/scroll-position.ts create mode 100644 src/v2.x/packages/angular/src/lib/slots/__tests__/slot.utils.spec.ts create mode 100644 src/v2.x/packages/angular/src/lib/slots/copilot-slot.ts create mode 100644 src/v2.x/packages/angular/src/lib/slots/index.ts create mode 100644 src/v2.x/packages/angular/src/lib/slots/slot.types.ts create mode 100644 src/v2.x/packages/angular/src/lib/slots/slot.utils.ts create mode 100644 src/v2.x/packages/angular/src/lib/tools.ts create mode 100644 src/v2.x/packages/angular/src/lib/utils.ts create mode 100644 src/v2.x/packages/angular/src/public-api.ts create mode 100644 src/v2.x/packages/angular/src/styles/globals.css create mode 100644 src/v2.x/packages/angular/src/styles/index.css create mode 100644 src/v2.x/packages/angular/src/test-setup.ts create mode 100644 src/v2.x/packages/angular/tsconfig.json create mode 100644 src/v2.x/packages/angular/tsconfig.spec.json create mode 100644 src/v2.x/packages/angular/vitest.config.mts create mode 100644 src/v2.x/packages/core/.npmignore create mode 100644 src/v2.x/packages/core/eslint.config.mjs create mode 100644 src/v2.x/packages/core/package.json create mode 100644 src/v2.x/packages/core/src/__tests__/core-agent-constraints.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-agent-id-validation.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-basic-functionality.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-basic.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-edge-cases.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-error-handling.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-follow-up.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-full.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-headers.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-simple.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-suggestions-e2e.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-suggestions.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-tool-minimal.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-tool-simple.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/core-wildcard.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/import.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/proxied-runtime-transport.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/simple.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/state-manager.test.ts create mode 100644 src/v2.x/packages/core/src/__tests__/test-utils.ts create mode 100644 src/v2.x/packages/core/src/agent.ts create mode 100644 src/v2.x/packages/core/src/core/__tests__/run-handler-schema.test.ts create mode 100644 src/v2.x/packages/core/src/core/agent-registry.ts create mode 100644 src/v2.x/packages/core/src/core/context-store.ts create mode 100644 src/v2.x/packages/core/src/core/core.ts create mode 100644 src/v2.x/packages/core/src/core/index.ts create mode 100644 src/v2.x/packages/core/src/core/run-handler.ts create mode 100644 src/v2.x/packages/core/src/core/state-manager.ts create mode 100644 src/v2.x/packages/core/src/core/suggestion-engine.ts create mode 100644 src/v2.x/packages/core/src/index.ts create mode 100644 src/v2.x/packages/core/src/types.ts create mode 100644 src/v2.x/packages/core/src/utils/markdown.ts create mode 100644 src/v2.x/packages/core/tsconfig.json create mode 100644 src/v2.x/packages/core/tsup.config.ts create mode 100644 src/v2.x/packages/core/vitest.config.mjs create mode 100644 src/v2.x/packages/demo-agents/package.json create mode 100644 src/v2.x/packages/demo-agents/src/index.ts create mode 100644 src/v2.x/packages/demo-agents/src/openai.ts create mode 100644 src/v2.x/packages/demo-agents/src/slow-tool-call-streaming.ts create mode 100644 src/v2.x/packages/demo-agents/tsconfig.json create mode 100644 src/v2.x/packages/demo-agents/tsup.config.ts create mode 100644 src/v2.x/packages/demo-agents/vitest.config.ts create mode 100644 src/v2.x/packages/eslint-config/README.md create mode 100644 src/v2.x/packages/eslint-config/base.js create mode 100644 src/v2.x/packages/eslint-config/next.js create mode 100644 src/v2.x/packages/eslint-config/package.json create mode 100644 src/v2.x/packages/eslint-config/react-internal.js create mode 100644 src/v2.x/packages/react/.npmignore create mode 100644 src/v2.x/packages/react/components.json create mode 100644 src/v2.x/packages/react/eslint.config.mjs create mode 100644 src/v2.x/packages/react/package.json create mode 100644 src/v2.x/packages/react/postcss.config.js create mode 100644 src/v2.x/packages/react/src/__tests__/setup.ts create mode 100644 src/v2.x/packages/react/src/__tests__/utils/test-helpers.tsx create mode 100644 src/v2.x/packages/react/src/components/CopilotKitInspector.tsx create mode 100644 src/v2.x/packages/react/src/components/MCPAppsActivityRenderer.tsx create mode 100644 src/v2.x/packages/react/src/components/WildcardToolCallRender.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChat.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatAssistantMessage.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatAudioRecorder.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatInput.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatMessageView.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatSuggestionPill.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatSuggestionView.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatToggleButton.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatToolCallsView.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatUserMessage.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotChatView.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotModalHeader.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotPopup.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotPopupView.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotSidebar.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/CopilotSidebarView.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/CopilotChat.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/CopilotChatInput.test.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/CopilotChatMessageView.test.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/components/chat/__tests__/setup.ts create mode 100644 src/v2.x/packages/react/src/components/chat/index.ts create mode 100644 src/v2.x/packages/react/src/components/index.ts create mode 100644 src/v2.x/packages/react/src/components/ui/button.tsx create mode 100644 src/v2.x/packages/react/src/components/ui/dropdown-menu.tsx create mode 100644 src/v2.x/packages/react/src/components/ui/tooltip.tsx create mode 100644 src/v2.x/packages/react/src/hooks/__tests__/use-agent-context.test.tsx create mode 100644 src/v2.x/packages/react/src/hooks/__tests__/use-agent.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/hooks/__tests__/use-configure-suggestions.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/hooks/__tests__/use-frontend-tool.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/hooks/__tests__/use-suggestions.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/hooks/index.ts create mode 100644 src/v2.x/packages/react/src/hooks/use-agent-context.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-agent.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-configure-suggestions.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-frontend-tool.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-human-in-the-loop.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-keyboard-height.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-render-activity-message.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-render-custom-messages.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-render-tool-call.tsx create mode 100644 src/v2.x/packages/react/src/hooks/use-suggestions.tsx create mode 100644 src/v2.x/packages/react/src/index.ts create mode 100644 src/v2.x/packages/react/src/lib/__tests__/completePartialMarkdown.test.ts create mode 100644 src/v2.x/packages/react/src/lib/__tests__/renderSlot.test.tsx create mode 100644 src/v2.x/packages/react/src/lib/react-core.ts create mode 100644 src/v2.x/packages/react/src/lib/slots.tsx create mode 100644 src/v2.x/packages/react/src/lib/utils.ts create mode 100644 src/v2.x/packages/react/src/providers/CopilotChatConfigurationProvider.tsx create mode 100644 src/v2.x/packages/react/src/providers/CopilotKitProvider.tsx create mode 100644 src/v2.x/packages/react/src/providers/__tests__/CopilotChatConfigurationProvider.test.tsx create mode 100644 src/v2.x/packages/react/src/providers/__tests__/CopilotKitProvider.renderCustomMessages.e2e.test.tsx create mode 100644 src/v2.x/packages/react/src/providers/__tests__/CopilotKitProvider.test.tsx create mode 100644 src/v2.x/packages/react/src/providers/__tests__/CopilotKitProvider.wildcard.test.tsx create mode 100644 src/v2.x/packages/react/src/providers/index.ts create mode 100644 src/v2.x/packages/react/src/styles/globals.css create mode 100644 src/v2.x/packages/react/src/types/__tests__/defineToolCallRenderer.test.tsx create mode 100644 src/v2.x/packages/react/src/types/defineToolCallRenderer.ts create mode 100644 src/v2.x/packages/react/src/types/frontend-tool.ts create mode 100644 src/v2.x/packages/react/src/types/human-in-the-loop.ts create mode 100644 src/v2.x/packages/react/src/types/index.ts create mode 100644 src/v2.x/packages/react/src/types/react-activity-message-renderer.ts create mode 100644 src/v2.x/packages/react/src/types/react-custom-message-renderer.ts create mode 100644 src/v2.x/packages/react/src/types/react-tool-call-renderer.ts create mode 100644 src/v2.x/packages/react/tailwind.config.js create mode 100644 src/v2.x/packages/react/test.css create mode 100644 src/v2.x/packages/react/tsconfig.json create mode 100644 src/v2.x/packages/react/tsup.config.ts create mode 100644 src/v2.x/packages/react/vitest.config.mjs create mode 100644 src/v2.x/packages/runtime/.cursor/rules/runtime.always.mdc create mode 100644 src/v2.x/packages/runtime/.npmignore create mode 100644 src/v2.x/packages/runtime/eslint.config.mjs create mode 100644 src/v2.x/packages/runtime/package.json create mode 100644 src/v2.x/packages/runtime/src/__tests__/express-body-order.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/get-runtime-info.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/handle-run.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/handle-transcribe.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/in-process-agent-runner-messages.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/in-process-agent-runner.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/middleware-express.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/middleware-single-express.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/middleware-single.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/middleware.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/routing-express.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/routing-single-express.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/routing-single.test.ts create mode 100644 src/v2.x/packages/runtime/src/__tests__/routing.test.ts create mode 100644 src/v2.x/packages/runtime/src/endpoints/express-single.ts create mode 100644 src/v2.x/packages/runtime/src/endpoints/express-utils.ts create mode 100644 src/v2.x/packages/runtime/src/endpoints/express.ts create mode 100644 src/v2.x/packages/runtime/src/endpoints/hono-single.ts create mode 100644 src/v2.x/packages/runtime/src/endpoints/hono.ts create mode 100644 src/v2.x/packages/runtime/src/endpoints/index.ts create mode 100644 src/v2.x/packages/runtime/src/endpoints/single-route-helpers.ts create mode 100644 src/v2.x/packages/runtime/src/express.ts create mode 100644 src/v2.x/packages/runtime/src/handler.ts create mode 100644 src/v2.x/packages/runtime/src/handlers/get-runtime-info.ts create mode 100644 src/v2.x/packages/runtime/src/handlers/handle-connect.ts create mode 100644 src/v2.x/packages/runtime/src/handlers/handle-run.ts create mode 100644 src/v2.x/packages/runtime/src/handlers/handle-stop.ts create mode 100644 src/v2.x/packages/runtime/src/handlers/handle-transcribe.ts create mode 100644 src/v2.x/packages/runtime/src/index.ts create mode 100644 src/v2.x/packages/runtime/src/middleware.ts create mode 100644 src/v2.x/packages/runtime/src/runner/__tests__/finalize-events.test.ts create mode 100644 src/v2.x/packages/runtime/src/runner/__tests__/in-memory-runner.e2e.test.ts create mode 100644 src/v2.x/packages/runtime/src/runner/__tests__/in-memory-runner.test.ts create mode 100644 src/v2.x/packages/runtime/src/runner/agent-runner.ts create mode 100644 src/v2.x/packages/runtime/src/runner/in-memory.ts create mode 100644 src/v2.x/packages/runtime/src/runner/index.ts create mode 100644 src/v2.x/packages/runtime/src/runtime.ts create mode 100644 src/v2.x/packages/runtime/src/transcription-service/transcription-service-openai.ts create mode 100644 src/v2.x/packages/runtime/src/transcription-service/transcription-service.ts create mode 100644 src/v2.x/packages/runtime/tsconfig.json create mode 100644 src/v2.x/packages/runtime/tsup.config.ts create mode 100644 src/v2.x/packages/runtime/vitest.config.mjs create mode 100644 src/v2.x/packages/shared/.npmignore create mode 100644 src/v2.x/packages/shared/eslint.config.mjs create mode 100644 src/v2.x/packages/shared/package.json create mode 100644 src/v2.x/packages/shared/src/constants.ts create mode 100644 src/v2.x/packages/shared/src/finalize-events.ts create mode 100644 src/v2.x/packages/shared/src/index.ts create mode 100644 src/v2.x/packages/shared/src/logger.ts create mode 100644 src/v2.x/packages/shared/src/types.ts create mode 100644 src/v2.x/packages/shared/src/utils.ts create mode 100644 src/v2.x/packages/shared/tsconfig.json create mode 100644 src/v2.x/packages/shared/tsup.config.ts create mode 100644 src/v2.x/packages/sqlite-runner/eslint.config.mjs create mode 100644 src/v2.x/packages/sqlite-runner/package.json create mode 100644 src/v2.x/packages/sqlite-runner/src/__tests__/sqlite-runner.e2e.test.ts create mode 100644 src/v2.x/packages/sqlite-runner/src/__tests__/sqlite-runner.test.ts create mode 100644 src/v2.x/packages/sqlite-runner/src/index.ts create mode 100644 src/v2.x/packages/sqlite-runner/src/sqlite-runner.ts create mode 100644 src/v2.x/packages/sqlite-runner/tsconfig.json create mode 100644 src/v2.x/packages/sqlite-runner/tsup.config.ts create mode 100644 src/v2.x/packages/sqlite-runner/vitest.config.mjs create mode 100644 src/v2.x/packages/typescript-config/base.json create mode 100644 src/v2.x/packages/typescript-config/nextjs.json create mode 100644 src/v2.x/packages/typescript-config/package.json create mode 100644 src/v2.x/packages/typescript-config/react-library.json create mode 100644 src/v2.x/packages/web-inspector/eslint.config.mjs create mode 100644 src/v2.x/packages/web-inspector/package.json create mode 100644 src/v2.x/packages/web-inspector/src/__tests__/web-inspector.spec.ts create mode 100644 src/v2.x/packages/web-inspector/src/assets/inspector-logo-icon.svg create mode 100644 src/v2.x/packages/web-inspector/src/assets/inspector-logo.svg create mode 100644 src/v2.x/packages/web-inspector/src/components.d.ts create mode 100644 src/v2.x/packages/web-inspector/src/index.ts create mode 100644 src/v2.x/packages/web-inspector/src/lib/context-helpers.ts create mode 100644 src/v2.x/packages/web-inspector/src/lib/persistence.ts create mode 100644 src/v2.x/packages/web-inspector/src/lib/types.ts create mode 100644 src/v2.x/packages/web-inspector/src/styles/tailwind.css create mode 100644 src/v2.x/packages/web-inspector/src/types/css.d.ts create mode 100644 src/v2.x/packages/web-inspector/src/types/svg.d.ts create mode 100644 src/v2.x/packages/web-inspector/tsconfig.json create mode 100644 src/v2.x/packages/web-inspector/tsup.config.ts create mode 100644 src/v2.x/packages/web-inspector/vitest.config.ts create mode 100644 src/v2.x/pnpm-lock.yaml create mode 100644 src/v2.x/pnpm-workspace.yaml create mode 100644 src/v2.x/turbo.json diff --git a/.cursor/rules/agent-development.mdc b/.cursor/rules/agent-development.mdc index d75071229c..29027cdb89 100644 --- a/.cursor/rules/agent-development.mdc +++ b/.cursor/rules/agent-development.mdc @@ -23,7 +23,7 @@ Python agents are typically in folders named `agent-py/` or `agent/` and use the - **[langgraph-tutorial-quickstart/agent-py/](mdc:examples/langgraph-tutorial-quickstart/agent-py/)** - LangGraph integration ### JavaScript Agents -JavaScript agents are in folders named `agent-js/` and use the [sdk-js/](mdc:CopilotKit/packages/sdk-js/) package. +JavaScript agents are in folders named `agent-js/` and use the [sdk-js/](mdc:src/v1.x/packages/sdk-js/) package. #### JavaScript Agent Examples - **[coagents-starter/agent-js/](mdc:examples/coagents-starter/agent-js/)** - Basic JavaScript agent setup diff --git a/.cursor/rules/copilotkit-architecture.mdc b/.cursor/rules/copilotkit-architecture.mdc index e364d682cd..451cc7414a 100644 --- a/.cursor/rules/copilotkit-architecture.mdc +++ b/.cursor/rules/copilotkit-architecture.mdc @@ -6,18 +6,18 @@ alwaysApply: false # CopilotKit Architecture Overview ## Core Library Structure -The main CopilotKit library is located in [CopilotKit/](mdc:CopilotKit/) with the following key packages: +The main CopilotKit library is located in [CopilotKit/](mdc:src/v1.x/) with the following key packages: ### React Packages -- **[react-core/](mdc:CopilotKit/packages/react-core/)** - Core React components and hooks for CopilotKit -- **[react-textarea/](mdc:CopilotKit/packages/react-textarea/)** - Textarea component with AI integration -- **[react-ui/](mdc:CopilotKit/packages/react-ui/)** - UI components for copilot interfaces +- **[react-core/](mdc:src/v1.x/packages/react-core/)** - Core React components and hooks for CopilotKit +- **[react-textarea/](mdc:src/v1.x/packages/react-textarea/)** - Textarea component with AI integration +- **[react-ui/](mdc:src/v1.x/packages/react-ui/)** - UI components for copilot interfaces ### Runtime & SDK -- **[runtime/](mdc:CopilotKit/packages/runtime/)** - Core runtime for executing copilot actions -- **[runtime-client-gql/](mdc:CopilotKit/packages/runtime-client-gql/)** - GraphQL client for runtime communication -- **[sdk-js/](mdc:CopilotKit/packages/sdk-js/)** - JavaScript SDK for backend integration -- **[shared/](mdc:CopilotKit/packages/shared/)** - Shared utilities and types +- **[runtime/](mdc:src/v1.x/packages/runtime/)** - Core runtime for executing copilot actions +- **[runtime-client-gql/](mdc:src/v1.x/packages/runtime-client-gql/)** - GraphQL client for runtime communication +- **[sdk-js/](mdc:src/v1.x/packages/sdk-js/)** - JavaScript SDK for backend integration +- **[shared/](mdc:src/v1.x/packages/shared/)** - Shared utilities and types ### Python SDK - **[sdk-python/](mdc:sdk-python/)** - Python SDK with integrations for: @@ -39,7 +39,7 @@ All examples are in [examples/](mdc:examples/) and typically follow this pattern ## Development Workflow - Examples use the local packages via workspace references -- [package.json](mdc:CopilotKit/package.json) contains workspace configuration -- Scripts in [scripts/](mdc:CopilotKit/scripts/) for development, testing, and releases +- [package.json](mdc:src/v1.x/package.json) contains workspace configuration +- Scripts in [scripts/](mdc:src/v1.x/scripts/) for development, testing, and releases diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc index 9c9ca89d89..84e97c361a 100644 --- a/.cursor/rules/development-workflow.mdc +++ b/.cursor/rules/development-workflow.mdc @@ -7,20 +7,20 @@ alwaysApply: false ## Workspace Setup CopilotKit uses a monorepo structure with: -- **[CopilotKit/package.json](mdc:CopilotKit/package.json)** - Main workspace configuration -- **[CopilotKit/packages/](mdc:CopilotKit/packages)** - Core library packages +- **[src/v1.x/package.json](mdc:src/v1.x/package.json)** - Main workspace configuration +- **[src/v1.x/packages/](mdc:src/v1.x/packages)** - Core library packages - **[examples/](mdc:examples)** - Example applications ## Development Scripts -Located in [CopilotKit/scripts/](mdc:CopilotKit/scripts): +Located in [src/v1.x/scripts/](mdc:src/v1.x/scripts): ### Development Scripts -- **[develop/](mdc:CopilotKit/scripts/develop)** - Development environment setup -- **[qa/](mdc:CopilotKit/scripts/qa)** - Quality assurance and testing scripts -- **[release/](mdc:CopilotKit/scripts/release)** - Release automation scripts +- **[develop/](mdc:src/v1.x/scripts/develop)** - Development environment setup +- **[qa/](mdc:src/v1.x/scripts/qa)** - Quality assurance and testing scripts +- **[release/](mdc:src/v1.x/scripts/release)** - Release automation scripts ### Documentation Scripts -- **[docs/](mdc:CopilotKit/scripts/docs)** - Documentation generation and management +- **[docs/](mdc:src/v1.x/scripts/docs)** - Documentation generation and management ## Package Management - Uses pnpm workspaces for package management @@ -39,11 +39,11 @@ Located in [CopilotKit/scripts/](mdc:CopilotKit/scripts): - **[snippets/](mdc:docs/snippets)** - Code snippets for documentation ## Configuration Files -- **[CopilotKit.code-workspace](mdc:CopilotKit/CopilotKit.code-workspace)** - VS Code workspace settings -- **[utilities/](mdc:CopilotKit/utilities)** - Shared utilities: - - [eslint-config-custom/](mdc:CopilotKit/utilities/eslint-config-custom) - ESLint configuration - - [tailwind-config/](mdc:CopilotKit/utilities/tailwind-config) - Tailwind CSS configuration - - [tsconfig/](mdc:CopilotKit/utilities/tsconfig) - TypeScript configurations +- **[CopilotKit.code-workspace](mdc:src/v1.x/CopilotKit.code-workspace)** - VS Code workspace settings +- **[utilities/](mdc:src/v1.x/utilities)** - Shared utilities: + - [eslint-config-custom/](mdc:src/v1.x/utilities/eslint-config-custom) - ESLint configuration + - [tailwind-config/](mdc:src/v1.x/utilities/tailwind-config) - Tailwind CSS configuration + - [tsconfig/](mdc:src/v1.x/utilities/tsconfig) - TypeScript configurations ## Infrastructure - **[infra/](mdc:infra)** - AWS CDK infrastructure code diff --git a/.cursor/rules/frontend-development.mdc b/.cursor/rules/frontend-development.mdc index 2e0000522a..132a4d745d 100644 --- a/.cursor/rules/frontend-development.mdc +++ b/.cursor/rules/frontend-development.mdc @@ -8,20 +8,20 @@ alwaysApply: false ## Core React Packages ### React Components -- **[react-core/](mdc:CopilotKit/packages/react-core/)** - Core React components and hooks +- **[react-core/](mdc:src/v1.x/packages/react-core/)** - Core React components and hooks - `CopilotProvider` - Main provider component - `useCopilotChat` - Chat functionality hook - `useCopilotAction` - Action definition hook - `useCopilotReadable` - State reading hook ### UI Components -- **[react-ui/](mdc:CopilotKit/packages/react-ui/)** - Pre-built UI components +- **[react-ui/](mdc:src/v1.x/packages/react-ui/)** - Pre-built UI components - `CopilotChat` - Chat interface component - `CopilotPopup` - Popup chat component - `CopilotSidebar` - Sidebar chat component ### Specialized Components -- **[react-textarea/](mdc:CopilotKit/packages/react-textarea/)** - AI-enhanced textarea +- **[react-textarea/](mdc:src/v1.x/packages/react-textarea/)** - AI-enhanced textarea - `CopilotTextarea` - Smart textarea with AI suggestions - Auto-completion and suggestions @@ -115,8 +115,8 @@ function MyComponent() { ## Integration with Agents ### Runtime Configuration -- **[runtime/](mdc:CopilotKit/packages/runtime/)** - Core runtime for agent communication -- **[runtime-client-gql/](mdc:CopilotKit/packages/runtime-client-gql/)** - GraphQL client +- **[runtime/](mdc:src/v1.x/packages/runtime/)** - Core runtime for agent communication +- **[runtime-client-gql/](mdc:src/v1.x/packages/runtime-client-gql/)** - GraphQL client ### Agent Communication - Frontend components communicate with agents via the runtime @@ -145,7 +145,7 @@ function MyComponent() { ### Styling and Theming - Uses Tailwind CSS for styling -- Shared configuration in [utilities/tailwind-config/](mdc:CopilotKit/utilities/tailwind-config/) +- Shared configuration in [utilities/tailwind-config/](mdc:src/v1.x/utilities/tailwind-config/) - Custom components in [components.json](mdc:docs/components.json) - UI components follow modern design patterns diff --git a/.cursor/rules/quick-reference.mdc b/.cursor/rules/quick-reference.mdc index 9da8093ead..5e6e18f6eb 100644 --- a/.cursor/rules/quick-reference.mdc +++ b/.cursor/rules/quick-reference.mdc @@ -9,8 +9,8 @@ alwaysApply: false ### Build a Simple Copilot - Start with [copilot-chat-with-your-data/](mdc:examples/copilot-chat-with-your-data) -- Use [react-core/](mdc:CopilotKit/packages/react-core) for basic integration -- Add [react-ui/](mdc:CopilotKit/packages/react-ui) for pre-built components +- Use [react-core/](mdc:src/v1.x/packages/react-core) for basic integration +- Add [react-ui/](mdc:src/v1.x/packages/react-ui) for pre-built components ### Create Multi-Agent Systems - Check [coagents-starter/](mdc:examples/coagents-starter) for basic setup @@ -27,18 +27,18 @@ alwaysApply: false - See [coagents-starter/agent-py/](mdc:examples/coagents-starter/agent-py) for example ### Develop JavaScript Agents -- Use [sdk-js/](mdc:CopilotKit/packages/sdk-js) package +- Use [sdk-js/](mdc:src/v1.x/packages/sdk-js) package - Check [coagents-starter/agent-js/](mdc:examples/coagents-starter/agent-js) for example ### Customize UI Components - Browse [registry/](mdc:registry) for reusable components - Check [docs/components/react/](mdc:docs/components/react) for documentation -- Use [react-ui/](mdc:CopilotKit/packages/react-ui) for pre-built components +- Use [react-ui/](mdc:src/v1.x/packages/react-ui) for pre-built components ### Set Up Development Environment -- Review [CopilotKit/package.json](mdc:CopilotKit/package.json) for workspace setup -- Use scripts in [CopilotKit/scripts/](mdc:CopilotKit/scripts) -- Check [utilities/](mdc:CopilotKit/utilities) for shared configurations +- Review [src/v1.x/package.json](mdc:src/v1.x/package.json) for workspace setup +- Use scripts in [src/v1.x/scripts/](mdc:src/v1.x/scripts) +- Check [utilities/](mdc:src/v1.x/utilities) for shared configurations ### Run Tests - Use [examples/e2e/](mdc:examples/e2e) for end-to-end testing @@ -50,9 +50,9 @@ alwaysApply: false - See [docs/content/docs/](mdc:docs/content/docs) for markdown files ## Key Files to Know -- **[CopilotKit/package.json](mdc:CopilotKit/package.json)** - Main workspace configuration +- **[src/v1.x/package.json](mdc:src/v1.x/package.json)** - Main workspace configuration - **[sdk-python/pyproject.toml](mdc:sdk-python/pyproject.toml)** - Python SDK configuration -- **[CopilotKit.code-workspace](mdc:CopilotKit/CopilotKit.code-workspace)** - VS Code workspace settings +- **[CopilotKit.code-workspace](mdc:src/v1.x/CopilotKit.code-workspace)** - VS Code workspace settings - **[examples/shared/](mdc:examples/shared)** - Shared utilities and templates ## Common Patterns @@ -63,7 +63,7 @@ alwaysApply: false ## Getting Started Checklist 1. Clone the repository -2. Navigate to [CopilotKit/](mdc:CopilotKit) directory +2. Navigate to [src/v1.x/](mdc:src/v1.x) directory 3. Run `npm install` to set up workspace 4. Choose an example from [examples/](mdc:examples) 5. Follow the example's README.md diff --git a/.cursor/rules/suggestions-development.mdc b/.cursor/rules/suggestions-development.mdc index 47442956b2..760beb5c87 100644 --- a/.cursor/rules/suggestions-development.mdc +++ b/.cursor/rules/suggestions-development.mdc @@ -31,16 +31,16 @@ Users can configure suggestions via `useCopilotChatSuggestions` hook which regis ## Core files - **@react-ui** - - [Suggestions.tsx](mdc:CopilotKit/packages/react-ui/src/components/chat/Suggestions.tsx) - How suggestions are rendered - - [Messages.tsx](mdc:CopilotKit/packages/react-ui/src/components/chat/Messages.tsx) - Includes relevant code for what renders [Suggestions.tsx](mdc:CopilotKit/packages/react-ui/src/components/chat/Suggestions.tsx) - - [Chat.tsx](mdc:CopilotKit/packages/react-ui/src/components/chat/Chat.tsx) - Includes relevant logic for our prebuilt components loading suggestions - - [use-copilot-chat-suggestions.tsx](mdc:CopilotKit/packages/react-ui/src/hooks/use-copilot-chat-suggestions.tsx) - How users specify the configuration for their suggestions - - [suggestions.css](mdc:CopilotKit/packages/react-ui/src/css/suggestions.css) - Styling for suggestions + - [Suggestions.tsx](mdc:src/v1.x/packages/react-ui/src/components/chat/Suggestions.tsx) - How suggestions are rendered + - [Messages.tsx](mdc:src/v1.x/packages/react-ui/src/components/chat/Messages.tsx) - Includes relevant code for what renders [Suggestions.tsx](mdc:src/v1.x/packages/react-ui/src/components/chat/Suggestions.tsx) + - [Chat.tsx](mdc:src/v1.x/packages/react-ui/src/components/chat/Chat.tsx) - Includes relevant logic for our prebuilt components loading suggestions + - [use-copilot-chat-suggestions.tsx](mdc:src/v1.x/packages/react-ui/src/hooks/use-copilot-chat-suggestions.tsx) - How users specify the configuration for their suggestions + - [suggestions.css](mdc:src/v1.x/packages/react-ui/src/css/suggestions.css) - Styling for suggestions - **@react-core** - - [copilot-context.tsx](mdc:CopilotKit/packages/react-core/src/context/copilot-context.tsx) - Where the actual suggestions are stored, the "provider" or "context" - - [use-copilot-chat.ts](mdc:CopilotKit/packages/react-core/src/hooks/use-copilot-chat.ts) - Hook that controls and contains logic for suggestions, often referred to as "headless UI" - - [suggestions.ts](mdc:CopilotKit/packages/react-core/src/utils/suggestions.ts) - Core suggestion generation logic with streaming validation and error handling - - [suggestions-constants.ts](mdc:CopilotKit/packages/react-core/src/utils/suggestions-constants.ts) - Retry configuration constants + - [copilot-context.tsx](mdc:src/v1.x/packages/react-core/src/context/copilot-context.tsx) - Where the actual suggestions are stored, the "provider" or "context" + - [use-copilot-chat.ts](mdc:src/v1.x/packages/react-core/src/hooks/use-copilot-chat.ts) - Hook that controls and contains logic for suggestions, often referred to as "headless UI" + - [suggestions.ts](mdc:src/v1.x/packages/react-core/src/utils/suggestions.ts) - Core suggestion generation logic with streaming validation and error handling + - [suggestions-constants.ts](mdc:src/v1.x/packages/react-core/src/utils/suggestions-constants.ts) - Retry configuration constants ## How Suggestions Work diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31214830d3..49cfd1b72d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,9 @@ on: - 'examples/**' - '.github/workflows/demos_preview.yml' - '.github/workflows/release.yml' - - 'CopilotKit/packages/**/package.json' - - 'CopilotKit/packages/**/CHANGELOG.md' - - 'CopilotKit/.changeset/**' + - 'src/v1.x/packages/**/package.json' + - 'src/v1.x/packages/**/CHANGELOG.md' + - 'src/v1.x/.changeset/**' pull_request: branches: [main] paths-ignore: @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: 'CopilotKit' + working-directory: 'src/v1.x' strategy: matrix: node-version: [20.x, 18.x] diff --git a/.github/workflows/custom-prerelease.yml b/.github/workflows/custom-prerelease.yml index 0dd888a54b..3a08e701b1 100644 --- a/.github/workflows/custom-prerelease.yml +++ b/.github/workflows/custom-prerelease.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: "CopilotKit" + working-directory: "src/v1.x" timeout-minutes: 10 steps: - name: Checkout Repo diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index b724881e47..02e0246ad5 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -4,7 +4,7 @@ on: pull_request: paths: - "sdk-python/copilotkit/langgraph_agent.py" - - "CopilotKit/packages/sdk-js/src/langgraph.ts" + - "src/v1.x/packages/sdk-js/src/langgraph.ts" jobs: danger: diff --git a/.github/workflows/dojo-e2e.yml b/.github/workflows/dojo-e2e.yml index 29680142e1..cff9354220 100644 --- a/.github/workflows/dojo-e2e.yml +++ b/.github/workflows/dojo-e2e.yml @@ -143,11 +143,11 @@ jobs: uses: astral-sh/setup-uv@v6 - name: Install cpk dependencies - working-directory: CopilotKit/CopilotKit + working-directory: CopilotKit/src/v1.x run: pnpm install --frozen-lockfile - name: Build cpk - working-directory: CopilotKit/CopilotKit + working-directory: CopilotKit/src/v1.x env: NODE_OPTIONS: --max-old-space-size=8192 TURBO_CONCURRENCY: 2 @@ -163,7 +163,7 @@ jobs: - name: Link cpk into ag-ui working-directory: CopilotKit - run: node ../ag-ui/apps/dojo/scripts/link-cpk.js . + run: node ../ag-ui/apps/dojo/scripts/link-cpk.js src/v1.x/packages - name: Prepare dojo for e2e working-directory: ag-ui/apps/dojo diff --git a/.github/workflows/preview-envs-deploy.yml b/.github/workflows/preview-envs-deploy.yml index 800a9797be..5b3b2242e1 100644 --- a/.github/workflows/preview-envs-deploy.yml +++ b/.github/workflows/preview-envs-deploy.yml @@ -116,19 +116,19 @@ jobs: - name: (UI, Local Deps) Install monorepo if: matrix.app.type == 'ui' || matrix.app.type == 'next-openai' - working-directory: ./CopilotKit + working-directory: ./src/v1.x run: pnpm install --frozen-lockfile - name: (UI, Local Deps) Build @copilotkit packages if: matrix.app.type == 'ui' || matrix.app.type == 'next-openai' - working-directory: ./CopilotKit + working-directory: ./src/v1.x run: pnpm run freshbuild - name: (UI, Local Deps) Link @copilotkit packages if: matrix.app.type == 'ui' working-directory: ./examples/${{ matrix.app.project }}/${{ matrix.app.type }} run: | - for package in $(ls -d ../../../CopilotKit/packages/*); do + for package in $(ls -d ../../../src/v1.x/packages/*); do pnpm link $package done @@ -141,7 +141,7 @@ jobs: - name: (next-openai) Build app if: matrix.app.type == 'next-openai' - working-directory: CopilotKit/examples/next-openai + working-directory: src/v1.x/examples/next-openai run: pnpm run example-build env: OPENAI_API_KEY: placeholder @@ -165,7 +165,7 @@ jobs: run: | docker buildx build \ -t 058264219587.dkr.ecr.us-east-1.amazonaws.com/coagents:next-openai-${{ github.run_id }} \ - -f ./CopilotKit/examples/next-openai/Dockerfile \ + -f ./src/v1.x/examples/next-openai/Dockerfile \ --platform=linux/amd64 \ --provenance=false \ --push \ diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7a6baaf80a..741d5f7b9e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -9,9 +9,9 @@ on: - 'examples/**' - '.github/workflows/demos_preview.yml' - '.github/workflows/release.yml' - - 'CopilotKit/packages/**/package.json' - - 'CopilotKit/packages/**/CHANGELOG.md' - - 'CopilotKit/.changeset/**' + - 'src/v1.x/packages/**/package.json' + - 'src/v1.x/packages/**/CHANGELOG.md' + - 'src/v1.x/.changeset/**' pull_request: branches: [main] paths-ignore: @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: 'CopilotKit' + working-directory: 'src/v1.x' steps: - name: Checkout @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: CopilotKit + working-directory: src/v1.x steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51d2aaec59..9451a563a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: "CopilotKit" + working-directory: "src/v1.x" timeout-minutes: 10 steps: - name: Checkout Repo @@ -117,6 +117,6 @@ jobs: with: tag_name: v${{ steps.new-version.outputs.version }} release_name: v${{ steps.new-version.outputs.version }} - body_path: ./CopilotKit/changelog.txt + body_path: ./src/v1.x/changelog.txt draft: false prerelease: false diff --git a/.husky/pre-commit b/.husky/pre-commit index 88641c6ff9..c234aa1390 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -cd CopilotKit && pnpm run precommit +cd src/v1.x && pnpm run precommit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63edd3a60e..31a4fafd7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ pnpm install To make sure everything works, let’s build all packages once: ```jsx -cd CopilotKit +cd src/v1.x turbo run build ``` diff --git a/CopilotKit/.vscode/launch.json b/CopilotKit/.vscode/launch.json deleted file mode 100644 index f78de12cce..0000000000 --- a/CopilotKit/.vscode/launch.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - - { - "name": "examples/next-openai: Node", - "type": "node", - "request": "launch", - "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "run", - "dev" - ], - "cwd": "${workspaceFolder}/examples/next-openai", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "resolveSourceMapLocations": [ - "${workspaceFolder}/CopilotKit/**", - "!**/node_modules/**" - ], - "sourceMaps": true - }, - { - "name": "examples/next-openai: Chrome", - "type": "chrome", - "request": "launch", - "url": "http://localhost:3000/", - "webRoot": "${workspaceFolder}/examples/next-openai", - "sourceMaps": true - }, - { - "name": "run packages in dev mode", - "type": "node", - "request": "launch", - "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "run", - "dev" - ], - "cwd": "${workspaceFolder}/packages", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen" - } - ], - "compounds": [ - { - "name": "examples/next-openai: Full", - "configurations": ["run packages in dev mode", "examples/next-openai: Node", "examples/next-openai: Chrome"] - } - ] -} \ No newline at end of file diff --git a/CopilotKit/examples/next-openai/Dockerfile b/CopilotKit/examples/next-openai/Dockerfile deleted file mode 100644 index 630efd37da..0000000000 --- a/CopilotKit/examples/next-openai/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# syntax=docker/dockerfile:1-labs - -FROM node:21-slim AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -RUN corepack prepare pnpm@9.15.0 --activate -RUN pnpm i -g turbo - -# Production Stage -FROM base AS production -ARG APP_DIR="CopilotKit/examples/next-openai" -COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter - -WORKDIR /app/CopilotKit -COPY CopilotKit/package.json CopilotKit/pnpm-lock.yaml CopilotKit/pnpm-workspace.yaml ./ -COPY --parents ${APP_DIR}/**/package.json ./ - -# Copy the built artifacts from the builder stage -WORKDIR /app/${APP_DIR} -COPY ${APP_DIR}/.next/standalone/examples/next-openai ./ -COPY ${APP_DIR}/.next/static ./.next/static -RUN pnpm i --frozen-lockfile - -# Set the environment variables (if needed) -ENV NODE_ENV=production -EXPOSE 3000 -CMD ["node", "server.js"] \ No newline at end of file diff --git a/CopilotKit/packages/react-core/src/lib/copilot-task.ts b/CopilotKit/packages/react-core/src/lib/copilot-task.ts deleted file mode 100644 index 6081f29a6c..0000000000 --- a/CopilotKit/packages/react-core/src/lib/copilot-task.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * This class is used to execute one-off tasks, for example on button press. It can use the context available via [useCopilotReadable](/reference/hooks/useCopilotReadable) and the actions provided by [useCopilotAction](/reference/hooks/useCopilotAction), or you can provide your own context and actions. - * - * ## Example - * In the simplest case, use CopilotTask in the context of your app by giving it instructions on what to do. - * - * ```tsx - * import { CopilotTask, useCopilotContext } from "@copilotkit/react-core"; - * - * export function MyComponent() { - * const context = useCopilotContext(); - * - * const task = new CopilotTask({ - * instructions: "Set a random message", - * actions: [ - * { - * name: "setMessage", - * description: "Set the message.", - * argumentAnnotations: [ - * { - * name: "message", - * type: "string", - * description: - * "A message to display.", - * required: true, - * }, - * ], - * } - * ] - * }); - * - * const executeTask = async () => { - * await task.run(context, action); - * } - * - * return ( - * <> - * - * - * ) - * } - * ``` - * - * Have a look at the [Presentation Example App](https://github.com/CopilotKit/CopilotKit/blob/main/CopilotKit/examples/next-openai/src/app/presentation/page.tsx) for a more complete example. - */ - -import { - ActionExecutionMessage, - CopilotRuntimeClient, - Message, - Role, - TextMessage, - convertGqlOutputToMessages, - convertMessagesToGqlInput, - filterAgentStateMessages, - CopilotRequestType, - ForwardedParametersInput, -} from "@copilotkit/runtime-client-gql"; -import { FrontendAction, processActionsForRuntimeRequest } from "../types/frontend-action"; -import { CopilotContextParams } from "../context"; -import { defaultCopilotContextCategories } from "../components"; - -export interface CopilotTaskConfig { - /** - * The instructions to be given to the assistant. - */ - instructions: string; - /** - * An array of action definitions that can be called. - */ - actions?: FrontendAction[]; - /** - * Whether to include the copilot readable context in the task. - */ - includeCopilotReadable?: boolean; - - /** - * Whether to include actions defined via useCopilotAction in the task. - */ - includeCopilotActions?: boolean; - - /** - * The forwarded parameters to use for the task. - */ - forwardedParameters?: ForwardedParametersInput; -} - -export class CopilotTask { - private instructions: string; - private actions: FrontendAction[]; - private includeCopilotReadable: boolean; - private includeCopilotActions: boolean; - private forwardedParameters?: ForwardedParametersInput; - constructor(config: CopilotTaskConfig) { - this.instructions = config.instructions; - this.actions = config.actions || []; - this.includeCopilotReadable = config.includeCopilotReadable !== false; - this.includeCopilotActions = config.includeCopilotActions !== false; - this.forwardedParameters = config.forwardedParameters; - } - - /** - * Run the task. - * @param context The CopilotContext to use for the task. Use `useCopilotContext` to obtain the current context. - * @param data The data to use for the task. - */ - async run(context: CopilotContextParams, data?: T): Promise { - const actions = this.includeCopilotActions ? Object.assign({}, context.actions) : {}; - - // merge functions into entry points - for (const fn of this.actions) { - actions[fn.name] = fn; - } - - let contextString = ""; - - if (data) { - contextString = (typeof data === "string" ? data : JSON.stringify(data)) + "\n\n"; - } - - if (this.includeCopilotReadable) { - contextString += context.getContextString([], defaultCopilotContextCategories); - } - - const systemMessage = new TextMessage({ - content: taskSystemMessage(contextString, this.instructions), - role: Role.System, - }); - - const messages: Message[] = [systemMessage]; - - const runtimeClient = new CopilotRuntimeClient({ - url: context.copilotApiConfig.chatApiEndpoint, - publicApiKey: context.copilotApiConfig.publicApiKey, - headers: context.copilotApiConfig.headers, - credentials: context.copilotApiConfig.credentials, - }); - - const response = await runtimeClient - .generateCopilotResponse({ - data: { - frontend: { - actions: processActionsForRuntimeRequest(Object.values(actions)), - url: window.location.href, - }, - messages: convertMessagesToGqlInput(filterAgentStateMessages(messages)), - metadata: { - requestType: CopilotRequestType.Task, - }, - forwardedParameters: { - // if forwardedParameters is provided, use it - toolChoice: "required", - ...(this.forwardedParameters ?? {}), - }, - }, - properties: context.copilotApiConfig.properties, - }) - .toPromise(); - - const functionCallHandler = context.getFunctionCallHandler(actions); - const functionCalls = convertGqlOutputToMessages( - response.data?.generateCopilotResponse?.messages || [], - ).filter((m): m is ActionExecutionMessage => m.isActionExecutionMessage()); - - for (const functionCall of functionCalls) { - await functionCallHandler({ - messages, - name: functionCall.name, - args: functionCall.arguments, - }); - } - } -} - -function taskSystemMessage(contextString: string, instructions: string): string { - return ` -Please act as an efficient, competent, conscientious, and industrious professional assistant. - -Help the user achieve their goals, and you do so in a way that is as efficient as possible, without unnecessary fluff, but also without sacrificing professionalism. -Always be polite and respectful, and prefer brevity over verbosity. - -The user has provided you with the following context: -\`\`\` -${contextString} -\`\`\` - -They have also provided you with functions you can call to initiate actions on their behalf. - -Please assist them as best you can. - -This is not a conversation, so please do not ask questions. Just call a function without saying anything else. - -The user has given you the following task to complete: - -\`\`\` -${instructions} -\`\`\` -`; -} diff --git a/CopilotKit/packages/react-core/tsup.config.ts b/CopilotKit/packages/react-core/tsup.config.ts deleted file mode 100644 index 208ea3de3e..0000000000 --- a/CopilotKit/packages/react-core/tsup.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig, Options } from "tsup"; - -export default defineConfig((options: Options) => ({ - entry: [ - "src/**/*.{ts,tsx}", - "!src/**/*.test.{ts,tsx}", - "!src/**/__tests__/**/*", - "!src/**/*.spec.{ts,tsx}", - ], - format: ["esm", "cjs"], - dts: true, - minify: false, - external: ["react"], - sourcemap: true, - ...options, -})); diff --git a/CopilotKit/packages/react-textarea/src/components/copilot-textarea/copilot-textarea.tsx b/CopilotKit/packages/react-textarea/src/components/copilot-textarea/copilot-textarea.tsx deleted file mode 100644 index 49801f19c4..0000000000 --- a/CopilotKit/packages/react-textarea/src/components/copilot-textarea/copilot-textarea.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/** - *
- * - * - * `` is a React component that acts as a drop-in replacement for the standard ` + } + } + + + @if (toolbarTemplate || toolbarComponent()) { + + + } @else { +
+
+ @if (addFileButtonTemplate || addFileButtonComponent()) { + + + } @else { + + + } + @if (computedToolsMenu().length > 0) { + @if (toolsButtonTemplate || toolsButtonComponent()) { + + + } @else { + + + } + } + @if (additionalToolbarItems()) { + + } +
+
+ @if (computedMode() === "transcribe") { + @if ( + cancelTranscribeButtonTemplate || + cancelTranscribeButtonComponent() + ) { + + + } @else { + + + } + @if ( + finishTranscribeButtonTemplate || + finishTranscribeButtonComponent() + ) { + + + } @else { + + + } + } @else { + @if ( + startTranscribeButtonTemplate || + startTranscribeButtonComponent() + ) { + + + } @else { + + + } + + @if (sendButtonTemplate || sendButtonComponent()) { + + + } @else { +
+ +
+ } + } +
+
+ } + + `, + styles: [ + ` + :host { + display: block; + width: 100%; + } + .shadow-\\[0_4px_4px_0_\\#0000000a\\2c_0_0_1px_0_\\#0000009e\\] { + box-shadow: + 0 4px 4px 0 #0000000a, + 0 0 1px 0 #0000009e !important; + } + `, + ], +}) +export class CopilotChatInput implements AfterViewInit, OnDestroy { + @ViewChild(CopilotChatTextarea, { read: CopilotChatTextarea }) + textAreaRef?: CopilotChatTextarea; + + @ViewChild(CopilotChatAudioRecorder) + audioRecorderRef?: CopilotChatAudioRecorder; + + // Capture templates from content projection + @ContentChild("sendButton", { read: TemplateRef }) + sendButtonTemplate?: TemplateRef; + @ContentChild("toolbar", { read: TemplateRef }) + toolbarTemplate?: TemplateRef; + @ContentChild("textArea", { read: TemplateRef }) + textAreaTemplate?: TemplateRef; + @ContentChild("audioRecorder", { read: TemplateRef }) + audioRecorderTemplate?: TemplateRef; + @ContentChild("startTranscribeButton", { read: TemplateRef }) + startTranscribeButtonTemplate?: TemplateRef; + @ContentChild("cancelTranscribeButton", { read: TemplateRef }) + cancelTranscribeButtonTemplate?: TemplateRef; + @ContentChild("finishTranscribeButton", { read: TemplateRef }) + finishTranscribeButtonTemplate?: TemplateRef; + @ContentChild("addFileButton", { read: TemplateRef }) + addFileButtonTemplate?: TemplateRef; + @ContentChild("toolsButton", { read: TemplateRef }) + toolsButtonTemplate?: TemplateRef; + + // Class inputs for styling default components + sendButtonClass = input(undefined); + toolbarClass = input(undefined); + textAreaClass = input(undefined); + textAreaMaxRows = input(undefined); + textAreaPlaceholder = input(undefined); + audioRecorderClass = input(undefined); + startTranscribeButtonClass = input(undefined); + cancelTranscribeButtonClass = input(undefined); + finishTranscribeButtonClass = input(undefined); + addFileButtonClass = input(undefined); + toolsButtonClass = input(undefined); + + // Component inputs for overrides + sendButtonComponent = input | undefined>(undefined); + toolbarComponent = input | undefined>(undefined); + textAreaComponent = input | undefined>(undefined); + audioRecorderComponent = input | undefined>(undefined); + startTranscribeButtonComponent = input | undefined>(undefined); + cancelTranscribeButtonComponent = input | undefined>(undefined); + finishTranscribeButtonComponent = input | undefined>(undefined); + addFileButtonComponent = input | undefined>(undefined); + toolsButtonComponent = input | undefined>(undefined); + + // Regular inputs + mode = input(undefined); + toolsMenu = input<(ToolsMenuItem | "-")[] | undefined>(undefined); + autoFocus = input(undefined); + value = input(undefined); + inputClass = input(undefined); + // Note: Prefer host `class` for styling this component; + // keep only `inputClass` to style the internal wrapper if needed. + additionalToolbarItems = input | undefined>(undefined); + + // Output events + submitMessage = output(); + startTranscribe = output(); + cancelTranscribe = output(); + finishTranscribe = output(); + addFile = output(); + valueChange = output(); + + // Icons and default classes + readonly ArrowUpIcon = ArrowUp; + readonly defaultButtonClass = cn( + // Base button styles + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium", + "transition-all disabled:pointer-events-none disabled:opacity-50", + "shrink-0 outline-none", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + // chatInputToolbarPrimary variant + "cursor-pointer", + "bg-black text-white", + "dark:bg-white dark:text-black dark:focus-visible:outline-white", + "rounded-full h-9 w-9", + "transition-colors", + "focus:outline-none", + "hover:opacity-70 disabled:hover:opacity-100", + "disabled:cursor-not-allowed disabled:bg-[#00000014] disabled:text-[rgb(13,13,13)]", + "dark:disabled:bg-[#454545] dark:disabled:text-white" + ); + + // Services + readonly labels = injectChatLabels(); + // readonly chatConfig = injectChatConfig(); + readonly chatState = injectChatState(); + + // Signals + modeSignal = signal("input"); + toolsMenuSignal = signal<(ToolsMenuItem | "-")[]>([]); + autoFocusSignal = signal(true); + customClass = signal(undefined); + + // Default components + // Note: CopilotChatTextarea uses attribute selector but is a component + defaultAudioRecorder = CopilotChatAudioRecorder; + defaultSendButton: any = null; // Will be set to avoid circular dependency + CopilotChatToolbar = CopilotChatToolbar; + CopilotChatAddFileButton = CopilotChatAddFileButton; + CopilotChatToolsMenu = CopilotChatToolsMenu; + CopilotChatCancelTranscribeButton = CopilotChatCancelTranscribeButton; + CopilotChatFinishTranscribeButton = CopilotChatFinishTranscribeButton; + CopilotChatStartTranscribeButton = CopilotChatStartTranscribeButton; + + // Computed values + computedMode = computed(() => this.modeSignal()); + computedToolsMenu = computed(() => this.toolsMenu() ?? []); + computedAutoFocus = computed(() => this.autoFocus() ?? true); + computedValue = computed(() => { + const customValue = this.value() ?? ""; + const configValue = this.chatState.inputValue(); + return customValue || configValue || ""; + }); + + computedClass = computed(() => { + const baseClasses = cn( + // Layout + "flex w-full flex-col items-center justify-center", + // Interaction + "cursor-text", + // Overflow and clipping + "overflow-visible bg-clip-padding contain-inline-size", + // Background + "bg-white dark:bg-[#303030]", + // Visual effects + "shadow-[0_4px_4px_0_#0000000a,0_0_1px_0_#0000009e] rounded-[28px]" + ); + return cn(baseClasses, this.customClass()); + }); + + // Context for slots (reactive via signals) + sendButtonContext = computed(() => ({ + send: () => this.send(), + disabled: + !this.computedValue().trim() || this.computedMode() === "processing", + value: this.computedValue(), + })); + + toolbarContext = computed(() => ({ + mode: this.computedMode(), + value: this.computedValue(), + })); + + textAreaContext = computed(() => ({ + value: this.computedValue(), + autoFocus: this.computedAutoFocus(), + disabled: this.computedMode() === "processing", + maxRows: this.textAreaMaxRows(), + placeholder: this.textAreaPlaceholder(), + inputClass: this.textAreaClass(), + onKeyDown: (event: KeyboardEvent) => this.handleKeyDown(event), + onChange: (value: string) => this.handleValueChange(value), + })); + + audioRecorderContext = computed(() => ({ + inputShowControls: true, + })); + + // Button contexts removed - now using outputs map for click handlers + + toolsContext = computed(() => ({ + inputToolsMenu: this.computedToolsMenu(), + inputDisabled: this.computedMode() === "transcribe", + })); + + constructor() { + // Effect to handle mode changes (no signal writes) + effect(() => { + const currentMode = this.computedMode(); + if (currentMode === "transcribe" && this.audioRecorderRef) { + this.audioRecorderRef.start().catch(console.error); + } else if (this.audioRecorderRef?.getState() === "recording") { + this.audioRecorderRef.stop().catch(console.error); + } + }); + } + + // Output maps for slots + addFileButtonOutputs = { clicked: () => this.handleAddFile() }; + cancelTranscribeButtonOutputs = { + clicked: () => this.handleCancelTranscribe(), + }; + finishTranscribeButtonOutputs = { + clicked: () => this.handleFinishTranscribe(), + }; + startTranscribeButtonOutputs = { + clicked: () => this.handleStartTranscribe(), + }; + // Support both `clicked` (idiomatic in our slots) and `click` (legacy) + sendButtonOutputs = { clicked: () => this.send(), click: () => this.send() }; + + ngAfterViewInit(): void { + // Auto-focus if needed + if (this.computedAutoFocus() && this.textAreaRef) { + setTimeout(() => { + this.textAreaRef?.focus(); + }); + } + } + + ngOnDestroy(): void { + // Clean up any resources + if (this.audioRecorderRef?.getState() === "recording") { + this.audioRecorderRef?.stop().catch(console.error); + } + } + + handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + this.send(); + } + } + + handleValueChange(value: string): void { + this.valueChange.emit(value); + if (this.chatState) this.chatState.changeInput(value); + } + + send(): void { + const trimmed = this.computedValue().trim(); + if (trimmed) { + this.submitMessage.emit(trimmed); + + this.chatState.submitInput(trimmed); + + if (this.chatState) this.chatState.changeInput(""); + if (this.textAreaRef) this.textAreaRef.setValue(""); + + // Refocus input + if (this.textAreaRef) { + setTimeout(() => { + this.textAreaRef?.focus(); + }); + } + } + } + + handleStartTranscribe(): void { + this.startTranscribe.emit(); + this.modeSignal.set("transcribe"); + } + + handleCancelTranscribe(): void { + this.cancelTranscribe.emit(); + this.modeSignal.set("input"); + } + + handleFinishTranscribe(): void { + this.finishTranscribe.emit(); + this.modeSignal.set("input"); + } + + handleAddFile(): void { + this.addFile.emit(); + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-input.types.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-input.types.ts new file mode 100644 index 0000000000..b6da757ab6 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-input.types.ts @@ -0,0 +1,148 @@ +import type { Type, TemplateRef } from "@angular/core"; + +/** + * Mode of the chat input component + */ +export type CopilotChatInputMode = "input" | "transcribe" | "processing"; + +/** + * Represents a menu item in the tools menu + */ +export type ToolsMenuItem = { + label: string; +} & ( + | { + action: () => void; + items?: never; + } + | { + action?: never; + items: (ToolsMenuItem | "-")[]; + } +); + +/** + * Audio recorder state + */ +export type AudioRecorderState = "idle" | "recording" | "processing"; + +/** + * Error class for audio recorder failures + */ +export class AudioRecorderError extends Error { + constructor(message: string) { + super(message); + this.name = "AudioRecorderError"; + } +} + +/** + * Props for textarea component + */ +export interface CopilotChatTextareaProps { + value?: string; + placeholder?: string; + maxRows?: number; + autoFocus?: boolean; + disabled?: boolean; + onChange?: (value: string) => void; + onKeyDown?: (event: KeyboardEvent) => void; + inputClass?: string; + style?: any; + rows?: number; + cols?: number; + readonly?: boolean; + spellcheck?: boolean; + wrap?: "hard" | "soft" | "off"; +} + +/** + * Props for button components + */ +export interface CopilotChatButtonProps { + disabled?: boolean; + onClick?: () => void; + inputClass?: string; + style?: any; + type?: "button" | "submit" | "reset"; + ariaLabel?: string; + ariaPressed?: boolean; + ariaExpanded?: boolean; + title?: string; +} + +/** + * Props for toolbar button with tooltip + */ +export interface CopilotChatToolbarButtonProps extends CopilotChatButtonProps { + icon?: TemplateRef; + tooltip?: string; + variant?: "primary" | "secondary"; +} + +/** + * Props for tools menu button + */ +export interface CopilotChatToolsButtonProps extends CopilotChatButtonProps { + toolsMenu?: (ToolsMenuItem | "-")[]; +} + +/** + * Props for audio recorder + */ +export interface CopilotChatAudioRecorderProps { + inputClass?: string; + style?: any; + onStateChange?: (state: AudioRecorderState) => void; + showControls?: boolean; + maxDuration?: number; +} + +/** + * Props for toolbar + */ +export interface CopilotChatToolbarProps { + inputClass?: string; + style?: any; + position?: "top" | "bottom"; + alignment?: "left" | "center" | "right" | "space-between"; +} + +/** + * Slot configuration for chat input + */ +export interface CopilotChatInputSlots { + textArea?: Type | TemplateRef; + sendButton?: Type | TemplateRef; + startTranscribeButton?: Type | TemplateRef; + cancelTranscribeButton?: Type | TemplateRef; + finishTranscribeButton?: Type | TemplateRef; + addFileButton?: Type | TemplateRef; + toolsButton?: Type | TemplateRef; + toolbar?: Type | TemplateRef; + audioRecorder?: Type | TemplateRef; +} + +/** + * Input configuration for the chat input component + */ +export interface CopilotChatInputConfig { + mode?: CopilotChatInputMode; + toolsMenu?: (ToolsMenuItem | "-")[]; + autoFocus?: boolean; + additionalToolbarItems?: TemplateRef; + value?: string; + class?: string; +} + +/** + * Output events for the chat input component + */ +export interface CopilotChatInputOutputs { + submitMessage: (value: string) => void; + startTranscribe: () => void; + cancelTranscribe: () => void; + finishTranscribe: () => void; + addFile: () => void; + changeValue: (value: string) => void; +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts new file mode 100644 index 0000000000..27cc145178 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts @@ -0,0 +1,33 @@ +import { + Component, + input, + ChangeDetectionStrategy, + ViewEncapsulation, + computed, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { cn } from "../../utils"; + +/** + * Cursor component that matches the React implementation exactly. + * Shows a pulsing dot animation to indicate activity. + */ +@Component({ + selector: "copilot-chat-message-view-cursor", + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: `
`, +}) +export class CopilotChatMessageViewCursor { + inputClass = input(); + + // Computed class that matches React exactly: w-[11px] h-[11px] rounded-full bg-foreground animate-pulse-cursor ml-1 + computedClass = computed(() => + cn( + "w-[11px] h-[11px] rounded-full bg-foreground animate-pulse-cursor ml-1", + this.inputClass() + ) + ); +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts new file mode 100644 index 0000000000..73013db069 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts @@ -0,0 +1,222 @@ +import { + Component, + input, + output, + ContentChild, + TemplateRef, + Type, + ChangeDetectionStrategy, + ViewEncapsulation, + computed, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { CopilotSlot } from "../../slots/copilot-slot"; +import type { Message } from "@ag-ui/core"; +import { CopilotChatAssistantMessage } from "./copilot-chat-assistant-message"; +import { CopilotChatUserMessage } from "./copilot-chat-user-message"; +import { CopilotChatMessageViewCursor } from "./copilot-chat-message-view-cursor"; +import { cn } from "../../utils"; + +/** + * CopilotChatMessageView component - Angular port of the React component. + * Renders a list of chat messages with support for custom slots and layouts. + * DOM structure and Tailwind classes match the React implementation exactly. + */ +@Component({ + selector: "copilot-chat-message-view", + standalone: true, + imports: [ + CommonModule, + CopilotSlot, + CopilotChatAssistantMessage, + CopilotChatUserMessage, + CopilotChatMessageViewCursor, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + @if (customLayoutTemplate) { + + } @else { + +
+ + @for ( + message of messagesValue(); + track trackByMessageId($index, message) + ) { + @if (message && message.role === "assistant") { + + @if (assistantMessageComponent() || assistantMessageTemplate()) { + + + } @else { + + + } + } @else if (message && message.role === "user") { + + @if (userMessageComponent() || userMessageTemplate()) { + + + } @else { + + + } + } + } + + + @if (showCursorValue()) { + @if (cursorComponent() || cursorTemplate()) { + + + } @else { + + + } + } +
+ } + `, +}) +export class CopilotChatMessageView { + // Core inputs matching React props + messages = input([]); + showCursor = input(false); + isLoading = input(false); + inputClass = input(); + + // Handler availability handled via DI service + + // Assistant message slot inputs + assistantMessageComponent = input | undefined>(); + assistantMessageTemplate = input | undefined>(); + assistantMessageClass = input(); + + // User message slot inputs + userMessageComponent = input | undefined>(); + userMessageTemplate = input | undefined>(); + userMessageClass = input(); + + // Cursor slot inputs + cursorComponent = input | undefined>(); + cursorTemplate = input | undefined>(); + cursorClass = input(); + + // Custom layout template (render prop pattern) + @ContentChild("customLayout") customLayoutTemplate?: TemplateRef; + + // Output events (bubbled from child components) + assistantMessageThumbsUp = output<{ message: Message }>(); + assistantMessageThumbsDown = output<{ message: Message }>(); + assistantMessageReadAloud = output<{ message: Message }>(); + assistantMessageRegenerate = output<{ message: Message }>(); + userMessageCopy = output<{ message: Message }>(); + userMessageEdit = output<{ message: Message }>(); + + // Default components for slots + protected readonly defaultAssistantComponent = CopilotChatAssistantMessage; + protected readonly defaultUserComponent = CopilotChatUserMessage; + protected readonly defaultCursorComponent = CopilotChatMessageViewCursor; + + // Derived values from inputs + protected messagesValue = computed(() => this.messages()); + protected showCursorValue = computed(() => this.showCursor()); + protected isLoadingValue = computed(() => this.isLoading()); + + // Computed class matching React: twMerge("flex flex-col", className) + computedClass = computed(() => cn("flex flex-col", this.inputClass())); + + // Layout context for custom templates (render prop pattern) + layoutContext = computed(() => ({ + isLoading: this.isLoadingValue(), + messages: this.messagesValue(), + showCursor: this.showCursorValue(), + messageElements: this.messagesValue().filter( + (m) => m && (m.role === "assistant" || m.role === "user") + ), + })); + + // Slot resolution computed signals + assistantMessageSlot = computed( + () => this.assistantMessageComponent() || this.assistantMessageClass() + ); + + userMessageSlot = computed( + () => this.userMessageComponent() || this.userMessageClass() + ); + + cursorSlot = computed(() => this.cursorComponent() || this.cursorClass()); + + // Props merging helpers + mergeAssistantProps(message: Message) { + return { + message, + messages: this.messagesValue(), + isLoading: this.isLoadingValue(), + inputClass: this.assistantMessageClass(), + }; + } + + mergeUserProps(message: Message) { + return { + message, + inputClass: this.userMessageClass(), + }; + } + + // TrackBy function for performance optimization + trackByMessageId(index: number, message: Message): string { + return message?.id || `index-${index}`; + } + + constructor() {} + + // Event handlers - just pass them through + handleAssistantThumbsUp(event: { message: Message }): void { + this.assistantMessageThumbsUp.emit(event); + } + + handleAssistantThumbsDown(event: { message: Message }): void { + this.assistantMessageThumbsDown.emit(event); + } + + handleAssistantReadAloud(event: { message: Message }): void { + this.assistantMessageReadAloud.emit(event); + } + + handleAssistantRegenerate(event: { message: Message }): void { + this.assistantMessageRegenerate.emit(event); + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.types.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.types.ts new file mode 100644 index 0000000000..c912053529 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-message-view.types.ts @@ -0,0 +1,39 @@ +import { Message } from "@ag-ui/client"; +import { Type, TemplateRef } from "@angular/core"; + +// Context interfaces for template slots +export interface MessageViewContext { + showCursor: boolean; + messages: Message[]; + messageElements: any[]; // Will be populated with rendered elements +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CursorContext { + // Empty for now, can be extended if needed +} + +// Component input props interface +export interface CopilotChatMessageViewProps { + messages?: Message[]; + showCursor?: boolean; + inputClass?: string; + + // Assistant message slots + assistantMessageComponent?: Type; + assistantMessageTemplate?: TemplateRef; + assistantMessageClass?: string; + + // User message slots + userMessageComponent?: Type; + userMessageTemplate?: TemplateRef; + userMessageClass?: string; + + // Cursor slots + cursorComponent?: Type; + cursorTemplate?: TemplateRef; + cursorClass?: string; +} + +// Re-export for convenience +export type { Message }; diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts new file mode 100644 index 0000000000..9f70bdfc50 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts @@ -0,0 +1,186 @@ +import { + Component, + input, + output, + ElementRef, + AfterViewInit, + signal, + computed, + inject, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { cn } from "../../utils"; +import { injectChatLabels } from "../../chat-config"; +import { injectChatState } from "../../chat-state"; + +@Component({ + selector: "textarea[copilotChatTextarea]", + standalone: true, + imports: [], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + "[value]": "computedValue()", + "[placeholder]": "placeholder()", + "[disabled]": "disabled()", + "[class]": "computedClass()", + "[style.max-height.px]": "maxHeight()", + "[style.overflow]": "'auto'", + "[style.resize]": "'none'", + "(input)": "onInput($event)", + "(keydown)": "onKeyDown($event)", + "[attr.rows]": "1", + }, + template: "", + styles: [], +}) +export class CopilotChatTextarea implements AfterViewInit { + private elementRef = inject(ElementRef); + get textareaRef() { + return this.elementRef; + } + + inputValue = input(); + inputPlaceholder = input(); + inputMaxRows = input(); + inputAutoFocus = input(); + inputDisabled = input(); + inputClass = input(); + + valueChange = output(); + keyDown = output(); + + readonly chatLabels = injectChatLabels(); + readonly chatState = injectChatState(); + + // Internal signals + maxHeight = signal(0); + + // Computed values + computedValue = computed( + () => this.inputValue() ?? this.chatState.inputValue() ?? "" + ); + placeholder = computed( + () => this.inputPlaceholder() || this.chatLabels.chatInputPlaceholder + ); + disabled = computed(() => this.inputDisabled() ?? false); + + computedClass = computed(() => { + const baseClasses = cn( + // Layout and sizing + "w-full p-5 pb-0", + // Behavior + "outline-none resize-none", + // Background + "bg-transparent", + // Typography + "antialiased font-regular leading-relaxed text-[16px]", + // Placeholder styles + "placeholder:text-[#00000077] dark:placeholder:text-[#fffc]" + ); + return cn(baseClasses, this.inputClass()); + }); + + constructor() {} + + ngAfterViewInit(): void { + this.calculateMaxHeight(); + this.adjustHeight(); + + if (this.inputAutoFocus() ?? true) { + setTimeout(() => { + this.elementRef.nativeElement.focus(); + }); + } + } + + onInput(event: Event): void { + const textarea = event.target as HTMLTextAreaElement; + const newValue = textarea.value; + this.valueChange.emit(newValue); + + this.chatState.changeInput(newValue); + + this.adjustHeight(); + } + + onKeyDown(event: KeyboardEvent): void { + // Check for Enter key without Shift + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + this.keyDown.emit(event); + } else { + this.keyDown.emit(event); + } + } + + private calculateMaxHeight(): void { + const textarea = this.elementRef.nativeElement; + const maxRowsValue = this.inputMaxRows() ?? 5; + + // Save current value + const currentValue = textarea.value; + + // Clear content to measure single row height + textarea.value = ""; + textarea.style.height = "auto"; + + // Get computed styles to account for padding + const computedStyle = window.getComputedStyle(textarea); + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + + // Calculate actual content height (without padding) + const contentHeight = textarea.scrollHeight - paddingTop - paddingBottom; + + // Calculate max height: content height for maxRows + padding + const calculatedMaxHeight = + contentHeight * maxRowsValue + paddingTop + paddingBottom; + this.maxHeight.set(calculatedMaxHeight); + + // Restore original value + textarea.value = currentValue; + + // Adjust height after calculating maxHeight + if (currentValue) { + this.adjustHeight(); + } + } + + private adjustHeight(): void { + const textarea = this.elementRef.nativeElement; + const maxHeightValue = this.maxHeight(); + + if (maxHeightValue > 0) { + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeightValue)}px`; + } + } + + /** + * Public method to focus the textarea + */ + focus(): void { + this.elementRef.nativeElement.focus(); + } + + /** + * Public method to get current value + */ + getValue(): string { + return this.elementRef.nativeElement.value; + } + + /** + * Public method to set value programmatically + */ + setValue(value: string): void { + this.elementRef.nativeElement.value = value; + this.valueChange.emit(value); + + this.chatState.changeInput(value); + + setTimeout(() => this.adjustHeight()); + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts new file mode 100644 index 0000000000..080ecca720 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts @@ -0,0 +1,24 @@ +import { Component, ChangeDetectionStrategy, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import type { AssistantMessage, Message } from "@ag-ui/core"; +import { RenderToolCalls } from "../../render-tool-calls"; + +@Component({ + standalone: true, + selector: "copilot-chat-tool-calls-view", + imports: [CommonModule, RenderToolCalls], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + `, +}) +export class CopilotChatToolCallsView { + readonly message = input.required(); + readonly messages = input.required(); + readonly isLoading = input(false); +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts new file mode 100644 index 0000000000..5756f74145 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts @@ -0,0 +1,31 @@ +import { + Component, + input, + computed, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { cn } from "../../utils"; + +@Component({ + selector: "div[copilotChatToolbar]", + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + "[class]": "computedClass()", + }, + template: ``, + styles: [], +}) +export class CopilotChatToolbar { + readonly inputClass = input(); + + readonly computedClass = computed(() => { + const baseClasses = + "w-full h-[60px] bg-transparent flex items-center justify-between"; + return cn(baseClasses, this.inputClass()); + }); +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts new file mode 100644 index 0000000000..d84969bbf0 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts @@ -0,0 +1,184 @@ +import { + Component, + input, + computed, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { CdkMenuModule } from "@angular/cdk/menu"; +import { OverlayModule } from "@angular/cdk/overlay"; +import { LucideAngularModule, Settings2, ChevronRight } from "lucide-angular"; +import type { ToolsMenuItem } from "./copilot-chat-input.types"; +import { cn } from "../../utils"; +import { injectChatLabels } from "../../chat-config"; + +@Component({ + selector: "copilot-chat-tools-menu", + standalone: true, + imports: [CommonModule, CdkMenuModule, OverlayModule, LucideAngularModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (hasItems()) { + + + +
+ @for (item of toolsMenu(); track $index) { + @if (item === "-") { +
+ } @else if (isMenuItem(item)) { + @if (item.items && item.items.length > 0) { + + + + + +
+ @for (subItem of item.items; track $index) { + @if (subItem === "-") { +
+ } @else if (isMenuItem(subItem)) { + + } + } +
+
+ } @else { + + + } + } + } +
+
+ } + `, + styles: [ + ` + /* CDK Overlay styles for positioning */ + .cdk-overlay-pane { + position: absolute; + pointer-events: auto; + z-index: 1000; + } + + /* Ensure menu appears above other content */ + .cdk-overlay-container { + position: fixed; + z-index: 1000; + } + + /* Menu animation */ + [cdkMenu] { + animation: menuFadeIn 0.15s ease-out; + } + + @keyframes menuFadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + `, + ], +}) +export class CopilotChatToolsMenu { + readonly Settings2Icon = Settings2; + readonly ChevronRightIcon = ChevronRight; + inputToolsMenu = input<(ToolsMenuItem | "-")[] | undefined>(); + inputDisabled = input(); + inputClass = input(); + + private labels = injectChatLabels(); + + // Derive state from inputs + toolsMenu = computed(() => this.inputToolsMenu() ?? []); + disabled = computed(() => this.inputDisabled() ?? false); + customClass = computed(() => this.inputClass()); + + hasItems = computed(() => this.toolsMenu().length > 0); + + readonly label = this.labels.chatInputToolbarToolsButtonLabel; + + buttonClass = computed(() => { + const baseClasses = cn( + // Base button styles + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium", + "transition-all disabled:pointer-events-none disabled:opacity-50", + "shrink-0 outline-none", + "focus-visible:ring-[3px]", + // chatInputToolbarSecondary variant + "cursor-pointer", + "bg-transparent text-[#444444]", + "dark:text-white dark:border-[#404040]", + "transition-colors", + "focus:outline-none", + "hover:bg-[#f8f8f8] hover:text-[#333333]", + "dark:hover:bg-[#404040] dark:hover:text-[#FFFFFF]", + "disabled:cursor-not-allowed disabled:opacity-50", + "disabled:hover:bg-transparent disabled:hover:text-[#444444]", + "dark:disabled:hover:bg-transparent dark:disabled:hover:text-[#CCCCCC]", + // Size + "h-9 px-3 gap-2 font-normal" + ); + return cn(baseClasses, this.customClass()); + }); + + isMenuItem(item: any): item is ToolsMenuItem { + return item && typeof item === "object" && "label" in item; + } + + handleItemClick(item: ToolsMenuItem): void { + if (item.action) { + item.action(); + } + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts new file mode 100644 index 0000000000..9bf5e5832c --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts @@ -0,0 +1,88 @@ +import { Component, input, output, ChangeDetectionStrategy, ViewEncapsulation, computed } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { LucideAngularModule, ChevronLeft, ChevronRight } from "lucide-angular"; +import { type CopilotChatUserMessageOnSwitchToBranchProps } from "./copilot-chat-user-message.types"; +import { cn } from "../../utils"; +import { UserMessage } from "@ag-ui/core"; + +@Component({ + standalone: true, + selector: "copilot-chat-user-message-branch-navigation", + imports: [CommonModule, LucideAngularModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (showNavigation()) { +
+ + + {{ currentBranch() + 1 }}/{{ numberOfBranches() }} + + +
+ } + `, +}) +export class CopilotChatUserMessageBranchNavigation { + currentBranch = input(0); + numberOfBranches = input(1); + message = input(); + inputClass = input(); + switchToBranch = output(); + + readonly ChevronLeftIcon = ChevronLeft; + readonly ChevronRightIcon = ChevronRight; + + readonly buttonClass = cn( + // Flex centering + "inline-flex items-center justify-center", + // Cursor + "cursor-pointer", + // Background and text + "p-0 text-[rgb(93,93,93)] hover:bg-[#E8E8E8]", + // Dark mode + "dark:text-[rgb(243,243,243)] dark:hover:bg-[#303030]", + // Shape and sizing + "h-6 w-6 rounded-md", + // Interactions + "transition-colors", + // Disabled state + "disabled:opacity-50 disabled:cursor-not-allowed", + ); + + showNavigation = computed(() => this.numberOfBranches() > 1); + + canGoPrev = computed(() => this.currentBranch() > 0); + + canGoNext = computed(() => this.currentBranch() < this.numberOfBranches() - 1); + + computedClass = computed(() => { + return cn("flex items-center gap-1", this.inputClass()); + }); + + handlePrevious(): void { + if (this.canGoPrev()) { + const newIndex = this.currentBranch() - 1; + this.switchToBranch.emit({ + branchIndex: newIndex, + numberOfBranches: this.numberOfBranches(), + message: this.message()!, + }); + } + } + + handleNext(): void { + if (this.canGoNext()) { + const newIndex = this.currentBranch() + 1; + this.switchToBranch.emit({ + branchIndex: newIndex, + numberOfBranches: this.numberOfBranches(), + message: this.message()!, + }); + } + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-buttons.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-buttons.ts new file mode 100644 index 0000000000..d70d4a5f11 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-buttons.ts @@ -0,0 +1,161 @@ +import { + Component, + input, + output, + signal, + computed, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { LucideAngularModule, Copy, Check, Edit } from "lucide-angular"; +import { CopilotTooltip } from "../../directives/tooltip"; +import { cn } from "../../utils"; +import { injectChatLabels } from "../../chat-config"; + +// Base toolbar button component +@Component({ + selector: "button[copilotChatUserMessageToolbarButton]", + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` `, + host: { + "[class]": "computedClass()", + "[attr.disabled]": "disabled() ? true : null", + type: "button", + "[attr.aria-label]": "title()", + }, + hostDirectives: [ + { + directive: CopilotTooltip, + inputs: ["copilotTooltip: title", "tooltipPosition", "tooltipDelay"], + }, + ], +}) +export class CopilotChatUserMessageToolbarButton { + title = input(""); + disabled = input(false); + inputClass = input(); + + computedClass = computed(() => { + return cn( + // Flex centering + "inline-flex items-center justify-center", + // Cursor + "cursor-pointer", + // Background and text + "p-0 text-[rgb(93,93,93)] hover:bg-[#E8E8E8]", + // Dark mode + "dark:text-[rgb(243,243,243)] dark:hover:bg-[#303030]", + // Shape and sizing + "h-8 w-8 rounded-md", + // Interactions + "transition-colors", + // Hover states + "hover:text-[rgb(93,93,93)]", + "dark:hover:text-[rgb(243,243,243)]", + // Focus states + "focus:outline-none focus:ring-2 focus:ring-offset-2", + // Disabled state + "disabled:opacity-50 disabled:cursor-not-allowed", + this.inputClass() + ); + }); +} + +// Copy button component +@Component({ + standalone: true, + selector: "copilot-chat-user-message-copy-button", + imports: [ + CommonModule, + LucideAngularModule, + CopilotChatUserMessageToolbarButton, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, +}) +export class CopilotChatUserMessageCopyButton { + readonly title = input(); + readonly disabled = input(false); + readonly inputClass = input(); + readonly content = input(); + readonly clicked = output(); + readonly CopyIcon = Copy; + readonly CheckIcon = Check; + readonly copied = signal(false); + readonly labels = injectChatLabels(); + + handleCopy(): void { + if (!this.content()) return; + + // Set copied immediately for instant feedback + this.copied.set(true); + setTimeout(() => this.copied.set(false), 2000); + + // Copy to clipboard (fire and forget) + navigator.clipboard.writeText(this.content()!).then( + () => this.clicked.emit(), + (err) => { + console.error("Failed to copy message:", err); + this.copied.set(false); + } + ); + } +} + +// Edit button component +@Component({ + selector: "copilot-chat-user-message-edit-button", + standalone: true, + imports: [ + CommonModule, + LucideAngularModule, + CopilotChatUserMessageToolbarButton, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, +}) +export class CopilotChatUserMessageEditButton { + title = input(); + disabled = input(false); + inputClass = input(); + clicked = output(); + + readonly EditIcon = Edit; + readonly labels = injectChatLabels(); + + handleEdit(): void { + if (!this.disabled()) { + this.clicked.emit(); + } + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-renderer.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-renderer.ts new file mode 100644 index 0000000000..aac08b4be6 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-renderer.ts @@ -0,0 +1,32 @@ +import { + Component, + input, + ChangeDetectionStrategy, + ViewEncapsulation, + computed, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { cn } from "../../utils"; + +@Component({ + selector: "copilot-chat-user-message-renderer", + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + "[class]": "computedClass()", + }, + template: `{{ content() }}`, +}) +export class CopilotChatUserMessageRenderer { + readonly content = input(""); + readonly inputClass = input(); + + readonly computedClass = computed(() => { + return cn( + "prose dark:prose-invert bg-muted relative max-w-[80%] rounded-[18px] px-4 py-1.5 data-[multiline]:py-3 inline-block whitespace-pre-wrap", + this.inputClass() + ); + }); +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-toolbar.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-toolbar.ts new file mode 100644 index 0000000000..cbaadb15d8 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message-toolbar.ts @@ -0,0 +1,31 @@ +import { + Component, + input, + computed, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { cn } from "../../utils"; + +@Component({ + selector: "div[copilotChatUserMessageToolbar]", + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` `, + host: { + "[class]": "computedClass()", + }, +}) +export class CopilotChatUserMessageToolbar { + readonly inputClass = input(); + + readonly computedClass = computed(() => + cn( + "w-full bg-transparent flex items-center justify-end mt-[4px] invisible group-hover:visible", + this.inputClass() + ) + ); +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.ts new file mode 100644 index 0000000000..eaad605ef2 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.ts @@ -0,0 +1,261 @@ +import { + Component, + input, + output, + TemplateRef, + ContentChild, + computed, + Type, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { CopilotSlot } from "../../slots/copilot-slot"; +import { + type CopilotChatUserMessageOnEditMessageProps, + type CopilotChatUserMessageOnSwitchToBranchProps, + type MessageRendererContext, + type CopyButtonContext, + type EditButtonContext, + type BranchNavigationContext, + type UserMessageToolbarContext, +} from "./copilot-chat-user-message.types"; +import { CopilotChatUserMessageRenderer } from "./copilot-chat-user-message-renderer"; +import { + CopilotChatUserMessageCopyButton, + CopilotChatUserMessageEditButton, +} from "./copilot-chat-user-message-buttons"; +import { CopilotChatUserMessageToolbar } from "./copilot-chat-user-message-toolbar"; +import { CopilotChatUserMessageBranchNavigation } from "./copilot-chat-user-message-branch-navigation"; +import { cn } from "../../utils"; +import { UserMessage } from "@ag-ui/core"; + +function flattenUserMessageContent(content?: UserMessage["content"]): string { + if (!content) { + return ""; + } + + if (typeof content === "string") { + return content; + } + + return content + .map((part) => { + if ( + part && + typeof part === "object" && + "type" in part && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" + ) { + return (part as { text: string }).text; + } + return ""; + }) + .filter((text) => text.length > 0) + .join("\n"); +} + +@Component({ + standalone: true, + selector: "copilot-chat-user-message", + imports: [ + CommonModule, + CopilotSlot, + CopilotChatUserMessageRenderer, + CopilotChatUserMessageCopyButton, + CopilotChatUserMessageEditButton, + CopilotChatUserMessageToolbar, + CopilotChatUserMessageBranchNavigation, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (messageRendererTemplate || messageRendererComponent()) { + + + } @else { + + + } + + + @if (toolbarTemplate || toolbarComponent()) { + + + } @else { +
+
+ + @if (additionalToolbarItems()) { + + } + + + @if (copyButtonTemplate || copyButtonComponent()) { + + + } @else { + + + } + + + @if (true) { + @if (editButtonTemplate || editButtonComponent()) { + + + } @else { + + + } + } + + + @if (showBranchNavigation()) { + @if (branchNavigationTemplate || branchNavigationComponent()) { + + + } @else { + + + } + } +
+
+ } +
+ `, + styles: [ + ` + :host { + display: block; + width: 100%; + } + `, + ], +}) +export class CopilotChatUserMessage { + // Capture templates from content projection + @ContentChild("messageRenderer", { read: TemplateRef }) + messageRendererTemplate?: TemplateRef; + @ContentChild("toolbar", { read: TemplateRef }) + toolbarTemplate?: TemplateRef; + @ContentChild("copyButton", { read: TemplateRef }) + copyButtonTemplate?: TemplateRef; + @ContentChild("editButton", { read: TemplateRef }) + editButtonTemplate?: TemplateRef; + @ContentChild("branchNavigation", { read: TemplateRef }) + branchNavigationTemplate?: TemplateRef; + + // Props for tweaking default components + messageRendererClass = input(); + toolbarClass = input(); + copyButtonClass = input(); + editButtonClass = input(); + branchNavigationClass = input(); + + // Component inputs for overrides + messageRendererComponent = input | undefined>(); + toolbarComponent = input | undefined>(); + copyButtonComponent = input | undefined>(); + editButtonComponent = input | undefined>(); + branchNavigationComponent = input | undefined>(); + + // Regular inputs + message = input(); + branchIndex = input(); + numberOfBranches = input(); + additionalToolbarItems = input | undefined>(); + inputClass = input(); + + // Output events + editMessage = output(); + switchToBranch = output(); + + // Derived values + branchIndexValue = computed(() => this.branchIndex() ?? 0); + numberOfBranchesValue = computed(() => this.numberOfBranches() ?? 1); + + // Default components + CopilotChatUserMessageRenderer = CopilotChatUserMessageRenderer; + CopilotChatUserMessageToolbar = CopilotChatUserMessageToolbar; + CopilotChatUserMessageCopyButton = CopilotChatUserMessageCopyButton; + CopilotChatUserMessageEditButton = CopilotChatUserMessageEditButton; + CopilotChatUserMessageBranchNavigation = CopilotChatUserMessageBranchNavigation; + + // Computed values + showBranchNavigation = computed(() => (this.numberOfBranches() ?? 1) > 1); + + computedClass = computed(() => cn("flex flex-col items-end group pt-10", this.inputClass())); + + // Context for slots (reactive via signals) + flattenedContent = computed(() => flattenUserMessageContent(this.message()?.content)); + + messageRendererContext = computed(() => ({ + content: this.flattenedContent(), + })); + + // Output maps for slots + copyButtonOutputs = { clicked: () => this.handleCopy() }; + editButtonOutputs = { clicked: () => this.handleEdit() }; + + branchNavigationContext = computed(() => ({ + currentBranch: this.branchIndexValue(), + numberOfBranches: this.numberOfBranchesValue(), + onSwitchToBranch: (props) => this.handleSwitchToBranch(props), + message: this.message()!, + })); + + toolbarContext = computed(() => ({ + children: null, // Will be populated by the toolbar content + })); + + handleCopy(): void { + // Copy is handled by the button component itself + // This is just for any additional logic if needed + } + + handleEdit(): void { + this.editMessage.emit({ message: this.message()! }); + } + + handleSwitchToBranch(props: CopilotChatUserMessageOnSwitchToBranchProps): void { + this.switchToBranch.emit(props); + } + constructor() {} +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.types.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.types.ts new file mode 100644 index 0000000000..a733e1f237 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-user-message.types.ts @@ -0,0 +1,36 @@ +import type { UserMessage } from "@ag-ui/core"; + +export interface CopilotChatUserMessageOnEditMessageProps { + message: UserMessage; +} + +export interface CopilotChatUserMessageOnSwitchToBranchProps { + message: UserMessage; + branchIndex: number; + numberOfBranches: number; +} + +// Context interfaces for slots +export interface MessageRendererContext { + content: string; +} + +export interface CopyButtonContext { + content?: string; + copied?: boolean; +} + +export interface EditButtonContext { + // Empty context - click handled via outputs map +} + +export interface BranchNavigationContext { + currentBranch: number; + numberOfBranches: number; + onSwitchToBranch?: (props: CopilotChatUserMessageOnSwitchToBranchProps) => void; + message: UserMessage; +} + +export interface UserMessageToolbarContext { + children?: any; +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-disclaimer.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-disclaimer.ts new file mode 100644 index 0000000000..3455fbbe2a --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-disclaimer.ts @@ -0,0 +1,50 @@ +import { + Component, + input, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { cn } from "../..//utils"; +import { injectChatLabels } from "../../chat-config"; + +/** + * Disclaimer component for CopilotChatView + * Shows configurable disclaimer text below the input + * Integrates with CopilotChatConfigurationService for labels + */ +@Component({ + selector: "copilot-chat-view-disclaimer", + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ {{ disclaimerText }} +
+ `, +}) +export class CopilotChatViewDisclaimer { + inputClass = input(); + text = input(); + + readonly labels = injectChatLabels(); + + // Get disclaimer text from input or configuration + get disclaimerText(): string { + if (this.text()) { + return this.text() as string; + } + + return this.labels.chatDisclaimerText; + } + + // Computed class matching React exactly + get computedClass(): string { + return cn( + "text-center text-xs text-muted-foreground py-3 px-4 max-w-3xl mx-auto", + this.inputClass() + ); + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-feather.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-feather.ts new file mode 100644 index 0000000000..af2f3ba3cf --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-feather.ts @@ -0,0 +1,42 @@ +import { + Component, + input, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { cn } from "../../utils"; + +/** + * Feather component for CopilotChatView + * Creates a gradient overlay effect between messages and input + * Matches React implementation exactly with same Tailwind classes + */ +@Component({ + selector: "copilot-chat-view-feather", + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: `
`, +}) +export class CopilotChatViewFeather { + inputClass = input(); + style = input<{ [key: string]: any } | undefined>(); + + // Computed class matching React exactly + get computedClass(): string { + return cn( + // Positioning + "absolute bottom-0 left-0 right-4 h-24 pointer-events-none z-10", + // Gradient + "bg-gradient-to-t", + // Light mode colors + "from-white via-white to-transparent", + // Dark mode colors + "dark:from-[rgb(33,33,33)] dark:via-[rgb(33,33,33)]", + // Custom classes + this.inputClass() + ); + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-handlers.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-handlers.ts new file mode 100644 index 0000000000..5c5340d13a --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-handlers.ts @@ -0,0 +1,14 @@ +import { Injectable, signal } from "@angular/core"; + +@Injectable({ providedIn: "root" }) +export class CopilotChatViewHandlers { + // Assistant message handler availability + hasAssistantThumbsUpHandler = signal(false); + hasAssistantThumbsDownHandler = signal(false); + hasAssistantReadAloudHandler = signal(false); + hasAssistantRegenerateHandler = signal(false); + + // User message handler availability + hasUserCopyHandler = signal(false); + hasUserEditHandler = signal(false); +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-input-container.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-input-container.ts new file mode 100644 index 0000000000..3b6f2f4400 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-input-container.ts @@ -0,0 +1,80 @@ +import { + Component, + input, + ChangeDetectionStrategy, + ViewEncapsulation, + forwardRef, + ElementRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { CopilotSlot } from "../../slots/copilot-slot"; +import { CopilotChatInput } from "./copilot-chat-input"; +import { CopilotChatViewDisclaimer } from "./copilot-chat-view-disclaimer"; +import { cn } from "../../utils"; + +/** + * InputContainer component for CopilotChatView + * Container for input and disclaimer components + * Uses ForwardRef for DOM access + */ +@Component({ + standalone: true, + selector: "copilot-chat-view-input-container", + imports: [CommonModule, CopilotSlot], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: ElementRef, + useExisting: forwardRef(() => CopilotChatViewInputContainer), + }, + ], + template: ` +
+ +
+ + +
+ + + + +
+ `, +}) +export class CopilotChatViewInputContainer extends ElementRef { + inputContainerClass = input(); + + // Input slot configuration + input = input(); + inputClass = input(); + + // Disclaimer slot configuration + disclaimer = input(); + disclaimerText = input(); + disclaimerClass = input(); + + // Default components + protected readonly defaultInputComponent = CopilotChatInput; + protected readonly defaultDisclaimerComponent = CopilotChatViewDisclaimer; + + constructor(elementRef: ElementRef) { + super(elementRef.nativeElement); + } + + get computedClass(): string { + return cn( + "absolute bottom-0 left-0 right-0 z-20", + this.inputContainerClass() + ); + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-to-bottom-button.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-to-bottom-button.ts new file mode 100644 index 0000000000..facd5a307a --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-to-bottom-button.ts @@ -0,0 +1,80 @@ +import { + Component, + input, + output, + ChangeDetectionStrategy, + ViewEncapsulation, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { LucideAngularModule, ChevronDown } from "lucide-angular"; +import { cn } from "../../utils"; + +/** + * ScrollToBottomButton component for CopilotChatView + * Matches React implementation exactly with same Tailwind classes + */ +@Component({ + standalone: true, + selector: "copilot-chat-view-scroll-to-bottom-button", + imports: [CommonModule, LucideAngularModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, +}) +export class CopilotChatViewScrollToBottomButton { + inputClass = input(); + disabled = input(false); + // Support function-style click handler via slot context + onClick = input<(() => void) | undefined>(); + + // Simple, idiomatic Angular output + clicked = output(); + + // Icon reference + protected readonly ChevronDown = ChevronDown; + + // Computed class matching React exactly + get computedClass(): string { + return cn( + // Base button styles + "rounded-full w-10 h-10 p-0", + // Background colors + "bg-white dark:bg-gray-900", + // Border and shadow + "shadow-lg border border-gray-200 dark:border-gray-700", + // Hover states + "hover:bg-gray-50 dark:hover:bg-gray-800", + // Layout + "flex items-center justify-center cursor-pointer", + // Transition + "transition-colors", + // Focus states + "focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + // Custom classes + this.inputClass() + ); + } + + handleClick(): void { + if (!this.disabled()) { + // Call input handler if provided (slot-style) + if (this.onClick()) { + this.onClick()!(); + } + this.clicked.emit(); + } + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts new file mode 100644 index 0000000000..a3c750073e --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts @@ -0,0 +1,343 @@ +import { + Component, + input, + output, + ViewChild, + ElementRef, + ChangeDetectionStrategy, + ViewEncapsulation, + signal, + computed, + OnInit, + AfterViewInit, + OnDestroy, + inject, + PLATFORM_ID, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule, isPlatformBrowser } from "@angular/common"; +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { CopilotSlot } from "../../slots/copilot-slot"; +import { CopilotChatMessageView } from "./copilot-chat-message-view"; +import { CopilotChatViewScrollToBottomButton } from "./copilot-chat-view-scroll-to-bottom-button"; +import { StickToBottom } from "../../directives/stick-to-bottom"; +import { ScrollPosition } from "../../scroll-position"; +import { Message } from "@ag-ui/client"; +import { cn } from "../../utils"; +import { Subject } from "rxjs"; +import { takeUntil } from "rxjs/operators"; + +/** + * ScrollView component for CopilotChatView + * Handles auto-scrolling and scroll position management + */ +@Component({ + standalone: true, + selector: "copilot-chat-view-scroll-view", + imports: [ + CommonModule, + ScrollingModule, + CopilotSlot, + CopilotChatMessageView, + StickToBottom, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ScrollPosition], + template: ` + @if (!hasMounted()) { + +
+
+ +
+
+ } @else if (!autoScroll()) { + +
+
+
+ +
+
+ @if (messageView()) { + + + } @else { + + + } +
+
+
+
+ + + @if (showScrollButton() && !isResizing()) { +
+ + +
+ } +
+ } @else { + +
+
+ +
+ +
+
+ @if (messageView()) { + + + } @else { + + + } +
+
+
+
+ + + @if (!isAtBottom() && !isResizing()) { +
+ + +
+ } +
+ } + `, +}) +export class CopilotChatViewScrollView + implements OnInit, AfterViewInit, OnDestroy +{ + private cdr = inject(ChangeDetectorRef); + + autoScroll = input(true); + + inputContainerHeight = input(0); + + isResizing = input(false); + inputClass = input(); + messages = input([]); + messageView = input(); + messageViewClass = input(); + showCursor = input(false); + + // Handler availability flags removed in favor of DI service + + // Slot inputs + scrollToBottomButton = input(); + scrollToBottomButtonClass = input(); + + // Output events (bubbled from message view) + assistantMessageThumbsUp = output<{ message: Message }>(); + assistantMessageThumbsDown = output<{ message: Message }>(); + assistantMessageReadAloud = output<{ message: Message }>(); + assistantMessageRegenerate = output<{ message: Message }>(); + userMessageCopy = output<{ message: Message }>(); + userMessageEdit = output<{ message: Message }>(); + + // ViewChild references + @ViewChild("scrollContainer", { read: ElementRef }) + scrollContainer?: ElementRef; + @ViewChild("contentContainer", { read: ElementRef }) + contentContainer?: ElementRef; + @ViewChild(StickToBottom) stickToBottomDirective?: StickToBottom; + + // Default components + protected readonly defaultMessageViewComponent = CopilotChatMessageView; + protected readonly defaultScrollToBottomButtonComponent = + CopilotChatViewScrollToBottomButton; + + // Signals + protected hasMounted = signal(false); + protected showScrollButton = signal(false); + protected isAtBottom = signal(true); + protected paddingBottom = computed(() => this.inputContainerHeight() + 32); + + // Computed class + protected computedClass = computed(() => cn(this.inputClass())); + + private destroy$ = new Subject(); + private platformId = inject(PLATFORM_ID); + private scrollPositionService = inject(ScrollPosition); + + // No mirroring of inputs; derive directly via computed() + + ngOnInit(): void { + // Check if we're in the browser + if (isPlatformBrowser(this.platformId)) { + // Set mounted after a tick to allow for hydration + setTimeout(() => { + this.hasMounted.set(true); + }, 0); + } + } + + ngAfterViewInit(): void { + if (!this.autoScroll()) { + // Wait for the view to be fully rendered after hasMounted is set + setTimeout(() => { + if (this.scrollContainer) { + // Check initial scroll position + const initialState = this.scrollPositionService.getScrollState( + this.scrollContainer.nativeElement, + 10 + ); + this.showScrollButton.set(!initialState.isAtBottom); + + // Monitor scroll position for manual mode + this.scrollPositionService + .monitorScrollPosition(this.scrollContainer, 10) + .pipe(takeUntil(this.destroy$)) + .subscribe((state) => { + this.showScrollButton.set(!state.isAtBottom); + }); + } + }, 100); + } + } + + /** + * Handle isAtBottom change from StickToBottom directive + */ + onIsAtBottomChange(isAtBottom: boolean): void { + this.isAtBottom.set(isAtBottom); + } + + /** + * Scroll to bottom for manual mode + */ + scrollToBottom(): void { + if (this.scrollContainer) { + this.scrollPositionService.scrollToBottom(this.scrollContainer, true); + } + } + + /** + * Scroll to bottom for stick-to-bottom mode + */ + scrollToBottomFromStick(): void { + if (this.stickToBottomDirective) { + this.stickToBottomDirective.scrollToBottom("smooth"); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // Output maps for slots + scrollToBottomOutputs = { clicked: () => this.scrollToBottom() }; + scrollToBottomFromStickOutputs = { + clicked: () => this.scrollToBottomFromStick(), + }; + + // Context methods for templates + messageViewContext(): any { + return { + messages: this.messages(), + inputClass: this.messageViewClass(), + showCursor: this.showCursor(), + }; + } + + scrollToBottomContext(): any { + return { + inputClass: this.scrollToBottomButtonClass(), + onClick: () => this.scrollToBottom(), + }; + } + + scrollToBottomFromStickContext(): any { + return { + inputClass: this.scrollToBottomButtonClass(), + onClick: () => this.scrollToBottomFromStick(), + }; + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.ts new file mode 100644 index 0000000000..c5670423b8 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.ts @@ -0,0 +1,409 @@ +import { + Component, + ContentChild, + TemplateRef, + Type, + ViewChild, + ElementRef, + ChangeDetectionStrategy, + ChangeDetectorRef, + ViewEncapsulation, + computed, + signal, + OnInit, + OnChanges, + OnDestroy, + AfterViewInit, + input, + output, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { CopilotSlot } from "../../slots/copilot-slot"; +import { CopilotChatViewScrollView } from "./copilot-chat-view-scroll-view"; +import { CopilotChatViewScrollToBottomButton } from "./copilot-chat-view-scroll-to-bottom-button"; +import { CopilotChatViewFeather } from "./copilot-chat-view-feather"; +import { CopilotChatViewInputContainer } from "./copilot-chat-view-input-container"; +import { CopilotChatViewDisclaimer } from "./copilot-chat-view-disclaimer"; +import { Message } from "@ag-ui/client"; +import { cn } from "../../utils"; +import { ResizeObserverService } from "../../resize-observer"; +import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers"; +import { Subject } from "rxjs"; +import { takeUntil } from "rxjs/operators"; + +/** + * CopilotChatView component - Angular port of the React component. + * A complete chat interface with message feed and input components. + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + standalone: true, + selector: "copilot-chat-view", + imports: [CommonModule, CopilotSlot, CopilotChatViewScrollView], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ResizeObserverService, CopilotChatViewHandlers], + template: ` + + @if (customLayoutTemplate) { + + } @else { + +
+ + + + + + + + + + + +
+ } + `, +}) +export class CopilotChatView + implements OnInit, OnChanges, AfterViewInit, OnDestroy +{ + // Core inputs matching React props + messages = input([]); + autoScroll = input(true); + showCursor = input(false); + + // MessageView slot inputs + messageViewComponent = input | undefined>(undefined); + messageViewTemplate = input | undefined>(undefined); + messageViewClass = input(undefined); + + // ScrollView slot inputs + scrollViewComponent = input | undefined>(undefined); + scrollViewTemplate = input | undefined>(undefined); + scrollViewClass = input(undefined); + + // ScrollToBottomButton slot inputs + scrollToBottomButtonComponent = input | undefined>(undefined); + scrollToBottomButtonTemplate = input | undefined>(undefined); + scrollToBottomButtonClass = input(undefined); + + // Input slot inputs + inputComponent = input | undefined>(undefined); + inputTemplate = input | undefined>(undefined); + + // InputContainer slot inputs + inputContainerComponent = input | undefined>(undefined); + inputContainerTemplate = input | undefined>(undefined); + inputContainerClass = input(undefined); + + // Feather slot inputs + featherComponent = input | undefined>(undefined); + featherTemplate = input | undefined>(undefined); + featherClass = input(undefined); + + // Disclaimer slot inputs + disclaimerComponent = input | undefined>(undefined); + disclaimerTemplate = input | undefined>(undefined); + disclaimerClass = input(undefined); + disclaimerText = input(undefined); + + // Custom layout template (render prop pattern) + @ContentChild("customLayout") customLayoutTemplate?: TemplateRef; + + // Named template slots for deep customization + @ContentChild("sendButton") sendButtonTemplate?: TemplateRef; + @ContentChild("toolbar") toolbarTemplate?: TemplateRef; + @ContentChild("textArea") textAreaTemplate?: TemplateRef; + @ContentChild("audioRecorder") audioRecorderTemplate?: TemplateRef; + @ContentChild("assistantMessageMarkdownRenderer") + assistantMessageMarkdownRendererTemplate?: TemplateRef; + @ContentChild("thumbsUpButton") thumbsUpButtonTemplate?: TemplateRef; + @ContentChild("thumbsDownButton") thumbsDownButtonTemplate?: TemplateRef; + @ContentChild("readAloudButton") readAloudButtonTemplate?: TemplateRef; + @ContentChild("regenerateButton") regenerateButtonTemplate?: TemplateRef; + + // Output events for assistant message actions (bubbled from child components) + assistantMessageThumbsUp = output<{ message: Message }>(); + assistantMessageThumbsDown = output<{ message: Message }>(); + assistantMessageReadAloud = output<{ message: Message }>(); + assistantMessageRegenerate = output<{ message: Message }>(); + + // Output events for user message actions (if applicable) + userMessageCopy = output<{ message: Message }>(); + userMessageEdit = output<{ message: Message }>(); + + // ViewChild references + @ViewChild("inputContainerSlotRef", { read: ElementRef }) + inputContainerSlotRef?: ElementRef; + + // Default components for slots + protected readonly defaultScrollViewComponent = CopilotChatViewScrollView; + protected readonly defaultScrollToBottomButtonComponent = + CopilotChatViewScrollToBottomButton; + protected readonly defaultInputContainerComponent = + CopilotChatViewInputContainer; + protected readonly defaultFeatherComponent = CopilotChatViewFeather; + protected readonly defaultDisclaimerComponent = CopilotChatViewDisclaimer; + + // Signals for reactive state + protected messagesValue = computed(() => this.messages()); + protected autoScrollSignal = computed(() => this.autoScroll()); + protected showCursorSignal = computed(() => this.showCursor()); + protected disclaimerTextSignal = computed(() => this.disclaimerText()); + protected disclaimerClassSignal = computed(() => this.disclaimerClass()); + protected inputContainerHeight = signal(0); + protected isResizing = signal(false); + protected contentPaddingBottom = computed( + () => this.inputContainerHeight() + 32 + ); + + // Computed signals + protected computedClass = computed(() => cn("relative h-full")); + + // Slot resolution computed signals + protected messageViewSlot = computed( + () => this.messageViewTemplate() || this.messageViewComponent() + ); + + protected scrollViewSlot = computed( + () => this.scrollViewTemplate() || this.scrollViewComponent() + ); + + protected scrollToBottomButtonSlot = computed( + () => + this.scrollToBottomButtonTemplate() || + this.scrollToBottomButtonComponent() + ); + + protected inputSlot = computed( + () => this.inputTemplate() || this.inputComponent() + ); + + protected inputContainerSlot = computed( + () => this.inputContainerTemplate() || this.inputContainerComponent() + ); + + protected featherSlot = computed( + () => this.featherTemplate() || this.featherComponent() + ); + + protected disclaimerSlot = computed( + () => this.disclaimerTemplate() || this.disclaimerComponent() + ); + + // Context objects for slots + protected scrollViewContext = computed(() => ({ + autoScroll: this.autoScrollSignal(), + scrollToBottomButton: this.scrollToBottomButtonSlot(), + scrollToBottomButtonClass: this.scrollToBottomButtonClass(), + inputContainerHeight: this.inputContainerHeight(), + isResizing: this.isResizing(), + messages: this.messagesValue(), + messageView: this.messageViewSlot(), + messageViewClass: this.messageViewClass(), + })); + + // Removed scrollViewPropsComputed - no longer needed + + protected inputContainerContext = computed(() => ({ + input: this.inputSlot(), + disclaimer: this.disclaimerSlot(), + disclaimerText: this.disclaimerTextSignal(), + disclaimerClass: this.disclaimerClassSignal(), + inputContainerClass: this.inputContainerClass(), + })); + + // Removed inputContainerPropsComputed - no longer needed + + // Layout context for custom templates (render prop pattern) + protected layoutContext = computed(() => ({ + messageView: this.messageViewSlot(), + input: this.inputSlot(), + scrollView: this.scrollViewSlot(), + scrollToBottomButton: this.scrollToBottomButtonSlot(), + feather: this.featherSlot(), + inputContainer: this.inputContainerSlot(), + disclaimer: this.disclaimerSlot(), + })); + + private destroy$ = new Subject(); + private resizeTimeoutRef?: number; + + constructor( + private resizeObserverService: ResizeObserverService, + private cdr: ChangeDetectorRef, + private handlers: CopilotChatViewHandlers + ) { + // Clear any pending resize timeout when toggling isResizing, without signal writes here + } + + ngOnInit(): void { + // Initialize handler availability in the view-scoped service + // OutputEmitterRef doesn't expose 'observed'; default to true to enable UI affordances + this.handlers.hasAssistantThumbsUpHandler.set(true); + this.handlers.hasAssistantThumbsDownHandler.set(true); + this.handlers.hasAssistantReadAloudHandler.set(true); + this.handlers.hasAssistantRegenerateHandler.set(true); + this.handlers.hasUserCopyHandler.set(true); + this.handlers.hasUserEditHandler.set(true); + } + + ngOnChanges(): void { + // Keep handler availability in sync (assume available) + this.handlers.hasAssistantThumbsUpHandler.set(true); + this.handlers.hasAssistantThumbsDownHandler.set(true); + this.handlers.hasAssistantReadAloudHandler.set(true); + this.handlers.hasAssistantRegenerateHandler.set(true); + this.handlers.hasUserCopyHandler.set(true); + this.handlers.hasUserEditHandler.set(true); + } + + ngAfterViewInit(): void { + // Don't set a default height - measure it dynamically + + // Set up input container height monitoring + const measureAndObserve = () => { + if ( + !this.inputContainerSlotRef || + !this.inputContainerSlotRef.nativeElement + ) { + return false; + } + + // The slot ref points to the copilot-slot element + // We need to find the actual input container component inside it + const slotElement = this.inputContainerSlotRef.nativeElement; + const componentElement = slotElement.querySelector( + "copilot-chat-view-input-container" + ); + + if (!componentElement) { + return false; + } + + // Look for the absolute positioned div that contains the input + let innerDiv = componentElement.querySelector( + "div.absolute" + ) as HTMLElement; + + // If not found by class, try first child + if (!innerDiv) { + innerDiv = componentElement.firstElementChild as HTMLElement; + } + + if (!innerDiv) { + return false; + } + + // Measure the actual height + const measuredHeight = innerDiv.offsetHeight; + + if (measuredHeight === 0) { + return false; + } + + // Success! Set the initial height + this.inputContainerHeight.set(measuredHeight); + this.cdr.detectChanges(); + + // Create an ElementRef wrapper for ResizeObserver + const innerDivRef = new ElementRef(innerDiv); + + // Set up ResizeObserver to track changes + this.resizeObserverService + .observeElement(innerDivRef, 0, 250) + .pipe(takeUntil(this.destroy$)) + .subscribe((state) => { + const newHeight = state.height; + + if (newHeight !== this.inputContainerHeight() && newHeight > 0) { + this.inputContainerHeight.set(newHeight); + this.isResizing.set(true); + this.cdr.detectChanges(); + + // Clear existing timeout + if (this.resizeTimeoutRef) { + clearTimeout(this.resizeTimeoutRef); + } + + // Set isResizing to false after a short delay + this.resizeTimeoutRef = window.setTimeout(() => { + this.isResizing.set(false); + this.resizeTimeoutRef = undefined; + this.cdr.detectChanges(); + }, 250); + } + }); + + return true; + }; + + // Try to measure immediately + if (!measureAndObserve()) { + // If failed, retry with increasing delays + let attempts = 0; + const maxAttempts = 10; + + const retry = () => { + attempts++; + if (measureAndObserve()) { + // Successfully measured + } else if (attempts < maxAttempts) { + // Exponential backoff: 50ms, 100ms, 200ms, 400ms, etc. + const delay = 50 * Math.pow(2, Math.min(attempts - 1, 4)); + setTimeout(retry, delay); + } else { + // Failed to measure after max attempts + } + }; + + // Start retry with first delay + setTimeout(retry, 50); + } + } + + ngOnDestroy(): void { + if (this.resizeTimeoutRef) { + clearTimeout(this.resizeTimeoutRef); + } + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.types.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.types.ts new file mode 100644 index 0000000000..dc9b8b23ea --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat-view.types.ts @@ -0,0 +1,52 @@ +import { Type, TemplateRef } from "@angular/core"; +import { Message } from "@ag-ui/client"; + +/** + * Props for CopilotChatView component + */ +export interface CopilotChatViewProps { + messages?: Message[]; + autoScroll?: boolean; + + // Slot configurations + messageViewComponent?: Type; + messageViewTemplate?: TemplateRef; + messageViewClass?: string; + + scrollViewComponent?: Type; + scrollViewTemplate?: TemplateRef; + scrollViewClass?: string; + + scrollToBottomButtonComponent?: Type; + scrollToBottomButtonTemplate?: TemplateRef; + scrollToBottomButtonClass?: string; + + inputComponent?: Type; + inputTemplate?: TemplateRef; + + inputContainerComponent?: Type; + inputContainerTemplate?: TemplateRef; + inputContainerClass?: string; + + featherComponent?: Type; + featherTemplate?: TemplateRef; + featherClass?: string; + + disclaimerComponent?: Type; + disclaimerTemplate?: TemplateRef; + disclaimerClass?: string; + disclaimerText?: string; +} + +/** + * Context for custom layout template + */ +export interface CopilotChatViewLayoutContext { + messageView: any; + input: any; + scrollView: any; + scrollToBottomButton: any; + feather: any; + inputContainer: any; + disclaimer: any; +} diff --git a/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat.ts b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat.ts new file mode 100644 index 0000000000..48cf655ca8 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/components/chat/copilot-chat.ts @@ -0,0 +1,178 @@ +import { + Component, + input, + ChangeDetectionStrategy, + ViewEncapsulation, + signal, + effect, + ChangeDetectorRef, + Injector, + Type, + computed, + inject, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { CopilotChatView } from "./copilot-chat-view"; + +import { DEFAULT_AGENT_ID, randomUUID } from "@copilotkitnext/shared"; +import { Message, AbstractAgent } from "@ag-ui/client"; +import { injectAgentStore } from "../../agent"; +import { ChatState } from "../../chat-state"; + +/** + * CopilotChat component - Angular equivalent of React's + * Provides a complete chat interface that wires an agent to the chat view + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: "copilot-chat", + standalone: true, + imports: [CommonModule, CopilotChatView], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + + `, + providers: [ + { + provide: ChatState, + useExisting: CopilotChat, + }, + ], +}) +export class CopilotChat implements ChatState { + readonly inputValue = signal(""); + readonly agentId = input(); + readonly threadId = input(); + readonly inputComponent = input | undefined>(); + private readonly resolvedAgentId = computed( + () => this.agentId() ?? DEFAULT_AGENT_ID + ); + readonly agentStore = injectAgentStore(this.resolvedAgentId); + // readonly chatConfig = injectChatConfig(); + readonly cdr = inject(ChangeDetectorRef); + readonly injector = inject(Injector); + + protected messages = computed(() => this.agentStore()?.messages()); + protected isRunning = computed(() => this.agentStore()?.isRunning()); + protected showCursor = signal(false); + + private generatedThreadId: string = randomUUID(); + private hasConnectedOnce = false; + + constructor() { + // Connect once when agent becomes available + // Connect once when agent becomes available + effect( + () => { + const a = this.agentStore()?.agent; + if (!a) return; + // Apply thread id when agent is available + a.threadId = this.threadId() || this.generatedThreadId; + if (!this.hasConnectedOnce) { + this.hasConnectedOnce = true; + if ("isCopilotKitAgent" in (a as any)) { + this.connectToAgent(a); + } else { + // Non-CopilotKit agent: nothing to connect; keep default cursor state + } + } + }, + { allowSignalWrites: true } + ); + + // Keep agent threadId in sync with input + effect(() => { + const a = this.agentStore()?.agent; + if (a) { + a.threadId = this.threadId() || this.generatedThreadId; + } + }); + } + + private async connectToAgent(agent: AbstractAgent): Promise { + if (!agent) return; + + this.showCursor.set(true); + this.cdr.markForCheck(); + + try { + await agent.runAgent( + { forwardedProps: { __copilotkitConnect: true } }, + { + onTextMessageStartEvent: () => { + this.showCursor.set(false); + this.cdr.detectChanges(); + }, + onToolCallStartEvent: () => { + this.showCursor.set(false); + this.cdr.detectChanges(); + }, + } + ); + this.showCursor.set(false); + this.cdr.markForCheck(); + } catch (error) { + console.error("Failed to connect to agent:", error); + this.showCursor.set(false); + this.cdr.markForCheck(); + } + } + + async submitInput(value: string): Promise { + const agent = this.agentStore()?.agent; + if (!agent || !value.trim()) return; + + // Add user message + const userMessage: Message = { + id: randomUUID(), + role: "user", + content: value, + }; + agent.addMessage(userMessage); + + // Clear the input + this.inputValue.set(""); + + // Show cursor while processing + this.showCursor.set(true); + this.cdr.markForCheck(); + + // Run the agent with named subscriber callbacks + try { + await agent.runAgent( + {}, + { + onTextMessageStartEvent: () => { + this.showCursor.set(false); + this.cdr.detectChanges(); + }, + onToolCallStartEvent: () => { + this.showCursor.set(false); + this.cdr.detectChanges(); + }, + } + ); + } catch (error) { + console.error("Agent run error:", error); + } finally { + this.showCursor.set(false); + this.cdr.markForCheck(); + } + } + + changeInput(value: string): void { + this.inputValue.set(value); + } +} diff --git a/src/v2.x/packages/angular/src/lib/config.ts b/src/v2.x/packages/angular/src/lib/config.ts new file mode 100644 index 0000000000..502ea1c5e6 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/config.ts @@ -0,0 +1,31 @@ +import { inject, InjectionToken, Provider } from "@angular/core"; +import { AbstractAgent } from "@ag-ui/client"; +import { + ClientTool, + FrontendToolConfig, + HumanInTheLoopConfig, + RenderToolCallConfig, +} from "./tools"; + +export interface CopilotKitConfig { + runtimeUrl?: string; + headers?: Record; + properties?: Record; + agents?: Record; + tools?: ClientTool[]; + renderToolCalls?: RenderToolCallConfig[]; + frontendTools?: FrontendToolConfig[]; + humanInTheLoop?: HumanInTheLoopConfig[]; +} + +export const COPILOT_KIT_CONFIG = new InjectionToken( + "COPILOT_KIT_CONFIG" +); + +export function injectCopilotKitConfig(): CopilotKitConfig { + return inject(COPILOT_KIT_CONFIG); +} + +export function provideCopilotKit(config: CopilotKitConfig): Provider { + return { provide: COPILOT_KIT_CONFIG, useValue: config }; +} diff --git a/src/v2.x/packages/angular/src/lib/copilotkit.spec.ts b/src/v2.x/packages/angular/src/lib/copilotkit.spec.ts new file mode 100644 index 0000000000..27b80f1e55 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/copilotkit.spec.ts @@ -0,0 +1,240 @@ +import { Component, Injector, signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { CopilotKit } from "./copilotkit"; +import { provideCopilotKit } from "./config"; +import { HumanInTheLoop } from "./human-in-the-loop"; + +const mockSubscribe = vi.fn(); +const mockAddTool = vi.fn(); +const mockRemoveTool = vi.fn(); +const mockSetRuntimeUrl = vi.fn(); +const mockSetHeaders = vi.fn(); +const mockSetProperties = vi.fn(); +const mockSetAgents = vi.fn(); +const mockGetAgent = vi.fn(); + +let lastCoreInstance: any; +let lastCoreConfig: any; + +vi.mock("@copilotkitnext/core", () => { + class MockCopilotKitCore { + readonly subscribe = mockSubscribe; + readonly addTool = mockAddTool; + readonly removeTool = mockRemoveTool; + readonly setRuntimeUrl = mockSetRuntimeUrl; + readonly setHeaders = mockSetHeaders; + readonly setProperties = mockSetProperties; + readonly setAgents__unsafe_dev_only = mockSetAgents; + readonly getAgent = mockGetAgent; + agents: Record = {}; + listener?: Parameters[0]; + + constructor(config: any) { + lastCoreConfig = config; + lastCoreInstance = this; + mockSubscribe.mockImplementationOnce((listener: any) => { + this.listener = listener; + return { unsubscribe: vi.fn() }; + }); + } + } + + return { CopilotKitCore: MockCopilotKitCore } as any; +}); + +describe("CopilotKit", () => { + beforeEach(() => { + TestBed.resetTestingModule(); + vi.clearAllMocks(); + }); + + it("initialises core with transformed tool and renderer config", () => { + @Component({ + standalone: true, + selector: "dummy-tool", + template: "", + }) + class DummyToolComponent { + toolCall = signal({} as any); + } + + TestBed.configureTestingModule({ + providers: [ + provideCopilotKit({ + runtimeUrl: "https://runtime.local", + headers: { Authorization: "token" }, + properties: { region: "eu" }, + tools: [ + { + name: "search", + description: "Search something", + parameters: z.object({ query: z.string() }), + handler: async () => "done", + renderer: DummyToolComponent, + }, + ], + renderToolCalls: [ + { + name: "custom", + args: z.object({ query: z.string() }), + component: DummyToolComponent, + agentId: "agent-a", + }, + ], + }), + ], + }); + + const copilotKit = TestBed.inject(CopilotKit); + + expect(lastCoreConfig.runtimeUrl).toBe("https://runtime.local"); + expect(lastCoreConfig.headers).toEqual({ + Authorization: "token", + }); + expect(lastCoreConfig.tools).toEqual([ + expect.objectContaining({ + name: "search", + description: "Search something", + parameters: expect.anything(), + handler: expect.any(Function), + renderer: DummyToolComponent, + }), + ]); + + expect(copilotKit.toolCallRenderConfigs()).toEqual([ + { + name: "custom", + args: expect.anything(), + component: DummyToolComponent, + agentId: "agent-a", + }, + { + name: "search", + args: expect.anything(), + component: DummyToolComponent, + agentId: undefined, + }, + ]); + }); + + it("tracks client tools and executes handlers within injection context", async () => { + const handlerSpy = vi.fn().mockResolvedValue("handled"); + TestBed.configureTestingModule({ + providers: [provideCopilotKit({})], + }); + + const copilotKit = TestBed.inject(CopilotKit); + const injector = TestBed.inject(Injector); + + copilotKit.addFrontendTool({ + name: "client", + description: "Client tool", + args: z.object({ value: z.string() }), + component: class { toolCall = signal({} as any); }, + handler: handlerSpy, + injector, + }); + + expect(mockAddTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "client" }) + ); + expect(copilotKit.clientToolCallRenderConfigs()).toHaveLength(1); + + const tool = mockAddTool.mock.calls.at(-1)![0]; + await tool.handler({ value: "ok" }); + expect(handlerSpy).toHaveBeenCalledWith({ value: "ok" }); + }); + + it("registers human-in-the-loop tools and delegates responses", async () => { + const onResultSpy = vi + .spyOn(HumanInTheLoop.prototype, "onResult") + .mockResolvedValue("result"); + + TestBed.configureTestingModule({ + providers: [provideCopilotKit({})], + }); + + const copilotKit = TestBed.inject(CopilotKit); + + const toolConfig = { + name: "approval", + args: z.object({ summary: z.string() }), + component: class { toolCall = signal({} as any); }, + toolCall: vi.fn(), + agentId: "agent-1", + } as const; + + copilotKit.addHumanInTheLoop(toolConfig); + + expect(copilotKit.humanInTheLoopToolRenderConfigs()).toEqual([ + toolConfig, + ]); + expect(mockAddTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "approval" }) + ); + + const tool = mockAddTool.mock.calls.at(-1)![0]; + await tool.handler({}, { id: "call-1", function: { name: "approval" } }); + expect(onResultSpy).toHaveBeenCalledWith("call-1", "approval"); + + onResultSpy.mockRestore(); + }); + + it("removes tools and renderer configs", () => { + TestBed.configureTestingModule({ + providers: [provideCopilotKit({})], + }); + + const copilotKit = TestBed.inject(CopilotKit); + + copilotKit.addRenderToolCall({ + name: "temp", + args: z.object({}), + component: class { toolCall = signal({} as any); }, + agentId: undefined, + }); + + copilotKit.removeTool("temp"); + + expect(mockRemoveTool).toHaveBeenCalledWith("temp"); + expect(copilotKit.toolCallRenderConfigs()).toEqual([]); + }); + + it("updates runtime configuration via core methods", () => { + TestBed.configureTestingModule({ + providers: [provideCopilotKit({})], + }); + + const copilotKit = TestBed.inject(CopilotKit); + + copilotKit.updateRuntime({ + runtimeUrl: "https://other", + headers: { Authorization: "different" }, + properties: { locale: "en" }, + agents: { a: {} as any }, + }); + + expect(mockSetRuntimeUrl).toHaveBeenCalledWith("https://other"); + expect(mockSetHeaders).toHaveBeenCalledWith({ Authorization: "different" }); + expect(mockSetProperties).toHaveBeenCalledWith({ locale: "en" }); + expect(mockSetAgents).toHaveBeenCalledWith({ a: {} }); + }); + + it("reflects agent updates from core subscriptions", () => { + TestBed.configureTestingModule({ + providers: [provideCopilotKit({})], + }); + + const copilotKit = TestBed.inject(CopilotKit); + const core = lastCoreInstance!; + + core.agents = { + agent1: { id: "agent1" }, + } as any; + + core.listener!.onAgentsChanged(); + expect(copilotKit.agents()).toEqual(core.agents); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/copilotkit.ts b/src/v2.x/packages/angular/src/lib/copilotkit.ts new file mode 100644 index 0000000000..0520b94c4f --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/copilotkit.ts @@ -0,0 +1,154 @@ +import { AbstractAgent } from "@ag-ui/client"; +import { FrontendTool, CopilotKitCore } from "@copilotkitnext/core"; +import { Injectable, Injector, Signal, WritableSignal, runInInjectionContext, signal, inject } from "@angular/core"; +import { FrontendToolConfig, HumanInTheLoopConfig, RenderToolCallConfig } from "./tools"; +import { injectCopilotKitConfig } from "./config"; +import { HumanInTheLoop } from "./human-in-the-loop"; + +@Injectable({ providedIn: "root" }) +export class CopilotKit { + readonly #config = injectCopilotKitConfig(); + readonly #hitl = inject(HumanInTheLoop); + readonly #rootInjector = inject(Injector); + readonly #agents = signal>(this.#config.agents ?? {}); + readonly agents = this.#agents.asReadonly(); + + readonly core = new CopilotKitCore({ + runtimeUrl: this.#config.runtimeUrl, + headers: this.#config.headers, + properties: this.#config.properties, + agents__unsafe_dev_only: this.#config.agents, + tools: this.#config.tools, + }); + + readonly #toolCallRenderConfigs: WritableSignal = signal([]); + readonly #clientToolCallRenderConfigs: WritableSignal = signal([]); + readonly #humanInTheLoopToolRenderConfigs: WritableSignal = signal([]); + + readonly toolCallRenderConfigs: Signal = this.#toolCallRenderConfigs.asReadonly(); + readonly clientToolCallRenderConfigs: Signal = this.#clientToolCallRenderConfigs.asReadonly(); + readonly humanInTheLoopToolRenderConfigs: Signal = + this.#humanInTheLoopToolRenderConfigs.asReadonly(); + + constructor() { + this.#config.renderToolCalls?.forEach((renderConfig) => { + this.addRenderToolCall(renderConfig); + }); + + this.#config.tools?.forEach((tool) => { + if (tool.renderer && tool.parameters) { + this.addRenderToolCall({ + name: tool.name, + args: tool.parameters, + component: tool.renderer, + agentId: tool.agentId, + }); + } + }); + + this.#config.frontendTools?.forEach((clientTool) => { + this.addFrontendTool({ ...clientTool, injector: this.#rootInjector }); + }); + + this.#config.humanInTheLoop?.forEach((humanInTheLoopTool) => { + this.addHumanInTheLoop(humanInTheLoopTool); + }); + + this.core.subscribe({ + onAgentsChanged: () => { + this.#agents.set(this.core.agents); + }, + }); + } + + #bindClientTool( + clientToolWithInjector: FrontendToolConfig & { + injector: Injector; + }, + ): FrontendTool { + const { injector, handler, ...frontendCandidate } = clientToolWithInjector; + + return { + ...frontendCandidate, + handler: (args) => runInInjectionContext(injector, () => handler(args)), + }; + } + + addFrontendTool( + clientToolWithInjector: FrontendToolConfig & { + injector: Injector; + }, + ): void { + const tool = this.#bindClientTool(clientToolWithInjector); + + this.core.addTool(tool); + + this.#clientToolCallRenderConfigs.update((current) => [...current, clientToolWithInjector]); + } + + addRenderToolCall(renderConfig: RenderToolCallConfig): void { + this.#toolCallRenderConfigs.update((current) => [...current, renderConfig]); + } + + #bindHumanInTheLoopTool(humanInTheLoopTool: HumanInTheLoopConfig): FrontendTool { + return { + ...humanInTheLoopTool, + handler: (args, toolCall) => { + return this.#hitl.onResult(toolCall.id, humanInTheLoopTool.name); + }, + }; + } + + addHumanInTheLoop(humanInTheLoopTool: HumanInTheLoopConfig): void { + this.#humanInTheLoopToolRenderConfigs.update((current) => [...current, humanInTheLoopTool]); + + const tool = this.#bindHumanInTheLoopTool(humanInTheLoopTool); + + this.core.addTool(tool); + } + + #isSameAgentId(target: T, agentId?: string): boolean { + if (agentId) { + return target.agentId === agentId; + } + + return true; + } + + removeTool(toolName: string, agentId?: string): void { + this.core.removeTool(toolName); + this.#clientToolCallRenderConfigs.update((current) => + current.filter((renderConfig) => renderConfig.name !== toolName && this.#isSameAgentId(renderConfig, agentId)), + ); + this.#humanInTheLoopToolRenderConfigs.update((current) => + current.filter((renderConfig) => renderConfig.name !== toolName && this.#isSameAgentId(renderConfig, agentId)), + ); + this.#toolCallRenderConfigs.update((current) => + current.filter((renderConfig) => renderConfig.name !== toolName && this.#isSameAgentId(renderConfig, agentId)), + ); + } + + getAgent(agentId: string): AbstractAgent | undefined { + return this.core.getAgent(agentId); + } + + updateRuntime(options: { + runtimeUrl?: string; + headers?: Record; + properties?: Record; + agents?: Record; + }): void { + if (options.runtimeUrl !== undefined) { + this.core.setRuntimeUrl(options.runtimeUrl); + } + if (options.headers !== undefined) { + this.core.setHeaders(options.headers); + } + if (options.properties !== undefined) { + this.core.setProperties(options.properties); + } + if (options.agents !== undefined) { + this.core.setAgents__unsafe_dev_only(options.agents); + } + } +} diff --git a/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-agent-context.directive.spec.ts b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-agent-context.directive.spec.ts new file mode 100644 index 0000000000..0fe4b43a2b --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-agent-context.directive.spec.ts @@ -0,0 +1,84 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CopilotKitAgentContext } from "../copilotkit-agent-context"; +import { CopilotKit } from "../../copilotkit"; + +class CopilotKitCoreStub { + addContext = vi.fn(() => "ctx-1"); + removeContext = vi.fn(); +} + +class CopilotKitStub { + core = new CopilotKitCoreStub(); +} + +describe("CopilotKitAgentContext", () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [{ provide: CopilotKit, useClass: CopilotKitStub }], + }); + }); + + it("adds and removes context for separate description/value inputs", () => { + @Component({ + standalone: true, + imports: [CopilotKitAgentContext], + template: ` +
+ `, + }) + class HostComponent { + description = "Initial"; + value = { foo: "bar" }; + } + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const core = TestBed.inject(CopilotKit).core as CopilotKitCoreStub; + expect(core.addContext).toHaveBeenCalledWith({ + description: "Initial", + value: { foo: "bar" }, + }); + + fixture.componentInstance.description = "Updated"; + fixture.detectChanges(); + + expect(core.removeContext).toHaveBeenCalledWith("ctx-1"); + expect(core.addContext).toHaveBeenLastCalledWith({ + description: "Updated", + value: { foo: "bar" }, + }); + + fixture.destroy(); + expect(core.removeContext).toHaveBeenLastCalledWith("ctx-1"); + }); + + it("supports passing full context object via directive binding", () => { + @Component({ + standalone: true, + imports: [CopilotKitAgentContext], + template: ` +
+ `, + }) + class ObjectHostComponent { + context = { description: "All", value: 42 }; + } + + const fixture = TestBed.createComponent(ObjectHostComponent); + fixture.detectChanges(); + + const core = TestBed.inject(CopilotKit).core as CopilotKitCoreStub; + expect(core.addContext).toHaveBeenCalledWith({ + description: "All", + value: 42, + }); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-chat-config.directive.spec.ts b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-chat-config.directive.spec.ts new file mode 100644 index 0000000000..84803de969 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-chat-config.directive.spec.ts @@ -0,0 +1,44 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { describe, expect, it } from "vitest"; +import { + COPILOT_CHAT_DEFAULT_LABELS, + injectChatLabels, + provideCopilotChatLabels, +} from "../../chat-config"; + +describe("Copilot chat labels", () => { + it("returns default labels when no provider is registered", () => { + @Component({ standalone: true, template: "" }) + class HostComponent { + labels = injectChatLabels(); + } + + TestBed.configureTestingModule({}); + const fixture = TestBed.createComponent(HostComponent); + + expect(fixture.componentInstance.labels).toEqual(COPILOT_CHAT_DEFAULT_LABELS); + }); + + it("merges provided labels with defaults", () => { + @Component({ standalone: true, template: "" }) + class HostComponent { + labels = injectChatLabels(); + } + + TestBed.configureTestingModule({ + providers: [ + provideCopilotChatLabels({ + chatInputPlaceholder: "Override", + }), + ], + }); + + const fixture = TestBed.createComponent(HostComponent); + expect(fixture.componentInstance.labels.chatInputPlaceholder).toBe( + "Override" + ); + expect(fixture.componentInstance.labels.assistantMessageToolbarCopyCodeLabel) + .toBe(COPILOT_CHAT_DEFAULT_LABELS.assistantMessageToolbarCopyCodeLabel); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-config.directive.spec.ts b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-config.directive.spec.ts new file mode 100644 index 0000000000..11ab02c9a3 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-config.directive.spec.ts @@ -0,0 +1,30 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { describe, expect, it } from "vitest"; +import { provideCopilotKit, injectCopilotKitConfig } from "../../config"; + +describe("CopilotKit config", () => { + it("provides configuration via DI", () => { + @Component({ standalone: true, template: "" }) + class HostComponent { + config = injectCopilotKitConfig(); + } + + const headers = { Authorization: "token" }; + + TestBed.configureTestingModule({ + providers: [ + provideCopilotKit({ + runtimeUrl: "https://example.com", + headers, + }), + ], + }); + + const fixture = TestBed.createComponent(HostComponent); + expect(fixture.componentInstance.config.runtimeUrl).toBe( + "https://example.com" + ); + expect(fixture.componentInstance.config.headers).toBe(headers); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool-simple.directive.spec.ts.disabled b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool-simple.directive.spec.ts.disabled new file mode 100644 index 0000000000..39bd89e600 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool-simple.directive.spec.ts.disabled @@ -0,0 +1,68 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { CopilotKitFrontendTool } from "../copilotkit-frontend-tool"; +import { CopilotKit } from "../../core/copilotkit"; +import { provideCopilotKit } from "../../core/copilotkit.providers"; +import { z } from "zod"; + +// Mock CopilotKitCore +vi.mock("@copilotkitnext/core", () => ({ + CopilotKitCore: vi.fn().mockImplementation(() => { + const runtimeUrlSetter = vi.fn(); + return { + addTool: vi.fn(), + removeTool: vi.fn(), + setHeaders: vi.fn(), + setProperties: vi.fn(), + setAgents: vi.fn(), + subscribe: vi.fn(() => () => {}), + setRuntimeUrl: vi.fn((url: string | undefined) => { + runtimeUrlSetter(url); + }), + get runtimeUrl() { + return undefined; + }, + __runtimeUrlSetter: runtimeUrlSetter, + }; + }), +})); + +describe("CopilotKitFrontendTool - Simple", () => { + let service: CopilotKit; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideCopilotKit({})], + }); + service = TestBed.inject(CopilotKit); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it.skip("should create directive instance", () => { + // Cannot test direct instantiation with inject() + expect(true).toBe(true); + }); + + it.skip("should have required inputs", () => { + // Cannot test direct instantiation with inject() + expect(true).toBe(true); + }); + + it.skip("should register tool on init", () => { + // Cannot test direct instantiation with inject() + expect(true).toBe(true); + }); + + it.skip("should unregister tool on destroy", () => { + // Cannot test direct instantiation with inject() + expect(true).toBe(true); + }); + + it.skip("should warn if name is missing", () => { + // Cannot test direct instantiation with inject() + expect(true).toBe(true); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool.directive.spec.ts b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool.directive.spec.ts new file mode 100644 index 0000000000..c2ff06f0b4 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-frontend-tool.directive.spec.ts @@ -0,0 +1,108 @@ +import { Component, signal, Type } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + registerRenderToolCall, + registerFrontendTool, + registerHumanInTheLoop, +} from "../../tools"; +import { CopilotKit } from "../../copilotkit"; +import { z } from "zod"; + +class CopilotKitStub { + addRenderToolCall = vi.fn(); + addFrontendTool = vi.fn(); + removeTool = vi.fn(); + addHumanInTheLoop = vi.fn(); +} + +@Component({ standalone: true, template: "", selector: "dummy-tool" }) +class DummyToolComponent {} + +describe("tool registration helpers", () => { + let copilotKitStub: CopilotKitStub; + + beforeEach(() => { + TestBed.resetTestingModule(); + copilotKitStub = new CopilotKitStub(); + TestBed.configureTestingModule({ + providers: [{ provide: CopilotKit, useValue: copilotKitStub }], + }); + }); + + it("registers and cleans up renderers", () => { + @Component({ standalone: true, template: "" }) + class HostComponent { + constructor() { + registerRenderToolCall({ + name: "tool", + args: z.object({ value: z.string() }), + component: DummyToolComponent as Type, + }); + } + } + + const fixture = TestBed.createComponent(HostComponent); + expect(copilotKitStub.addRenderToolCall).toHaveBeenCalledWith( + expect.objectContaining({ name: "tool" }) + ); + + fixture.destroy(); + expect(copilotKitStub.removeTool).toHaveBeenCalledWith("tool", undefined); + }); + + it("registers client tools and removes them on destroy", async () => { + const handler = vi.fn(async () => "handled"); + + @Component({ standalone: true, template: "" }) + class HostComponent { + constructor() { + registerFrontendTool({ + name: "client-tool", + description: "", + args: z.object({}), + component: DummyToolComponent as Type, + handler, + }); + } + } + + const fixture = TestBed.createComponent(HostComponent); + expect(copilotKitStub.addFrontendTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "client-tool" }) + ); + + const added = copilotKitStub.addFrontendTool.mock.calls.at(-1)![0]; + await added.handler({}); + expect(handler).toHaveBeenCalled(); + + fixture.destroy(); + expect(copilotKitStub.removeTool).toHaveBeenCalledWith("client-tool"); + }); + + it("registers human-in-the-loop tools and removes them on destroy", () => { + @Component({ standalone: true, template: "" }) + class HostComponent { + constructor() { + registerHumanInTheLoop({ + name: "approval", + args: z.object({}), + component: DummyToolComponent as Type, + toolCall: signal({ + args: {}, + status: "in-progress", + result: undefined, + }), + }); + } + } + + const fixture = TestBed.createComponent(HostComponent); + expect(copilotKitStub.addHumanInTheLoop).toHaveBeenCalledWith( + expect.objectContaining({ name: "approval" }) + ); + + fixture.destroy(); + expect(copilotKitStub.removeTool).toHaveBeenCalledWith("approval"); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-human-in-the-loop.directive.spec.ts b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-human-in-the-loop.directive.spec.ts new file mode 100644 index 0000000000..801be7fe1b --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/__tests__/copilotkit-human-in-the-loop.directive.spec.ts @@ -0,0 +1,42 @@ +import { TestBed } from "@angular/core/testing"; +import { describe, expect, it } from "vitest"; +import { HumanInTheLoop } from "../../human-in-the-loop"; + +describe("HumanInTheLoop service", () => { + it("resolves when matching result is provided", async () => { + TestBed.configureTestingModule({ providers: [HumanInTheLoop] }); + const service = TestBed.inject(HumanInTheLoop); + + const promise = service.onResult("call-1", "approval"); + service.addResult("call-1", "approval", { status: "ok" }); + + await expect(promise).resolves.toEqual({ + toolCallId: "call-1", + toolName: "approval", + result: { status: "ok" }, + }); + }); + + it("ignores non-matching results until criteria matches", async () => { + TestBed.configureTestingModule({ providers: [HumanInTheLoop] }); + const service = TestBed.inject(HumanInTheLoop); + + const promise = service.onResult("call-2", "verify"); + + service.addResult("call-2", "other", "nope"); + + const race = Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve("pending"), 20)), + ]); + + await expect(race).resolves.toBe("pending"); + + service.addResult("call-2", "verify", "ok"); + await expect(promise).resolves.toEqual({ + toolCallId: "call-2", + toolName: "verify", + result: "ok", + }); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/directives/copilotkit-agent-context.ts b/src/v2.x/packages/angular/src/lib/directives/copilotkit-agent-context.ts new file mode 100644 index 0000000000..7466d16d31 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/copilotkit-agent-context.ts @@ -0,0 +1,134 @@ +import { + Directive, + Input, + OnInit, + OnChanges, + OnDestroy, + SimpleChanges, + Inject, +} from "@angular/core"; +import { CopilotKit } from "../copilotkit"; +import type { Context } from "@ag-ui/client"; + +/** + * Directive to manage agent context in CopilotKit. + * Automatically adds context on init, updates on changes, and removes on destroy. + * + * @example + * ```html + * + *
+ *
+ * + * + *
+ *
+ * + * + *
+ *
+ * ``` + */ +@Directive({ + selector: "[copilotkitAgentContext]", + standalone: true, +}) +export class CopilotKitAgentContext implements OnInit, OnChanges, OnDestroy { + private contextId?: string; + + constructor(@Inject(CopilotKit) private readonly copilotkit: CopilotKit) {} + + /** + * Context object containing both description and value. + * If provided, this takes precedence over individual inputs. + */ + @Input("copilotkitAgentContext") context?: Context; + + /** + * Description of the context. + * Used when context object is not provided. + */ + @Input() description?: string; + + /** + * Value of the context. + * Used when context object is not provided. + */ + @Input() value?: any; + + ngOnInit(): void { + this.addContext(); + } + + ngOnChanges(changes: SimpleChanges): void { + // Check if any relevant input has changed + const hasContextChange = "context" in changes; + const hasDescriptionChange = "description" in changes; + const hasValueChange = "value" in changes; + + if (hasContextChange || hasDescriptionChange || hasValueChange) { + // Skip the first change as ngOnInit handles initial setup + if (this.contextId) { + this.updateContext(); + } + } + } + + ngOnDestroy(): void { + this.removeContext(); + } + + /** + * Adds the context to CopilotKit + */ + private addContext(): void { + const contextToAdd = this.getContext(); + + if (contextToAdd) { + this.contextId = this.copilotkit.core.addContext(contextToAdd); + } + } + + /** + * Updates the context by removing the old one and adding a new one + */ + private updateContext(): void { + this.removeContext(); + this.addContext(); + } + + /** + * Removes the current context from CopilotKit + */ + private removeContext(): void { + if (this.contextId) { + this.copilotkit.core.removeContext(this.contextId); + this.contextId = undefined; + } + } + + /** + * Gets the context object from inputs + */ + private getContext(): Context | null { + // If context object is provided, use it + if (this.context) { + return this.context; + } + + // Otherwise, build from individual inputs + // Note: null is a valid value, but undefined means not set + if (this.description !== undefined && this.value !== undefined) { + return { + description: this.description, + value: this.value, + }; + } + + return null; + } +} diff --git a/src/v2.x/packages/angular/src/lib/directives/stick-to-bottom.ts b/src/v2.x/packages/angular/src/lib/directives/stick-to-bottom.ts new file mode 100644 index 0000000000..707c41b874 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/stick-to-bottom.ts @@ -0,0 +1,218 @@ +import { + Directive, + ElementRef, + OnInit, + OnDestroy, + AfterViewInit, + inject, + input, + output, +} from "@angular/core"; +import { ScrollPosition } from "../scroll-position"; +import { ResizeObserverService } from "../resize-observer"; +import { Subject } from "rxjs"; +import { + takeUntil, + debounceTime, + filter, + distinctUntilChanged, +} from "rxjs/operators"; + +export type ScrollBehavior = "smooth" | "instant" | "auto"; + +/** + * Directive for implementing stick-to-bottom scroll behavior + * Similar to the React use-stick-to-bottom library + * + * @example + * ```html + *
+ * + *
+ * ``` + */ +@Directive({ + standalone: true, + selector: "[copilotStickToBottom]", + providers: [ScrollPosition, ResizeObserverService], +}) +export class StickToBottom implements OnInit, AfterViewInit, OnDestroy { + enabled = input(true); + threshold = input(10); + initialBehavior = input("smooth"); + resizeBehavior = input("smooth"); + debounceMs = input(100); + + isAtBottomChange = output(); + scrollToBottomRequested = output(); + + private elementRef = inject(ElementRef); + private scrollService = inject(ScrollPosition); + private resizeService = inject(ResizeObserverService); + + private destroy$ = new Subject(); + private contentElement?: HTMLElement; + private wasAtBottom = true; + private hasInitialized = false; + private userHasScrolled = false; + + ngOnInit(): void { + // Setup will happen in ngAfterViewInit + } + + ngAfterViewInit(): void { + const element = this.elementRef.nativeElement as HTMLElement; + + // Find or create content wrapper + this.contentElement = element.querySelector( + "[data-stick-to-bottom-content]" + ) as HTMLElement; + if (!this.contentElement) { + this.contentElement = element; + } + + this.setupScrollMonitoring(); + this.setupResizeMonitoring(); + this.setupContentMutationObserver(); + + // Initial scroll to bottom if enabled + setTimeout(() => { + this.hasInitialized = true; + if (this.enabled()) { + this.scrollToBottom(this.initialBehavior()); + } + }, 0); + } + + private setupScrollMonitoring(): void { + if (!this.enabled()) return; + + const element = this.elementRef.nativeElement; + + // Monitor scroll position + this.scrollService + .monitorScrollPosition(element, this.threshold()) + .pipe( + takeUntil(this.destroy$), + debounceTime(this.debounceMs()), + distinctUntilChanged((a, b) => a.isAtBottom === b.isAtBottom) + ) + .subscribe((state) => { + const wasAtBottom = this.wasAtBottom; + this.wasAtBottom = state.isAtBottom; + + // Detect user scroll + if (!state.isAtBottom && wasAtBottom && this.hasInitialized) { + this.userHasScrolled = true; + } else if (state.isAtBottom) { + this.userHasScrolled = false; + } + + // Emit change + this.isAtBottomChange.emit(state.isAtBottom); + }); + } + + private setupResizeMonitoring(): void { + if (!this.enabled() || !this.contentElement) return; + + // Monitor content resize + this.resizeService + .observeElement(this.contentElement, 0, 250) + .pipe( + takeUntil(this.destroy$), + filter(() => this.enabled() && !this.userHasScrolled) + ) + .subscribe((state) => { + // Auto-scroll on resize if we were at bottom + if (this.wasAtBottom && !state.isResizing) { + this.scrollToBottom(this.resizeBehavior()); + } + }); + + // Monitor container resize + const element = this.elementRef.nativeElement; + this.resizeService + .observeElement(element, 0, 250) + .pipe( + takeUntil(this.destroy$), + filter( + () => this.enabled() && !this.userHasScrolled && this.wasAtBottom + ) + ) + .subscribe(() => { + // Adjust scroll on container resize + this.scrollToBottom(this.resizeBehavior()); + }); + } + + private setupContentMutationObserver(): void { + if (!this.enabled() || !this.contentElement) return; + + const mutationObserver = new MutationObserver(() => { + if (this.enabled() && this.wasAtBottom && !this.userHasScrolled) { + // Content changed, scroll to bottom if we were there + requestAnimationFrame(() => { + this.scrollToBottom(this.resizeBehavior()); + }); + } + }); + + mutationObserver.observe(this.contentElement, { + childList: true, + subtree: true, + characterData: true, + }); + + // Cleanup on destroy + this.destroy$.subscribe(() => { + mutationObserver.disconnect(); + }); + } + + /** + * Public method to scroll to bottom + * Can be called from parent component + */ + public scrollToBottom(behavior: ScrollBehavior = "smooth"): void { + const element = this.elementRef.nativeElement; + const smooth = behavior === "smooth"; + + this.scrollService.scrollToBottom(element, smooth); + this.userHasScrolled = false; + this.scrollToBottomRequested.emit(); + } + + /** + * Check if currently at bottom + */ + public isAtBottom(): boolean { + return this.scrollService.isAtBottom( + this.elementRef.nativeElement, + this.threshold() + ); + } + + /** + * Get current scroll state + */ + public getScrollState() { + const element = this.elementRef.nativeElement; + return { + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, + isAtBottom: this.isAtBottom(), + }; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/v2.x/packages/angular/src/lib/directives/tooltip.ts b/src/v2.x/packages/angular/src/lib/directives/tooltip.ts new file mode 100644 index 0000000000..3c87401e66 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/directives/tooltip.ts @@ -0,0 +1,307 @@ +import { + Directive, + Input, + ElementRef, + HostListener, + OnDestroy, + inject, + ViewContainerRef, +} from "@angular/core"; +import { + Overlay, + OverlayRef, + OverlayPositionBuilder, + ConnectedPosition, +} from "@angular/cdk/overlay"; +import { ComponentPortal } from "@angular/cdk/portal"; +import { + Component, + ChangeDetectionStrategy, + ChangeDetectorRef, +} from "@angular/core"; + +@Component({ + selector: "copilot-tooltip-content", + standalone: true, + template: ` +
+
+ {{ text }} +
+
+
+ `, + styles: [ + ` + .copilot-tooltip-wrapper { + position: relative; + display: inline-block; + animation: fadeIn 0.15s ease-in-out; + } + + .copilot-tooltip { + background-color: #1a1a1a; + color: white; + padding: 6px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + max-width: 200px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } + + .copilot-tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border-style: solid; + } + + /* Arrow for tooltip below element (arrow points up to tooltip) */ + .copilot-tooltip-wrapper[data-position="below"] .copilot-tooltip-arrow { + top: -4px; + left: 50%; + transform: translateX(-50%); + border-width: 0 4px 4px 4px; + border-color: transparent transparent #1a1a1a transparent; + } + + /* Arrow for tooltip above element (arrow points down to element) */ + .copilot-tooltip-wrapper[data-position="above"] .copilot-tooltip-arrow { + bottom: -4px; + left: 50%; + transform: translateX(-50%); + border-width: 4px 4px 0 4px; + border-color: #1a1a1a transparent transparent transparent; + } + + /* Arrow for tooltip to the left */ + .copilot-tooltip-wrapper[data-position="left"] .copilot-tooltip-arrow { + right: -4px; + top: 50%; + transform: translateY(-50%); + border-width: 4px 0 4px 4px; + border-color: transparent transparent transparent #1a1a1a; + } + + /* Arrow for tooltip to the right */ + .copilot-tooltip-wrapper[data-position="right"] .copilot-tooltip-arrow { + left: -4px; + top: 50%; + transform: translateY(-50%); + border-width: 4px 4px 4px 0; + border-color: transparent #1a1a1a transparent transparent; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TooltipContent { + text = ""; + private _position: "above" | "below" | "left" | "right" = "below"; + + get position(): "above" | "below" | "left" | "right" { + return this._position; + } + + set position(value: "above" | "below" | "left" | "right") { + this._position = value; + this.cdr.markForCheck(); + } + + constructor(private cdr: ChangeDetectorRef) {} +} + +@Directive({ + selector: "[copilotTooltip]", + standalone: true, +}) +export class CopilotTooltip implements OnDestroy { + @Input("copilotTooltip") tooltipText = ""; + @Input() tooltipPosition: "above" | "below" | "left" | "right" = "below"; + @Input() tooltipDelay = 500; // milliseconds + + private overlay = inject(Overlay); + private overlayPositionBuilder = inject(OverlayPositionBuilder); + private elementRef = inject(ElementRef); + private viewContainerRef = inject(ViewContainerRef); + + private overlayRef?: OverlayRef; + private tooltipTimeout?: number; + private originalTitle?: string; + + @HostListener("mouseenter") + onMouseEnter(): void { + if (!this.tooltipText) return; + + // Store and remove native title to prevent OS tooltip + const element = this.elementRef.nativeElement; + if (element.hasAttribute("title")) { + this.originalTitle = element.getAttribute("title"); + element.removeAttribute("title"); + } + + // Clear any existing timeout + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + } + + // Set timeout to show tooltip after delay + this.tooltipTimeout = window.setTimeout(() => { + this.show(); + }, this.tooltipDelay); + } + + @HostListener("mouseleave") + onMouseLeave(): void { + // Clear timeout if mouse leaves before tooltip shows + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + this.tooltipTimeout = undefined; + } + + // Restore original title if it existed + if (this.originalTitle !== undefined) { + this.elementRef.nativeElement.setAttribute("title", this.originalTitle); + this.originalTitle = undefined; + } + + // Hide tooltip if it's showing + this.hide(); + } + + private show(): void { + if (this.overlayRef) { + return; + } + + // Create overlay + const positionStrategy = this.overlayPositionBuilder + .flexibleConnectedTo(this.elementRef) + .withPositions(this.getPositions()) + .withPush(false); + + this.overlayRef = this.overlay.create({ + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.close(), + hasBackdrop: false, + }); + + // Create component portal and attach + const portal = new ComponentPortal(TooltipContent, this.viewContainerRef); + const componentRef = this.overlayRef.attach(portal); + componentRef.instance.text = this.tooltipText; + + // Detect actual position after overlay is positioned + setTimeout(() => { + if (this.overlayRef && this.elementRef.nativeElement) { + const tooltipRect = + this.overlayRef.overlayElement.getBoundingClientRect(); + const elementRect = + this.elementRef.nativeElement.getBoundingClientRect(); + + let actualPosition: "above" | "below" | "left" | "right" = "below"; + + // Determine actual position based on relative positions + if (tooltipRect.bottom <= elementRect.top) { + actualPosition = "above"; + } else if (tooltipRect.top >= elementRect.bottom) { + actualPosition = "below"; + } else if (tooltipRect.right <= elementRect.left) { + actualPosition = "left"; + } else if (tooltipRect.left >= elementRect.right) { + actualPosition = "right"; + } + + componentRef.instance.position = actualPosition; + } + }, 0); + } + + private hide(): void { + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = undefined; + } + } + + private getPositions(): ConnectedPosition[] { + const positions: Record = { + above: [ + { + originX: "center", + originY: "top", + overlayX: "center", + overlayY: "bottom", + offsetY: -12, + }, + ], + below: [ + { + originX: "center", + originY: "bottom", + overlayX: "center", + overlayY: "top", + offsetY: 12, + }, + ], + left: [ + { + originX: "start", + originY: "center", + overlayX: "end", + overlayY: "center", + offsetX: -12, + }, + ], + right: [ + { + originX: "end", + originY: "center", + overlayX: "start", + overlayY: "center", + offsetX: 12, + }, + ], + }; + + // Prefer below position, but use above as fallback + const primary = positions[this.tooltipPosition] || positions.below; + // For below position, add above as first fallback + const fallbacks = + this.tooltipPosition === "below" + ? [ + ...(positions.above || []), + ...(positions.left || []), + ...(positions.right || []), + ] + : Object.values(positions) + .filter((p) => p !== primary) + .flat(); + + return [...(primary || []), ...fallbacks]; + } + + ngOnDestroy(): void { + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + } + // Restore original title if it existed + if (this.originalTitle !== undefined) { + this.elementRef.nativeElement.setAttribute("title", this.originalTitle); + } + this.hide(); + } +} diff --git a/src/v2.x/packages/angular/src/lib/human-in-the-loop.ts b/src/v2.x/packages/angular/src/lib/human-in-the-loop.ts new file mode 100644 index 0000000000..633241963c --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/human-in-the-loop.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@angular/core"; +import { filter, lastValueFrom, Subject, take } from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class HumanInTheLoop { + results = new Subject<{ + toolCallId: string; + toolName: string; + result: unknown; + }>(); + + addResult(toolCallId: string, toolName: string, result: unknown) { + this.results.next({ toolCallId, toolName, result }); + } + + onResult(toolCallId: string, toolName: string): Promise { + return lastValueFrom( + this.results.pipe( + filter( + (result) => + result.toolCallId === toolCallId && result.toolName === toolName + ), + take(1) + ) + ); + } +} diff --git a/src/v2.x/packages/angular/src/lib/render-tool-calls.ts b/src/v2.x/packages/angular/src/lib/render-tool-calls.ts new file mode 100644 index 0000000000..5b25e1076f --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/render-tool-calls.ts @@ -0,0 +1,157 @@ +import { NgComponentOutlet } from "@angular/common"; +import { Component, inject, input } from "@angular/core"; +import { AssistantMessage, Message, ToolCall, ToolMessage } from "@ag-ui/client"; +import { CopilotKit } from "./copilotkit"; +import { + FrontendToolConfig, + HumanInTheLoopToolCall, + HumanInTheLoopConfig, + AngularToolCall, + RenderToolCallConfig, +} from "./tools"; +import { partialJSONParse } from "@copilotkitnext/shared"; +import { HumanInTheLoop } from "./human-in-the-loop"; + +type RendererToolCallHandler = { + type: "renderer"; + config: RenderToolCallConfig; +}; +type ClientToolCallHandler = { + type: "clientTool"; + config: FrontendToolConfig; +}; +type HumanInTheLoopToolCallHandler = { + type: "humanInTheLoopTool"; + config: HumanInTheLoopConfig; +}; + +type ToolCallHandler = RendererToolCallHandler | ClientToolCallHandler | HumanInTheLoopToolCallHandler; + +@Component({ + selector: "copilot-render-tool-calls", + standalone: true, + imports: [NgComponentOutlet], + template: ` + @for (toolCall of message().toolCalls ?? []; track toolCall.id) { + @let renderConfig = pickRenderer(toolCall.function.name); + @if (renderConfig && renderConfig.type !== "humanInTheLoopTool" && renderConfig.config.component) { + + } + @if (renderConfig && renderConfig.type === "humanInTheLoopTool" && renderConfig.config.component) { + + } + } + `, +}) +export class RenderToolCalls { + readonly #copilotKit = inject(CopilotKit); + readonly #hitl = inject(HumanInTheLoop); + readonly message = input.required(); + readonly messages = input.required(); + readonly isLoading = input(false); + + protected pickRenderer(name: string): ToolCallHandler | undefined { + type AssistantMessageWithAgent = AssistantMessage & { + agentId?: string; + }; + const messageAgentId = (this.message() as AssistantMessageWithAgent).agentId; + const renderers = this.#copilotKit.toolCallRenderConfigs(); + const clientTools = this.#copilotKit.clientToolCallRenderConfigs(); + const humanInTheLoopTools = this.#copilotKit.humanInTheLoopToolRenderConfigs(); + + const renderer = renderers.find( + (candidate) => + candidate.name === name && (candidate.agentId === undefined || candidate.agentId === messageAgentId), + ); + + if (renderer) return { type: "renderer", config: renderer }; + + const clientTool = clientTools.find( + (candidate) => + candidate.name === name && (candidate.agentId === undefined || candidate.agentId === messageAgentId), + ); + if (clientTool) return { type: "clientTool", config: clientTool }; + + const humanInTheLoopTool = humanInTheLoopTools.find( + (candidate) => + candidate.name === name && (candidate.agentId === undefined || candidate.agentId === messageAgentId), + ); + if (humanInTheLoopTool) return { type: "humanInTheLoopTool", config: humanInTheLoopTool }; + + const starRenderer = renderers.find((candidate) => candidate.name === "*"); + if (starRenderer) return { type: "renderer", config: starRenderer }; + + return undefined; + } + + protected buildToolCall>(toolCall: ToolCall): AngularToolCall { + const args = partialJSONParse(toolCall.function.arguments); + const message = this.#getToolMessage(toolCall.id); + + if (message) { + return { + args, + status: "complete", + result: message.content, + }; + } else if (this.isLoading()) { + return { + args, + status: "in-progress", + result: undefined, + }; + } else { + return { + args, + status: "executing", + result: undefined, + }; + } + } + + protected buildHumanInTheLoopToolCall>( + toolCall: ToolCall, + ): HumanInTheLoopToolCall { + const args = partialJSONParse(toolCall.function.arguments); + const message = this.#getToolMessage(toolCall.id); + const respond = (result: unknown) => { + this.#hitl.addResult(toolCall.id, toolCall.function.name, result); + }; + + if (message) { + return { + args, + status: "complete", + result: message.content!, + respond, + }; + } else if (this.isLoading()) { + return { + args, + status: "in-progress", + result: undefined, + respond, + }; + } else { + return { + args, + status: "executing", + result: undefined, + respond, + }; + } + } + + #getToolMessage(toolCallId: string): ToolMessage | undefined { + const message = this.messages().find((m): m is ToolMessage => m.role === "tool" && m.toolCallId === toolCallId); + + return message; + } +} diff --git a/src/v2.x/packages/angular/src/lib/resize-observer.ts b/src/v2.x/packages/angular/src/lib/resize-observer.ts new file mode 100644 index 0000000000..6b70d2f9f4 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/resize-observer.ts @@ -0,0 +1,187 @@ +import { Injectable, ElementRef, NgZone, OnDestroy } from "@angular/core"; +import { Observable, Subject, BehaviorSubject } from "rxjs"; +import { debounceTime, takeUntil, distinctUntilChanged } from "rxjs/operators"; + +export interface ResizeState { + width: number; + height: number; + isResizing: boolean; +} + +@Injectable({ + providedIn: "root", +}) +export class ResizeObserverService implements OnDestroy { + private destroy$ = new Subject(); + private observers = new Map(); + private resizeStates = new Map>(); + private resizeTimeouts = new Map(); + + constructor(private ngZone: NgZone) {} + + /** + * Observe element resize with debouncing and resizing state + * @param element Element to observe + * @param debounceMs Debounce time (default 250ms) + * @param resizingDurationMs How long to show "isResizing" state (default 250ms) + */ + observeElement( + element: ElementRef | HTMLElement, + debounceMs: number = 0, + resizingDurationMs: number = 250 + ): Observable { + const el = element instanceof ElementRef ? element.nativeElement : element; + + // Return existing observer if already observing + if (this.resizeStates.has(el)) { + return this.resizeStates.get(el)!.asObservable(); + } + + // Create new subject for this element + const resizeState$ = new BehaviorSubject({ + width: el.offsetWidth, + height: el.offsetHeight, + isResizing: false, + }); + + this.resizeStates.set(el, resizeState$); + + // Create ResizeObserver + const resizeObserver = new ResizeObserver((entries) => { + if (entries.length === 0) return; + + const entry = entries[0]; + if (!entry) return; + + const { width, height } = entry.contentRect; + + this.ngZone.run(() => { + // Clear existing timeout + const existingTimeout = this.resizeTimeouts.get(el); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + // Update state with isResizing = true + resizeState$.next({ + width, + height, + isResizing: true, + }); + + // Set timeout to clear isResizing flag + if (resizingDurationMs > 0) { + const timeout = window.setTimeout(() => { + resizeState$.next({ + width, + height, + isResizing: false, + }); + this.resizeTimeouts.delete(el); + }, resizingDurationMs); + + this.resizeTimeouts.set(el, timeout); + } else { + // If no duration, immediately set isResizing to false + resizeState$.next({ + width, + height, + isResizing: false, + }); + } + }); + }); + + // Start observing + resizeObserver.observe(el); + this.observers.set(el, resizeObserver); + + // Return observable with debouncing if specified + const observable = resizeState$.asObservable().pipe( + debounceMs > 0 ? debounceTime(debounceMs) : (source) => source, + distinctUntilChanged( + (a, b) => + a.width === b.width && + a.height === b.height && + a.isResizing === b.isResizing + ), + takeUntil(this.destroy$) + ); + + return observable; + } + + /** + * Stop observing an element + * @param element Element to stop observing + */ + unobserve(element: ElementRef | HTMLElement): void { + const el = element instanceof ElementRef ? element.nativeElement : element; + + // Clear timeout if exists + const timeout = this.resizeTimeouts.get(el); + if (timeout) { + clearTimeout(timeout); + this.resizeTimeouts.delete(el); + } + + // Disconnect observer + const observer = this.observers.get(el); + if (observer) { + observer.disconnect(); + this.observers.delete(el); + } + + // Complete and remove subject + const subject = this.resizeStates.get(el); + if (subject) { + subject.complete(); + this.resizeStates.delete(el); + } + } + + /** + * Get current size of element + * @param element Element to measure + */ + getCurrentSize(element: ElementRef | HTMLElement): { + width: number; + height: number; + } { + const el = element instanceof ElementRef ? element.nativeElement : element; + return { + width: el.offsetWidth, + height: el.offsetHeight, + }; + } + + /** + * Get current resize state of element + * @param element Element to check + */ + getCurrentState( + element: ElementRef | HTMLElement + ): ResizeState | null { + const el = element instanceof ElementRef ? element.nativeElement : element; + const subject = this.resizeStates.get(el); + return subject ? subject.value : null; + } + + ngOnDestroy(): void { + // Clear all timeouts + this.resizeTimeouts.forEach((timeout) => clearTimeout(timeout)); + this.resizeTimeouts.clear(); + + // Disconnect all observers + this.observers.forEach((observer) => observer.disconnect()); + this.observers.clear(); + + // Complete all subjects + this.resizeStates.forEach((subject) => subject.complete()); + this.resizeStates.clear(); + + // Complete destroy subject + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/v2.x/packages/angular/src/lib/scroll-position.ts b/src/v2.x/packages/angular/src/lib/scroll-position.ts new file mode 100644 index 0000000000..83a928a1c9 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/scroll-position.ts @@ -0,0 +1,173 @@ +import { Injectable, ElementRef, NgZone, OnDestroy } from "@angular/core"; +import { ScrollDispatcher, ViewportRuler } from "@angular/cdk/scrolling"; +import { + Observable, + Subject, + BehaviorSubject, + fromEvent, + merge, + animationFrameScheduler, +} from "rxjs"; +import { + takeUntil, + debounceTime, + throttleTime, + distinctUntilChanged, + map, + startWith, +} from "rxjs/operators"; + +export interface ScrollState { + isAtBottom: boolean; + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} + +@Injectable({ + providedIn: "root", +}) +export class ScrollPosition implements OnDestroy { + private destroy$ = new Subject(); + private scrollStateSubject = new BehaviorSubject({ + isAtBottom: true, + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + }); + + public scrollState$ = this.scrollStateSubject.asObservable(); + + constructor( + private scrollDispatcher: ScrollDispatcher, + private viewportRuler: ViewportRuler, + private ngZone: NgZone + ) {} + + /** + * Monitor scroll position of an element + * @param element The element to monitor + * @param threshold Pixels from bottom to consider "at bottom" (default 10) + */ + monitorScrollPosition( + element: ElementRef | HTMLElement, + threshold: number = 10 + ): Observable { + const el = element instanceof ElementRef ? element.nativeElement : element; + + // Create scroll observable + const scroll$ = merge( + fromEvent(el, "scroll"), + this.viewportRuler.change(150) // Monitor viewport changes + ).pipe( + startWith(null), // Emit initial state + throttleTime(16, animationFrameScheduler, { trailing: true }), // ~60fps + map(() => this.getScrollState(el, threshold)), + distinctUntilChanged( + (a, b) => + a.isAtBottom === b.isAtBottom && + a.scrollTop === b.scrollTop && + a.scrollHeight === b.scrollHeight + ), + takeUntil(this.destroy$) + ); + + // Subscribe and update subject + scroll$.subscribe((state) => { + this.scrollStateSubject.next(state); + }); + + return scroll$; + } + + /** + * Scroll element to bottom with smooth animation + * @param element The element to scroll + * @param smooth Whether to use smooth scrolling + */ + scrollToBottom( + element: ElementRef | HTMLElement, + smooth: boolean = true + ): void { + const el = element instanceof ElementRef ? element.nativeElement : element; + + this.ngZone.runOutsideAngular(() => { + if (smooth && "scrollBehavior" in document.documentElement.style) { + el.scrollTo({ + top: el.scrollHeight, + behavior: "smooth", + }); + } else { + el.scrollTop = el.scrollHeight; + } + }); + } + + /** + * Check if element is at bottom + * @param element The element to check + * @param threshold Pixels from bottom to consider "at bottom" + */ + isAtBottom( + element: ElementRef | HTMLElement, + threshold: number = 10 + ): boolean { + const el = element instanceof ElementRef ? element.nativeElement : element; + return this.getScrollState(el, threshold).isAtBottom; + } + + /** + * Get current scroll state of element + */ + public getScrollState(element: HTMLElement, threshold: number): ScrollState { + const scrollTop = element.scrollTop; + const scrollHeight = element.scrollHeight; + const clientHeight = element.clientHeight; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const isAtBottom = distanceFromBottom <= threshold; + + return { + isAtBottom, + scrollTop, + scrollHeight, + clientHeight, + }; + } + + /** + * Create a ResizeObserver for element size changes + * @param element The element to observe + * @param debounceMs Debounce time in milliseconds + */ + observeResize( + element: ElementRef | HTMLElement, + debounceMs: number = 250 + ): Observable { + const el = element instanceof ElementRef ? element.nativeElement : element; + const resize$ = new Subject(); + + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + this.ngZone.run(() => { + resize$.next(entry); + }); + } + }); + + resizeObserver.observe(el); + + // Cleanup on destroy + this.destroy$.subscribe(() => { + resizeObserver.disconnect(); + }); + + return resize$.pipe(debounceTime(debounceMs), takeUntil(this.destroy$)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.scrollStateSubject.complete(); + } +} diff --git a/src/v2.x/packages/angular/src/lib/slots/__tests__/slot.utils.spec.ts b/src/v2.x/packages/angular/src/lib/slots/__tests__/slot.utils.spec.ts new file mode 100644 index 0000000000..b8bbac6300 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/slots/__tests__/slot.utils.spec.ts @@ -0,0 +1,217 @@ +import { + Component, + Input, + TemplateRef, + ViewChild, + ViewContainerRef, + createEnvironmentInjector, + EnvironmentInjector, + runInInjectionContext, +} from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + renderSlot, + isComponentType, + isSlotValue, + normalizeSlotValue, + createSlotConfig, + provideSlots, + getSlotConfig, + createSlotRenderer, +} from "../slot.utils"; +import { SLOT_CONFIG } from "../slot.types"; + +@Component({ + standalone: true, + selector: "default-component", + template: `
{{ text }}
`, +}) +class DefaultComponent { + @Input() text = "Default"; +} + +@Component({ + standalone: true, + selector: "custom-component", + template: `
{{ text }}
`, +}) +class CustomComponent { + @Input() text = "Custom"; +} + +describe("slot utils", () => { + beforeEach(() => { + TestBed.resetTestingModule(); + }); + + describe("renderSlot", () => { + it("renders default component when no slot provided", () => { + @Component({ + standalone: true, + template: `
`, + imports: [DefaultComponent], + }) + class HostComponent { + @ViewChild("container", { read: ViewContainerRef }) + container!: ViewContainerRef; + } + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const ref = renderSlot(fixture.componentInstance.container, { + defaultComponent: DefaultComponent, + }); + + expect(ref).toBeTruthy(); + expect( + (ref as any).location.nativeElement.querySelector(".default") + ).toBeTruthy(); + }); + + it("renders template slot with provided context", () => { + @Component({ + standalone: true, + template: ` +
+ + {{ props?.value }} + + `, + }) + class HostComponent { + @ViewChild("container", { read: ViewContainerRef }) + container!: ViewContainerRef; + @ViewChild("tpl") tpl!: TemplateRef; + } + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + renderSlot(fixture.componentInstance.container, { + defaultComponent: DefaultComponent, + slot: fixture.componentInstance.tpl, + props: { value: "from template" }, + }); + fixture.detectChanges(); + + const span = fixture.nativeElement.querySelector(".template"); + expect(span?.textContent?.trim()).toBe("from template"); + }); + + it("applies inputs using setInput", () => { + @Component({ + standalone: true, + template: `
`, + imports: [DefaultComponent], + }) + class HostComponent { + @ViewChild("container", { read: ViewContainerRef }) + container!: ViewContainerRef; + } + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const ref = renderSlot(fixture.componentInstance.container, { + defaultComponent: DefaultComponent, + props: { text: "Updated" }, + }); + + expect(ref).toBeTruthy(); + expect((ref as any).instance.text).toBe("Updated"); + }); + }); + + describe("type guards", () => { + it("detects component types", () => { + expect(isComponentType(DefaultComponent)).toBe(true); + expect(isComponentType(() => {})).toBe(false); + expect(isComponentType(null)).toBe(false); + }); + + it("detects slot values", () => { + @Component({ + standalone: true, + template: ``, + }) + class HostComponent { + @ViewChild("tpl") tpl!: TemplateRef; + } + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + expect(isSlotValue(DefaultComponent)).toBe(true); + expect(isSlotValue(fixture.componentInstance.tpl)).toBe(true); + expect(isSlotValue("string")).toBe(false); + }); + }); + + describe("configuration helpers", () => { + it("normalises slot overrides to registry entries", () => { + expect(normalizeSlotValue(undefined, DefaultComponent)).toEqual({ + component: DefaultComponent, + }); + expect(normalizeSlotValue(CustomComponent, DefaultComponent)).toEqual({ + component: CustomComponent, + }); + }); + + it("creates slot configuration map with defaults", () => { + const config = createSlotConfig( + { button: CustomComponent }, + { button: DefaultComponent, toolbar: DefaultComponent } + ); + + expect(config.get("button")).toEqual({ component: CustomComponent }); + expect(config.get("toolbar")).toEqual({ component: DefaultComponent }); + }); + + it("provides and retrieves slot configuration via DI", () => { + const slots = new Map([["button", { component: CustomComponent }]]); + TestBed.configureTestingModule({ + providers: [{ provide: SLOT_CONFIG, useValue: slots }], + }); + + @Component({ standalone: true, template: "" }) + class HostComponent { + config = getSlotConfig(); + } + + const fixture = TestBed.createComponent(HostComponent); + expect(fixture.componentInstance.config).toBe(slots); + }); + + it("createSlotRenderer uses DI overrides when slot name provided", () => { + const parent = TestBed.inject(EnvironmentInjector); + const env = createEnvironmentInjector( + [provideSlots({ button: CustomComponent })], + parent + ); + + const renderer = runInInjectionContext(env, () => + createSlotRenderer(DefaultComponent, "button") + ); + + @Component({ + standalone: true, + template: `
`, + imports: [DefaultComponent, CustomComponent], + }) + class HostComponent { + @ViewChild("container", { read: ViewContainerRef }) + container!: ViewContainerRef; + } + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const ref = renderer(fixture.componentInstance.container); + expect( + (ref as any).location.nativeElement.querySelector(".custom") + ).toBeTruthy(); + }); + }); +}); diff --git a/src/v2.x/packages/angular/src/lib/slots/copilot-slot.ts b/src/v2.x/packages/angular/src/lib/slots/copilot-slot.ts new file mode 100644 index 0000000000..37caedb107 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/slots/copilot-slot.ts @@ -0,0 +1,150 @@ +import { + Component, + TemplateRef, + ViewContainerRef, + OnInit, + OnChanges, + SimpleChanges, + Inject, + ChangeDetectionStrategy, + ChangeDetectorRef, + input, + ViewChild, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { renderSlot } from "./slot.utils"; +import { Type } from "@angular/core"; + +/** + * @internal - This component is for internal use only. + * Simple slot component for rendering custom content or defaults. + * Supports templates and components only. + * + * @example + * ```html + * + * + * + * + * ``` + */ +@Component({ + standalone: true, + selector: "copilot-slot", + imports: [CommonModule], + template: ` + + @if (slot() && isTemplate(slot)) { + + + } + + + + + + @if (!slot && !defaultComponent) { + + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CopilotSlot implements OnInit, OnChanges { + slot = input | Type | undefined>(undefined); + context = input(undefined); + defaultComponent = input | undefined>(undefined); + outputs = input void> | undefined>(undefined); + + @ViewChild("slotContainer", { read: ViewContainerRef, static: true }) + private slotContainer!: ViewContainerRef; + + private componentRef?: any; + + constructor( + @Inject(ViewContainerRef) private viewContainer: ViewContainerRef, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.renderSlot(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes["slot"]) { + // Slot changed, need to re-render completely + this.renderSlot(); + } else if (changes["context"] && this.componentRef) { + // Just context changed, update existing component + this.updateComponentProps(); + this.cdr.detectChanges(); + } else if (changes["context"]) { + // No component ref yet, render the slot + this.renderSlot(); + } + } + + isTemplate(value: any): value is TemplateRef { + return value instanceof TemplateRef; + } + + private renderSlot(): void { + // Skip if it's a template (handled by ngTemplateOutlet) + if (this.slot() && this.isTemplate(this.slot())) { + this.componentRef = null; + return; + } + + // Clear previous content + this.slotContainer.clear(); + this.componentRef = null; + + // Skip if no slot and no default component + if (!this.slot() && !this.defaultComponent()) { + return; + } + + // Use the utility to render other slot types + if (this.slot() || this.defaultComponent()) { + this.componentRef = renderSlot(this.slotContainer, { + slot: this.slot(), + defaultComponent: this.defaultComponent()!, + props: this.context(), + outputs: this.outputs(), + }); + } + } + + private updateComponentProps(): void { + if (!this.componentRef || !this.componentRef.instance) { + return; + } + + const props = this.context(); + + // Update props using setInput, only for declared inputs + if (props) { + const ctor = this.componentRef.instance.constructor as any; + const cmpDef: any = ctor?.ɵcmp; + const declaredInputs = new Set(Object.keys(cmpDef?.inputs ?? {})); + + if (declaredInputs.has("props")) { + this.componentRef.setInput("props", props); + } else { + for (const key in props) { + if (declaredInputs.has(key)) { + const value = props[key]; + this.componentRef.setInput(key, value); + } + } + } + } + + // Trigger change detection + if (this.componentRef.changeDetectorRef) { + this.componentRef.changeDetectorRef.detectChanges(); + } + } +} diff --git a/src/v2.x/packages/angular/src/lib/slots/index.ts b/src/v2.x/packages/angular/src/lib/slots/index.ts new file mode 100644 index 0000000000..468c3aca7a --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/slots/index.ts @@ -0,0 +1,3 @@ +export * from "./slot.types"; +export * from "./slot.utils"; +export { CopilotSlot } from "./copilot-slot"; diff --git a/src/v2.x/packages/angular/src/lib/slots/slot.types.ts b/src/v2.x/packages/angular/src/lib/slots/slot.types.ts new file mode 100644 index 0000000000..6883f57296 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/slots/slot.types.ts @@ -0,0 +1,64 @@ +import { Type, TemplateRef, InjectionToken } from "@angular/core"; + +/** + * Represents a value that can be used as a slot override. + * Can be a component type or template reference only. + * @internal - This type is for internal use only + */ +export type SlotValue = Type | TemplateRef; + +/** + * Configuration for a slot + * @internal - This interface is for internal use only + */ +export interface SlotConfig { + value?: SlotValue; + default?: Type; +} + +/** + * Context passed to slot templates + */ +export interface SlotContext { + $implicit: T; + props?: Partial; + [key: string]: any; +} + +/** + * Slot registry entry + * @internal - This interface is for internal use only + */ +export interface SlotRegistryEntry { + component?: Type; + template?: TemplateRef; +} + +/** + * Options for rendering a slot + */ +export interface RenderSlotOptions { + slot?: SlotValue; + defaultComponent: Type; + props?: Partial; + injector?: any; + outputs?: Record void>; +} + +/** + * Injection token for slot configuration + */ +export const SLOT_CONFIG = new InjectionToken< + ReadonlyMap +>("SLOT_CONFIG"); + +/** + * Type for components with slots + */ +export type WithSlots>, Rest = object> = { + [K in keyof S as `${string & K}Component`]?: Type; +} & { + [K in keyof S as `${string & K}Template`]?: TemplateRef; +} & { + [K in keyof S as `${string & K}Class`]?: string; +} & Rest; diff --git a/src/v2.x/packages/angular/src/lib/slots/slot.utils.ts b/src/v2.x/packages/angular/src/lib/slots/slot.utils.ts new file mode 100644 index 0000000000..46a77dce4e --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/slots/slot.utils.ts @@ -0,0 +1,306 @@ +import { + Type, + TemplateRef, + ViewContainerRef, + ComponentRef, + EmbeddedViewRef, + Injector, + inject, +} from "@angular/core"; +import { + SlotValue, + RenderSlotOptions, + SlotRegistryEntry, + SLOT_CONFIG, +} from "./slot.types"; + +/** + * Renders a slot value into a ViewContainerRef. + * This is the core utility for slot rendering. + * + * @param viewContainer - The ViewContainerRef to render into + * @param options - Options for rendering the slot + * @returns The created component or embedded view reference + * + * @example + * ```typescript + * export class MyComponent { + * @ViewChild('container', { read: ViewContainerRef }) container!: ViewContainerRef; + * + * renderButton() { + * renderSlot(this.container, { + * slot: this.buttonOverride, + * defaultComponent: DefaultButton, + * props: { text: 'Click me' }, + * outputs: { click: (event) => this.handleClick(event) } + * }); + * } + * } + * ``` + */ +export function renderSlot( + viewContainer: ViewContainerRef, + options: RenderSlotOptions +): ComponentRef | EmbeddedViewRef | null { + const { slot, defaultComponent, props, injector, outputs } = options; + + viewContainer.clear(); + + const effectiveSlot = slot ?? defaultComponent; + const effectiveInjector = injector ?? viewContainer.injector; + + if (effectiveSlot instanceof TemplateRef) { + // TemplateRef: render template + return viewContainer.createEmbeddedView(effectiveSlot, { + $implicit: props ?? {}, + props: props ?? {}, + } as any); + } else if (isComponentType(effectiveSlot)) { + // Component type - wrap in try/catch for safety + try { + return createComponent( + viewContainer, + effectiveSlot as Type, + props, + effectiveInjector, + outputs + ); + } catch (error) { + console.warn("Failed to create component:", effectiveSlot, error); + // Fall through to default component + } + } + + // Default: render default component if provided + return defaultComponent + ? createComponent( + viewContainer, + defaultComponent, + props, + effectiveInjector, + outputs + ) + : null; +} + +/** + * Creates a component and applies properties. + */ +function createComponent( + viewContainer: ViewContainerRef, + component: Type, + props?: Partial, + injector?: Injector, + outputs?: Record void> +): ComponentRef { + const componentRef = viewContainer.createComponent(component, { + injector, + }); + + if (props) { + // Apply props using setInput, but only for declared inputs + const cmpDef: any = (component as any).ɵcmp; + const declaredInputs = new Set(Object.keys(cmpDef?.inputs ?? {})); + + if (declaredInputs.has("props")) { + componentRef.setInput("props", props as any); + } else { + for (const key in props) { + if (declaredInputs.has(key)) { + const value = (props as any)[key]; + componentRef.setInput(key, value); + } + } + } + } + + if (outputs) { + // Wire up output event handlers with proper cleanup + const instance = componentRef.instance as any; + const subscriptions: any[] = []; + + for (const [eventName, handler] of Object.entries(outputs)) { + if (instance[eventName]?.subscribe) { + const subscription = instance[eventName].subscribe(handler); + subscriptions.push(subscription); + } + } + + // Register cleanup on component destroy + componentRef.onDestroy(() => { + subscriptions.forEach((sub) => sub.unsubscribe()); + }); + } + + // Trigger change detection + componentRef.changeDetectorRef.detectChanges(); + + return componentRef; +} + +/** + * Checks if a value is a component type. + * Simplified check - rely on try/catch for actual validation. + */ +export function isComponentType(value: any): boolean { + // Arrow functions and regular functions without a prototype are not components + return typeof value === "function" && !!value.prototype; +} + +/** + * Checks if a value is a valid slot value. + */ +export function isSlotValue(value: any): value is SlotValue { + return value instanceof TemplateRef || isComponentType(value); +} + +/** + * Normalizes a slot value to a consistent format. + */ +export function normalizeSlotValue( + value: SlotValue | undefined, + defaultComponent: Type | undefined +): SlotRegistryEntry { + if (!value) { + return { component: defaultComponent }; + } + + if (value instanceof TemplateRef) { + return { template: value }; + } + + if (isComponentType(value)) { + return { component: value as Type }; + } + + return { component: defaultComponent }; +} + +/** + * Creates a slot configuration map for a component. + * + * @example + * ```typescript + * const slots = createSlotConfig({ + standalone: true, +* sendButton: CustomSendButton, + * toolbar: 'custom-toolbar-class', + * footer: footerTemplate + * }, { + * sendButton: DefaultSendButton, + * toolbar: DefaultToolbar, + * footer: DefaultFooter + * }); + * ``` + */ +export function createSlotConfig>>( + overrides: Partial>, + defaults: T +): Map { + const config = new Map(); + + for (const key in defaults) { + const override = overrides[key]; + const defaultComponent = defaults[key]; + config.set(key, normalizeSlotValue(override, defaultComponent)); + } + + return config; +} + +/** + * Provides slot configuration to child components via DI. + * + * @example + * ```typescript + * @Component({ + standalone: true, +* providers: [ + * provideSlots({ + * sendButton: CustomSendButton, + * toolbar: CustomToolbar + * }) + * ] + * }) + * ``` + */ +export function provideSlots(slots: Record>) { + const slotMap = new Map(); + + // Only accept component types in DI (templates lack view context) + for (const [key, value] of Object.entries(slots)) { + if (isComponentType(value)) { + slotMap.set(key, { component: value as Type }); + } + } + + return { + provide: SLOT_CONFIG, + useValue: slotMap, + }; +} + +/** + * Gets slot configuration from DI. + * Must be called within an injection context. + * + * @example + * ```typescript + * export class MyComponent { + * slots = getSlotConfig(); + * + * ngOnInit() { + * const sendButton = this.slots?.get('sendButton'); + * } + * } + * ``` + */ +export function getSlotConfig(): ReadonlyMap | null { + return inject(SLOT_CONFIG, { optional: true }); +} + +/** + * Creates a render function for a specific slot. + * Useful for creating reusable slot renderers. + * + * @example + * ```typescript + * const renderSendButton = createSlotRenderer( + * DefaultSendButton, + * 'sendButton' + * ); + * + * // Later in template + * renderSendButton(this.viewContainer, this.sendButtonOverride); + * ``` + */ +export function createSlotRenderer( + defaultComponent: Type, + slotName?: string +) { + // Get config in the injection context when the renderer is created + const config = slotName ? getSlotConfig() : null; + + return ( + viewContainer: ViewContainerRef, + slot?: SlotValue, + props?: Partial, + outputs?: Record void> + ) => { + // Check DI for overrides if slot name provided + if (slotName && !slot && config) { + const entry = config.get(slotName); + if (entry) { + if (entry.component) slot = entry.component; + else if (entry.template) slot = entry.template; + } + } + + return renderSlot(viewContainer, { + slot, + defaultComponent, + props, + outputs, + }); + }; +} diff --git a/src/v2.x/packages/angular/src/lib/tools.ts b/src/v2.x/packages/angular/src/lib/tools.ts new file mode 100644 index 0000000000..934527028a --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/tools.ts @@ -0,0 +1,123 @@ +import { DestroyRef, Injector, Signal, Type, inject } from "@angular/core"; +import { FrontendTool } from "@copilotkitnext/core"; +import { z } from "zod"; +import { CopilotKit } from "./copilotkit"; + +export type AngularToolCall = Record> = + | { + args: Partial; + status: "in-progress"; + result: undefined; + } + | { + args: Args; + status: "executing"; + result: undefined; + } + | { + args: Args; + status: "complete"; + result: string; + }; + +export type HumanInTheLoopToolCall = Record> = + | { + args: Partial; + status: "in-progress"; + result: undefined; + respond: (result: unknown) => void; + } + | { + args: Args; + status: "executing"; + result: undefined; + respond: (result: unknown) => void; + } + | { + args: Args; + status: "complete"; + result: string; + respond: (result: unknown) => void; + }; + +export interface ToolRenderer = Record> { + toolCall: Signal>; +} + +export interface HumanInTheLoopToolRenderer = Record> { + toolCall: Signal>; +} + +export type ClientTool = Record> = Omit< + FrontendTool, + "handler" +> & { + renderer?: Type>; +}; + +export interface RenderToolCallConfig = Record> { + name: string; + args: z.ZodType; + component: Type>; + agentId?: string; +} + +export interface FrontendToolConfig = Record> { + name: string; + description: string; + parameters: z.ZodType; + component?: Type>; + handler: (args: Args) => Promise; + agentId?: string; +} + +export interface HumanInTheLoopConfig = Record> { + name: string; + description: string; + parameters: z.ZodType; + component: Type>; + agentId?: string; +} + +export function registerRenderToolCall = Record>( + renderToolCall: RenderToolCallConfig, +): void { + const copilotKit = inject(CopilotKit); + const destroyRef = inject(DestroyRef); + + copilotKit.addRenderToolCall(renderToolCall); + + destroyRef.onDestroy(() => { + copilotKit.removeTool(renderToolCall.name, renderToolCall.agentId); + }); +} + +export function registerFrontendTool = Record>( + frontendTool: FrontendToolConfig, +): void { + const injector = inject(Injector); + const destroyRef = inject(DestroyRef); + const copilotKit = inject(CopilotKit); + + copilotKit.addFrontendTool({ + ...(frontendTool as FrontendToolConfig), + injector, + }); + + destroyRef.onDestroy(() => { + copilotKit.removeTool(frontendTool.name); + }); +} + +export function registerHumanInTheLoop = Record>( + humanInTheLoop: HumanInTheLoopConfig, +): void { + const destroyRef = inject(DestroyRef); + const copilotKit = inject(CopilotKit); + + copilotKit.addHumanInTheLoop(humanInTheLoop); + + destroyRef.onDestroy(() => { + copilotKit.removeTool(humanInTheLoop.name); + }); +} diff --git a/src/v2.x/packages/angular/src/lib/utils.ts b/src/v2.x/packages/angular/src/lib/utils.ts new file mode 100644 index 0000000000..23b96ff122 --- /dev/null +++ b/src/v2.x/packages/angular/src/lib/utils.ts @@ -0,0 +1,10 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Utility function to merge Tailwind CSS classes + * Combines clsx for conditional classes and tailwind-merge for proper Tailwind class merging + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/v2.x/packages/angular/src/public-api.ts b/src/v2.x/packages/angular/src/public-api.ts new file mode 100644 index 0000000000..0a97d13ae6 --- /dev/null +++ b/src/v2.x/packages/angular/src/public-api.ts @@ -0,0 +1,50 @@ +export * from "./lib/config"; +export * from "./lib/copilotkit"; +export * from "./lib/tools"; +export * from "./lib/render-tool-calls"; +export * from "./lib/agent"; +export * from "./lib/chat-config"; +export * from "./lib/chat-state"; +export * from "./lib/scroll-position"; +export * from "./lib/resize-observer"; +export * from "./lib/utils"; +export * from "./lib/agent-context"; + +export * from "./lib/slots"; + +export * from "./lib/directives/copilotkit-agent-context"; +export * from "./lib/directives/stick-to-bottom"; +export * from "./lib/directives/tooltip"; + +export * from "./lib/components/chat/copilot-chat"; +export * from "./lib/components/chat/copilot-chat-assistant-message"; +export * from "./lib/components/chat/copilot-chat-assistant-message-buttons"; +export * from "./lib/components/chat/copilot-chat-assistant-message-renderer"; +export * from "./lib/components/chat/copilot-chat-assistant-message-toolbar"; +export * from "./lib/components/chat/copilot-chat-assistant-message.types"; +export * from "./lib/components/chat/copilot-chat-audio-recorder"; +export * from "./lib/components/chat/copilot-chat-buttons"; +export * from "./lib/components/chat/copilot-chat-input"; +export * from "./lib/components/chat/copilot-chat-input-defaults"; +export * from "./lib/components/chat/copilot-chat-input.types"; +export * from "./lib/components/chat/copilot-chat-message-view"; +export * from "./lib/components/chat/copilot-chat-message-view-cursor"; +export * from "./lib/components/chat/copilot-chat-message-view.types"; +export * from "./lib/components/chat/copilot-chat-textarea"; +export * from "./lib/components/chat/copilot-chat-tool-calls-view"; +export * from "./lib/components/chat/copilot-chat-toolbar"; +export * from "./lib/components/chat/copilot-chat-tools-menu"; +export * from "./lib/components/chat/copilot-chat-user-message"; +export * from "./lib/components/chat/copilot-chat-user-message-branch-navigation"; +export * from "./lib/components/chat/copilot-chat-user-message-buttons"; +export * from "./lib/components/chat/copilot-chat-user-message-renderer"; +export * from "./lib/components/chat/copilot-chat-user-message-toolbar"; +export * from "./lib/components/chat/copilot-chat-user-message.types"; +export * from "./lib/components/chat/copilot-chat-view"; +export * from "./lib/components/chat/copilot-chat-view-disclaimer"; +export * from "./lib/components/chat/copilot-chat-view-feather"; +export * from "./lib/components/chat/copilot-chat-view-handlers"; +export * from "./lib/components/chat/copilot-chat-view-input-container"; +export * from "./lib/components/chat/copilot-chat-view-scroll-to-bottom-button"; +export * from "./lib/components/chat/copilot-chat-view-scroll-view"; +export * from "./lib/components/chat/copilot-chat-view.types"; diff --git a/src/v2.x/packages/angular/src/styles/globals.css b/src/v2.x/packages/angular/src/styles/globals.css new file mode 100644 index 0000000000..bd1d74362d --- /dev/null +++ b/src/v2.x/packages/angular/src/styles/globals.css @@ -0,0 +1,266 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; +@source "../**/*.{ts,html}"; +@source "../../apps/angular/storybook/stories/**/*.{ts,html}"; + +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@theme { + --animate-pulse-cursor: pulse-cursor 0.9s cubic-bezier(0.4, 0, 0.2, 1) + infinite; + @keyframes pulse-cursor { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.5); + opacity: 0.8; + } + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Shiki styles removed - Angular uses highlight.js instead */ + +.prose { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.prose + :where(code):not( + :where([class~="not-prose"], [class~="not-prose"] *) + )::before { + content: none; +} + +.prose + :where(code):not( + :where([class~="not-prose"], [class~="not-prose"] *) + )::after { + content: none; +} + +.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + font-weight: 400; +} + +.prose h1 { + margin-block-end: 8px; + margin-bottom: 8px; + font-size: 24px; + font-weight: 600; +} + +.prose h2 { + margin-block-end: 4px; + margin-block-start: 16px; + margin-top: 16px; + margin-bottom: 4px; + font-size: 20px; + font-weight: 600; +} + +.prose h3 { + margin-block-end: 4px; + margin-block-start: 16px; + margin-top: 16px; + margin-bottom: 4px; + font-size: 18px; + font-weight: 600; +} + +.prose p { + margin-block-start: 8px; + margin-block-end: 4px; + margin-top: 4px; + margin-bottom: 8px; +} + +.prose a { + color: #2964aa; + text-decoration: none; +} + +.prose a:hover { + color: #749ac8; +} + +.prose + :where(blockquote p:first-of-type):not( + :where([class~="not-prose"], [class~="not-prose"] *) + )::before { + content: none; +} + +.prose blockquote { + font-style: normal; + font-weight: 400; +} + +.prose input[type="checkbox"] { + appearance: none; + background-color: #fff; + background-origin: border-box; + border-color: #9b9b9b; + border-width: 1px; + color: #004f99; + display: inline-block; + flex-shrink: 0; + height: 1rem; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + -webkit-user-select: none; + user-select: none; + vertical-align: middle; + width: 1rem; + border-radius: 2px; +} + +.prose input[type="checkbox"]:checked { + background-color: #004f99; + border-color: #004f99; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +.prose em { + @apply text-foreground; +} + +.prose hr { + margin-block-start: 0px; + margin-block-end: 0px; + margin-top: 28px; + margin-bottom: 28px; + color: rgb(13, 13, 13); +} + +.prose td { + @apply text-foreground; +} diff --git a/src/v2.x/packages/angular/src/styles/index.css b/src/v2.x/packages/angular/src/styles/index.css new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/src/v2.x/packages/angular/src/styles/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/v2.x/packages/angular/src/test-setup.ts b/src/v2.x/packages/angular/src/test-setup.ts new file mode 100644 index 0000000000..6c02928e2b --- /dev/null +++ b/src/v2.x/packages/angular/src/test-setup.ts @@ -0,0 +1,106 @@ +// Angular + Zone - Using AnalogJS setup for proper Zone.js integration +import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-zone'; + +import { getTestBed } from '@angular/core/testing'; +import { Injector } from '@angular/core'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +// JSDOM polyfills commonly needed by Angular/CDK/components +// ResizeObserver +if (!(globalThis as any).ResizeObserver) { + class RO { + callback: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { this.callback = cb; } + observe() { /* noop */ } + unobserve() { /* noop */ } + disconnect() { /* noop */ } + } + (globalThis as any).ResizeObserver = RO as any; +} + +// IntersectionObserver +if (!(globalThis as any).IntersectionObserver) { + class IO { + constructor(_: IntersectionObserverCallback) {} + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { return []; } + root = null; rootMargin = ''; thresholds: number[] = []; + } + (globalThis as any).IntersectionObserver = IO as any; +} + +// matchMedia +if (!window.matchMedia) { + (window as any).matchMedia = () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }); +} + +// requestAnimationFrame +if (!globalThis.requestAnimationFrame) { + (globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number; + (globalThis as any).cancelAnimationFrame = (id: number) => clearTimeout(id); +} + +// Canvas context - provide a mock implementation for testing +Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: function(contextType: string) { + // Return mock context for testing + return { + fillRect: () => {}, clearRect: () => {}, getImageData: () => ({ data: [] }), + putImageData: () => {}, createImageData: () => [], setTransform: () => {}, + drawImage: () => {}, save: () => {}, fillText: () => {}, restore: () => {}, + beginPath: () => {}, moveTo: () => {}, lineTo: () => {}, closePath: () => {}, + stroke: () => {}, translate: () => {}, scale: () => {}, rotate: () => {}, + arc: () => {}, fill: () => {}, measureText: () => ({ width: 0 }), + transform: () => {}, rect: () => {}, clip: () => {}, + lineWidth: 1, strokeStyle: '#000', fillStyle: '#000', + canvas: this, + }; + }, + writable: true, + configurable: true +}); + +// DOMRect +if (!(globalThis as any).DOMRect) { + (globalThis as any).DOMRect = class { constructor(public x=0, public y=0, public width=0, public height=0) {} } as any; +} + +// Initialize Angular testing environment once per worker +console.info('[vitest] test-setup.ts running in pid', process.pid); + +const testBed = getTestBed(); + +// Store platform instance globally to reuse across test files +const globalAny = globalThis as any; +if (!globalAny.__ANGULAR_TEST_PLATFORM__) { + console.info('[vitest] Creating Angular test platform'); + globalAny.__ANGULAR_TEST_PLATFORM__ = platformBrowserDynamicTesting(); +} + +// Check if TestBed has already been initialized by checking the platform +if (!testBed.platform) { + console.info('[vitest] Initializing TestBed'); + testBed.initTestEnvironment( + BrowserDynamicTestingModule, + globalAny.__ANGULAR_TEST_PLATFORM__, + { teardown: { destroyAfterEach: false } } // Don't tear down after each test + ); + console.info('[vitest] TestBed initialized'); +} else { + console.info('[vitest] TestBed already initialized, skipping'); +} \ No newline at end of file diff --git a/src/v2.x/packages/angular/tsconfig.json b/src/v2.x/packages/angular/tsconfig.json new file mode 100644 index 0000000000..d53ba473c1 --- /dev/null +++ b/src/v2.x/packages/angular/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "@copilotkitnext/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": true, + "types": ["node"], + "paths": { + "@copilotkitnext/core": ["../core/src/index.ts"], + "@copilotkitnext/shared": ["../shared/src/index.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"], + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/src/v2.x/packages/angular/tsconfig.spec.json b/src/v2.x/packages/angular/tsconfig.spec.json new file mode 100644 index 0000000000..8e906fc59f --- /dev/null +++ b/src/v2.x/packages/angular/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2016", + "types": ["vitest/globals", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.d.ts" + ], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/src/v2.x/packages/angular/vitest.config.mts b/src/v2.x/packages/angular/vitest.config.mts new file mode 100644 index 0000000000..868f5c5cde --- /dev/null +++ b/src/v2.x/packages/angular/vitest.config.mts @@ -0,0 +1,45 @@ +/// +import { defineConfig } from 'vite'; +import angular from '@analogjs/vite-plugin-angular'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const r = (...p: string[]) => resolve(__dirname, ...p); + +export default defineConfig(({ mode }) => ({ + plugins: [angular()], + resolve: { + dedupe: [ + '@angular/core', + '@angular/common', + '@angular/platform-browser', + '@angular/platform-browser-dynamic', + '@angular/compiler', + '@angular/core/testing', + ], + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: [r('src/test-setup.ts')], // Use absolute path + include: ['src/**/*.{spec,test}.{ts,tsx}'], + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '*.config.*', + 'src/test-setup.ts', + 'src/index.ts', + 'src/public-api.ts', + ], + }, + }, + define: { + 'import.meta.vitest': mode !== 'production', + }, +})); \ No newline at end of file diff --git a/src/v2.x/packages/core/.npmignore b/src/v2.x/packages/core/.npmignore new file mode 100644 index 0000000000..e349b68043 --- /dev/null +++ b/src/v2.x/packages/core/.npmignore @@ -0,0 +1,36 @@ +# Source files (only publish dist) +src/ +tsup.config.ts +tsconfig.json +vitest.config.ts + +# Turbo cache +.turbo/ + +# Test files +**/*.test.ts +**/*.spec.ts +**/__tests__/ + +# Config files +.eslintrc.js +.prettierrc + +# IDE +.vscode/ +.idea/ + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# Documentation +*.md +!README.md + +# Git +.git/ +.gitignore \ No newline at end of file diff --git a/src/v2.x/packages/core/eslint.config.mjs b/src/v2.x/packages/core/eslint.config.mjs new file mode 100644 index 0000000000..daa75ba7a7 --- /dev/null +++ b/src/v2.x/packages/core/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config as baseConfig } from "@copilotkitnext/eslint-config/base"; + +export default [...baseConfig]; diff --git a/src/v2.x/packages/core/package.json b/src/v2.x/packages/core/package.json new file mode 100644 index 0000000000..0fc45dbae7 --- /dev/null +++ b/src/v2.x/packages/core/package.json @@ -0,0 +1,48 @@ +{ + "name": "@copilotkitnext/core", + "version": "0.0.33", + "description": "Core web utilities for CopilotKit2", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "prepublishOnly": "pnpm run build", + "dev": "tsup --watch", + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --noEmit", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@copilotkitnext/eslint-config": "workspace:*", + "@copilotkitnext/typescript-config": "workspace:*", + "@types/node": "^22.15.3", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.30.0", + "tsup": "^8.5.0", + "typescript": "5.8.2", + "vitest": "^3.2.4" + }, + "dependencies": { + "@ag-ui/client": "0.0.42", + "@copilotkitnext/shared": "workspace:*", + "rxjs": "7.8.1", + "zod": "^3.25.75", + "zod-to-json-schema": "^3.24.6" + }, + "engines": { + "node": ">=18" + } +} diff --git a/src/v2.x/packages/core/src/__tests__/core-agent-constraints.test.ts b/src/v2.x/packages/core/src/__tests__/core-agent-constraints.test.ts new file mode 100644 index 0000000000..017f37be70 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-agent-constraints.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CopilotKitCore } from '../core'; +import { FrontendTool } from '../types'; +import { MockAgent, createAssistantMessage } from './test-utils'; + +describe('CopilotKitCore - Agent Constraints', () => { + it('should add tool with agentId', () => { + const core = new CopilotKitCore({ + headers: {}, + properties: {}, + }); + + const tool: FrontendTool = { + name: 'testTool', + handler: vi.fn(), + agentId: 'agent1', + }; + + core.addTool(tool); + const retrievedTool = core.getTool({ toolName: 'testTool', agentId: 'agent1' }); + expect(retrievedTool).toBeDefined(); + expect(retrievedTool?.agentId).toBe('agent1'); + }); + + it('should add multiple tools with different agentIds', () => { + const core = new CopilotKitCore({ + headers: {}, + properties: {}, + }); + + const globalTool: FrontendTool = { + name: 'globalTool', + handler: vi.fn(), + }; + + const agent1Tool: FrontendTool = { + name: 'agent1Tool', + handler: vi.fn(), + agentId: 'agent1', + }; + + const agent2Tool: FrontendTool = { + name: 'agent2Tool', + handler: vi.fn(), + agentId: 'agent2', + }; + + core.addTool(globalTool); + core.addTool(agent1Tool); + core.addTool(agent2Tool); + + const retrievedGlobalTool = core.getTool({ toolName: 'globalTool' }); + expect(retrievedGlobalTool).toBeDefined(); + expect(retrievedGlobalTool?.agentId).toBeUndefined(); + + const retrievedAgent1Tool = core.getTool({ toolName: 'agent1Tool', agentId: 'agent1' }); + expect(retrievedAgent1Tool).toBeDefined(); + expect(retrievedAgent1Tool?.agentId).toBe('agent1'); + + const retrievedAgent2Tool = core.getTool({ toolName: 'agent2Tool', agentId: 'agent2' }); + expect(retrievedAgent2Tool).toBeDefined(); + expect(retrievedAgent2Tool?.agentId).toBe('agent2'); + }); + + it('should preserve all FrontendTool properties including agentId', () => { + const core = new CopilotKitCore({ + headers: {}, + properties: {}, + }); + + const handler = vi.fn(async () => 'result'); + const tool: FrontendTool = { + name: 'fullTool', + description: 'A complete tool', + handler, + followUp: false, + agentId: 'specificAgent', + }; + + core.addTool(tool); + + const addedTool = core.getTool({ toolName: 'fullTool', agentId: 'specificAgent' }); + expect(addedTool).toBeDefined(); + expect(addedTool?.name).toBe('fullTool'); + expect(addedTool?.description).toBe('A complete tool'); + expect(addedTool?.handler).toBe(handler); + expect(addedTool?.followUp).toBe(false); + expect(addedTool?.agentId).toBe('specificAgent'); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/core-agent-id-validation.test.ts b/src/v2.x/packages/core/src/__tests__/core-agent-id-validation.test.ts new file mode 100644 index 0000000000..61a33fae03 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-agent-id-validation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { CopilotKitCore } from "../core"; +import { HttpAgent } from "@ag-ui/client"; + +describe("CopilotKitCore agent ID validation", () => { + it("should assign agent ID from registration key if agent ID is undefined", () => { + const agent = new HttpAgent({ url: "https://example.com" }); + expect(agent.agentId).toBeUndefined(); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + agents__unsafe_dev_only: { + myAgent: agent, + }, + }); + + expect(agent.agentId).toBe("myAgent"); + expect(core.getAgent("myAgent")).toBe(agent); + }); + + it("should allow registration when agent ID matches registration key", () => { + const agent = new HttpAgent({ url: "https://example.com" }); + agent.agentId = "myAgent"; + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + agents__unsafe_dev_only: { + myAgent: agent, + }, + }); + + expect(agent.agentId).toBe("myAgent"); + expect(core.getAgent("myAgent")).toBe(agent); + }); + + it("should throw error when agent ID doesn't match registration key", () => { + const agent = new HttpAgent({ url: "https://example.com" }); + agent.agentId = "differentId"; + + expect(() => { + new CopilotKitCore({ + runtimeUrl: undefined, + agents__unsafe_dev_only: { + myAgent: agent, + }, + }); + }).toThrow( + 'Agent registration mismatch: Agent with ID "differentId" cannot be registered under key "myAgent". ' + + "The agent ID must match the registration key or be undefined." + ); + }); + + it("should validate agent ID when using addAgent__unsafe_dev_only", () => { + const core = new CopilotKitCore({ + runtimeUrl: undefined, + }); + + const agent1 = new HttpAgent({ url: "https://example.com" }); + core.addAgent__unsafe_dev_only({ id: "agent1", agent: agent1 }); + expect(agent1.agentId).toBe("agent1"); + + const agent2 = new HttpAgent({ url: "https://example.com" }); + agent2.agentId = "agent2"; + core.addAgent__unsafe_dev_only({ id: "agent2", agent: agent2 }); + expect(agent2.agentId).toBe("agent2"); + + const agent3 = new HttpAgent({ url: "https://example.com" }); + agent3.agentId = "wrongId"; + expect(() => { + core.addAgent__unsafe_dev_only({ id: "agent3", agent: agent3 }); + }).toThrow( + 'Agent registration mismatch: Agent with ID "wrongId" cannot be registered under key "agent3". ' + + "The agent ID must match the registration key or be undefined." + ); + }); + + it("should validate agent IDs when using setAgents__unsafe_dev_only", () => { + const core = new CopilotKitCore({ + runtimeUrl: undefined, + }); + + const agent1 = new HttpAgent({ url: "https://example.com" }); + const agent2 = new HttpAgent({ url: "https://example.com" }); + agent2.agentId = "agent2"; + + core.setAgents__unsafe_dev_only({ + agent1: agent1, + agent2: agent2, + }); + + expect(agent1.agentId).toBe("agent1"); + expect(agent2.agentId).toBe("agent2"); + + const agent3 = new HttpAgent({ url: "https://example.com" }); + agent3.agentId = "wrongId"; + + expect(() => { + core.setAgents__unsafe_dev_only({ + agent3: agent3, + }); + }).toThrow( + 'Agent registration mismatch: Agent with ID "wrongId" cannot be registered under key "agent3". ' + + "The agent ID must match the registration key or be undefined." + ); + }); + + it("should handle multiple agents with proper ID validation", () => { + const agent1 = new HttpAgent({ url: "https://example.com" }); + const agent2 = new HttpAgent({ url: "https://example.com" }); + const agent3 = new HttpAgent({ url: "https://example.com" }); + + agent2.agentId = "secondAgent"; + // agent1 and agent3 have undefined IDs + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + agents__unsafe_dev_only: { + firstAgent: agent1, + secondAgent: agent2, + thirdAgent: agent3, + }, + }); + + expect(agent1.agentId).toBe("firstAgent"); + expect(agent2.agentId).toBe("secondAgent"); + expect(agent3.agentId).toBe("thirdAgent"); + + expect(core.getAgent("firstAgent")).toBe(agent1); + expect(core.getAgent("secondAgent")).toBe(agent2); + expect(core.getAgent("thirdAgent")).toBe(agent3); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/core-basic-functionality.test.ts b/src/v2.x/packages/core/src/__tests__/core-basic-functionality.test.ts new file mode 100644 index 0000000000..90c8d3a293 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-basic-functionality.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { CopilotKitCore } from "../core"; +import { + MockAgent, + createMessage, + createAssistantMessage, + createToolCallMessage, + createTool, +} from "./test-utils"; + +describe("CopilotKitCore.runAgent - Basic Functionality", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should run agent without tools", async () => { + const messages = [ + createMessage({ content: "Hello" }), + createAssistantMessage({ content: "Hi there!" }), + ]; + const agent = new MockAgent({ newMessages: messages }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result.newMessages).toEqual(messages); + expect(agent.runAgentCalls).toHaveLength(1); + expect(agent.runAgentCalls[0].forwardedProps).toEqual({}); + }); + + it("should forward properties to agent.runAgent", async () => { + const properties = { apiKey: "test-key", model: "gpt-4" }; + copilotKitCore = new CopilotKitCore({ properties }); + const agent = new MockAgent({ newMessages: [] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.runAgentCalls[0].forwardedProps).toEqual(properties); + }); + + it("should handle empty newMessages array", async () => { + const agent = new MockAgent({ newMessages: [] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result.newMessages).toEqual([]); + expect(agent.runAgentCalls).toHaveLength(1); + }); + + it("should ignore non-assistant messages for tool processing", async () => { + const messages = [ + createMessage({ role: "user", content: "User message" }), + createMessage({ role: "system", content: "System message" }), + createMessage({ role: "tool", content: "Tool result", toolCallId: "123" }), + ]; + const agent = new MockAgent({ newMessages: messages }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result.newMessages).toEqual(messages); + expect(agent.runAgentCalls).toHaveLength(1); + }); + + it("should handle messages with undefined toolCalls", async () => { + const message = createAssistantMessage({ + content: "Response", + toolCalls: undefined + }); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result.newMessages).toEqual([message]); + expect(agent.runAgentCalls).toHaveLength(1); + }); + + it("should handle tool returning undefined as empty string", async () => { + const toolName = "undefinedTool"; + const tool = createTool({ + name: toolName, + handler: vi.fn(async () => undefined), + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage(toolName); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.messages).toContainEqual( + expect.objectContaining({ + role: "tool", + content: "", // Should be empty string, not "undefined" + }) + ); + }); + + it("should handle tool returning null as empty string", async () => { + const toolName = "nullTool"; + const tool = createTool({ + name: toolName, + handler: vi.fn(async () => null), + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage(toolName); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.messages).toContainEqual( + expect.objectContaining({ + role: "tool", + content: "", // Should be empty string, not "null" + }) + ); + }); + + it("should return correct result structure", async () => { + const newMessages = [ + createAssistantMessage({ content: "Test" }) + ]; + const agent = new MockAgent({ newMessages }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result).toHaveProperty("newMessages"); + expect(Array.isArray(result.newMessages)).toBe(true); + expect(result.newMessages).toEqual(newMessages); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/core-basic.test.ts b/src/v2.x/packages/core/src/__tests__/core-basic.test.ts new file mode 100644 index 0000000000..8a3c27cae5 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-basic.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CopilotKitCore } from "../core"; + +describe("CopilotKitCore Basic", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + }); + + it("should create an instance", () => { + expect(copilotKitCore).toBeDefined(); + expect(copilotKitCore.agents).toEqual({}); + expect(copilotKitCore.tools).toEqual([]); + }); + + it("should add a tool", () => { + const tool = { + name: "testTool", + handler: vi.fn(), + }; + + copilotKitCore.addTool(tool); + + expect(copilotKitCore.getTool({ toolName: "testTool" })).toBe(tool); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-edge-cases.test.ts b/src/v2.x/packages/core/src/__tests__/core-edge-cases.test.ts new file mode 100644 index 0000000000..e1dc65f7c1 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-edge-cases.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { CopilotKitCore } from "../core"; +import { + MockAgent, + createAssistantMessage, + createToolCallMessage, + createToolResultMessage, + createTool, +} from "./test-utils"; + +describe("CopilotKitCore.runAgent - Edge Cases", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should skip tool call when result already exists in newMessages", async () => { + const tool = createTool({ + name: "alreadyProcessedTool", + handler: vi.fn(async () => "Should not be called"), + }); + copilotKitCore.addTool(tool); + + const toolCallId = "processed-call"; + const assistantMsg = createToolCallMessage("alreadyProcessedTool"); + if (assistantMsg.role === 'assistant' && assistantMsg.toolCalls && assistantMsg.toolCalls[0]) { + assistantMsg.toolCalls[0].id = toolCallId; + } + const existingResult = createToolResultMessage(toolCallId, "Already processed"); + + const agent = new MockAgent({ + newMessages: [assistantMsg, existingResult], + }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(tool.handler).not.toHaveBeenCalled(); + }); + + it("should handle empty tool function name", async () => { + const message = createAssistantMessage({ + content: "", + toolCalls: [{ + id: "empty-name-call", + type: "function", + function: { + name: "", + arguments: "{}", + }, + }], + }); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.messages.filter(m => m.role === "tool")).toHaveLength(0); + }); + + it("should handle tool arguments as empty string", async () => { + const tool = createTool({ + name: "emptyArgsTool", + handler: vi.fn(async (args) => `Received: ${JSON.stringify(args)}`), + }); + copilotKitCore.addTool(tool); + + const message = createAssistantMessage({ + content: "", + toolCalls: [{ + id: "empty-args-call", + type: "function", + function: { + name: "emptyArgsTool", + arguments: "", + }, + }], + }); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await expect(copilotKitCore.runAgent({ agent: agent as any })).rejects.toThrow(); + }); + + it("should handle very large tool result", async () => { + const largeResult = "x".repeat(100000); // 100KB string + const tool = createTool({ + name: "largeTool", + handler: vi.fn(async () => largeResult), + followUp: false, + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage("largeTool"); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + const toolMessage = agent.messages.find(m => m.role === "tool"); + expect(toolMessage?.content).toBe(largeResult); + }); + + it("should handle tool handler modifying agent state", async () => { + const tool = createTool({ + name: "stateTool", + handler: vi.fn(async () => { + // Try to modify agent messages during execution + agent.messages.push(createAssistantMessage({ content: "Injected" })); + return "Result"; + }), + followUp: false, + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage("stateTool"); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + // The injected message should be present + expect(agent.messages.some(m => m.content === "Injected")).toBe(true); + // Tool result should still be added correctly + expect(agent.messages.some(m => m.role === "tool" && m.content === "Result")).toBe(true); + }); + + it("should propagate errors from agent.runAgent", async () => { + const errorMessage = "Agent execution failed"; + const agent = new MockAgent({ + error: new Error(errorMessage) + }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await expect(copilotKitCore.runAgent({ agent: agent as any })) + .rejects + .toThrow(errorMessage); + }); + + it("should handle tool with invalid JSON arguments", async () => { + const toolName = "invalidJsonTool"; + const tool = createTool({ + name: toolName, + handler: vi.fn(async () => "Should not be called"), + }); + copilotKitCore.addTool(tool); + + const message = createAssistantMessage({ + content: "", + toolCalls: [{ + id: "tool-call-1", + type: "function", + function: { + name: toolName, + arguments: "{ invalid json", + }, + }], + }); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await expect(copilotKitCore.runAgent({ agent: agent as any })).rejects.toThrow(); + expect(tool.handler).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/core-error-handling.test.ts b/src/v2.x/packages/core/src/__tests__/core-error-handling.test.ts new file mode 100644 index 0000000000..f9c3df139e --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-error-handling.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { CopilotKitCore, CopilotKitCoreErrorCode } from "../core"; +import { ProxiedCopilotRuntimeAgent } from "../agent"; +import { createAssistantMessage } from "./test-utils"; + +describe("CopilotKitCore error handling", () => { + describe("agent error events", () => { + it("emits AGENT_RUN_ERROR_EVENT when agent sends RunError event", async () => { + const core = new CopilotKitCore({}); + const errors: Array<{ code: CopilotKitCoreErrorCode; error: Error; context: any }> = []; + const sub = core.subscribe({ onError: (e) => void errors.push(e) }); + + // Minimal agent that triggers onRunErrorEvent via the provided subscriber + const agent = { + agentId: "agent1", + threadId: "t1", + messages: [] as any[], + state: {}, + // Simulate HttpAgent-like API surface expected by core + addMessages: (_m: any[]) => {}, + addMessage: (_m: any) => {}, + abortRun: () => {}, + clone: () => agent, + subscribe: () => ({ unsubscribe() {} }), + async runAgent(_params: any, subscriber?: any) { + const event = { + type: "RUN_ERROR", + threadId: this.threadId, + runId: "r1", + message: "runtime error happened", + code: "bad_request", + rawEvent: { error: "bad_request" }, + } as any; + await subscriber?.onRunErrorEvent?.({ + event, + agent: this, + messages: this.messages, + state: this.state, + input: { threadId: this.threadId, runId: "r1", messages: this.messages, state: this.state }, + }); + return { newMessages: [] }; + }, + } as any; + + // Register agent to avoid suggestion engine warnings + core.addAgent__unsafe_dev_only({ id: agent.agentId, agent: agent as any }); + await core.runAgent({ agent }); + + expect(errors.some((e) => e.code === CopilotKitCoreErrorCode.AGENT_RUN_ERROR_EVENT)).toBe(true); + const evt = errors.find((e) => e.code === CopilotKitCoreErrorCode.AGENT_RUN_ERROR_EVENT)!; + expect(evt.context.agentId).toBe("agent1"); + expect(evt.context.event).toBeDefined(); + + sub.unsubscribe(); + }); + + it("emits AGENT_RUN_FAILED_EVENT when agent triggers onRunFailed", async () => { + const core = new CopilotKitCore({}); + const errors: Array<{ code: CopilotKitCoreErrorCode; error: Error; context: any }> = []; + const sub = core.subscribe({ onError: (e) => void errors.push(e) }); + + const agent = { + agentId: "agent2", + threadId: "t2", + messages: [] as any[], + state: {}, + addMessages: (_m: any[]) => {}, + addMessage: (_m: any) => {}, + abortRun: () => {}, + clone: () => agent, + subscribe: () => ({ unsubscribe() {} }), + async runAgent(_params: any, subscriber?: any) { + await subscriber?.onRunFailed?.({ error: new Error("agent failed") }); + return { newMessages: [] }; + }, + } as any; + + core.addAgent__unsafe_dev_only({ id: agent.agentId, agent: agent as any }); + await core.runAgent({ agent }); + + expect(errors.some((e) => e.code === CopilotKitCoreErrorCode.AGENT_RUN_FAILED_EVENT)).toBe(true); + const evt = errors.find((e) => e.code === CopilotKitCoreErrorCode.AGENT_RUN_FAILED_EVENT)!; + expect(evt.context.agentId).toBe("agent2"); + expect(evt.context.source).toBe("onRunFailed"); + + sub.unsubscribe(); + }); + }); + + describe("http errors", () => { + const originalFetch = global.fetch; + const originalWindow = (globalThis as { window?: unknown }).window; + + beforeEach(() => { + // Simulate browser environment to allow updateRuntimeConnection to proceed. + (globalThis as { window?: unknown }).window = {}; + }); + + afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = originalWindow; + } + }); + + it("emits RUNTIME_INFO_FETCH_FAILED when runtime info sync fails", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("network failure")); + global.fetch = fetchMock; + + const core = new CopilotKitCore({ runtimeUrl: "https://runtime.example/rest", runtimeTransport: "rest" }); + const errors: Array<{ code: CopilotKitCoreErrorCode; error: Error; context: any }> = []; + const sub = core.subscribe({ onError: (e) => void errors.push(e) }); + + await vi.waitFor(() => { + expect(errors.some((e) => e.code === CopilotKitCoreErrorCode.RUNTIME_INFO_FETCH_FAILED)).toBe(true); + }); + + sub.unsubscribe(); + }); + + it("emits AGENT_RUN_FAILED when proxied runtime agent run fails (REST)", async () => { + const runtimeUrl = "https://runtime.example/rest"; + const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failure")); + global.fetch = fetchMock; + + const core = new CopilotKitCore({}); + const errors: Array<{ code: CopilotKitCoreErrorCode; error: Error; context: any }> = []; + const sub = core.subscribe({ onError: (e) => void errors.push(e) }); + + const agent = new ProxiedCopilotRuntimeAgent({ runtimeUrl, agentId: "agent-http", transport: "rest" }); + + await expect(core.runAgent({ agent })).rejects.toBeDefined(); + + expect(errors.some((e) => e.code === CopilotKitCoreErrorCode.AGENT_RUN_FAILED)).toBe(true); + const evt = errors.find((e) => e.code === CopilotKitCoreErrorCode.AGENT_RUN_FAILED)!; + expect(evt.context.agentId).toBe("agent-http"); + + sub.unsubscribe(); + }); + }); + + describe("internal processing errors (tools)", () => { + it("emits TOOL_ARGUMENT_PARSE_FAILED then AGENT_RUN_FAILED when tool args JSON is invalid", async () => { + const core = new CopilotKitCore({}); + const errors: Array<{ code: CopilotKitCoreErrorCode; error: Error; context: any }> = []; + const sub = core.subscribe({ onError: (e) => void errors.push(e) }); + + const toolName = "parseFail"; + core.addTool({ name: toolName, description: "", handler: async () => "ok" }); + + // Assistant message with a tool call and invalid JSON arguments + const assistant = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "tc1", + type: "function", + function: { name: toolName, arguments: "not-json" }, + } as any, + ], + } as any); + + const agent = { + agentId: "agent-tools-1", + threadId: "t1", + messages: [] as any[], + state: {}, + addMessages: (m: any[]) => agent.messages.push(...m), + addMessage: (m: any) => agent.messages.push(m), + abortRun: () => {}, + clone: () => agent, + subscribe: () => ({ unsubscribe() {} }), + async runAgent() { + return { newMessages: [assistant] }; + }, + } as any; + + core.addAgent__unsafe_dev_only({ id: agent.agentId, agent: agent as any }); + await expect(core.runAgent({ agent })).rejects.toBeDefined(); + + // Argument parse error captured + expect(errors.some((e) => e.code === CopilotKitCoreErrorCode.TOOL_ARGUMENT_PARSE_FAILED)).toBe(true); + // The run rejects; current implementation does not emit AGENT_RUN_FAILED for this path + + sub.unsubscribe(); + }); + + it("emits TOOL_HANDLER_FAILED and continues run when tool handler throws", async () => { + const core = new CopilotKitCore({}); + const errors: Array<{ code: CopilotKitCoreErrorCode; error: Error; context: any }> = []; + const sub = core.subscribe({ onError: (e) => void errors.push(e) }); + + const toolName = "boom"; + core.addTool({ name: toolName, description: "", handler: async () => { throw new Error("boom"); } }); + + const assistant = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "tc2", + type: "function", + function: { name: toolName, arguments: JSON.stringify({ a: 1 }) }, + } as any, + ], + } as any); + + const agent = { + agentId: "agent-tools-2", + threadId: "t2", + messages: [] as any[], + state: {}, + addMessages: (m: any[]) => agent.messages.push(...m), + addMessage: (m: any) => agent.messages.push(m), + abortRun: () => {}, + clone: () => agent, + subscribe: () => ({ unsubscribe() {} }), + async runAgent() { + return { newMessages: [assistant] }; + }, + } as any; + + core.addAgent__unsafe_dev_only({ id: agent.agentId, agent: agent as any }); + const result = await core.runAgent({ agent }); + + // Handler error should be reported + expect(errors.some((e) => e.code === CopilotKitCoreErrorCode.TOOL_HANDLER_FAILED)).toBe(true); + // Run should not fail; tool result message should contain the error string + expect(Array.isArray(result.newMessages)).toBe(true); + // After run, the tool result is inserted into agent.messages with "Error: boom" + expect(agent.messages.some((m: any) => m.role === "tool" && typeof m.content === "string" && m.content.includes("Error: boom"))).toBe(true); + + sub.unsubscribe(); + }); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-follow-up.test.ts b/src/v2.x/packages/core/src/__tests__/core-follow-up.test.ts new file mode 100644 index 0000000000..d14015c751 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-follow-up.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { CopilotKitCore } from "../core"; +import { FrontendTool } from "../types"; +import { + MockAgent, + createAssistantMessage, + createToolCallMessage, + createMultipleToolCallsMessage, + createTool, +} from "./test-utils"; + +describe("CopilotKitCore.runAgent - Follow-up Logic", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should trigger recursive call when tool.followUp is true", async () => { + const tool = createTool({ + name: "followUpTool", + handler: vi.fn(async () => "Result"), + followUp: true, + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage("followUpTool"); + const followUpMessage = createAssistantMessage({ content: "Follow-up response" }); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + if (callCount === 2) { + agent.setNewMessages([followUpMessage]); + } + }; + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.runAgentCalls).toHaveLength(2); + expect(result.newMessages).toContain(followUpMessage); + }); + + it("should not trigger recursive call when tool.followUp is false", async () => { + const tool = createTool({ + name: "noFollowUpTool", + handler: vi.fn(async () => "Result"), + followUp: false, + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage("noFollowUpTool"); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.runAgentCalls).toHaveLength(1); + }); + + it("should trigger recursive call when tool.followUp is undefined (default)", async () => { + const tool: FrontendTool = { + name: "defaultFollowUpTool", + handler: vi.fn(async () => "Result"), + // followUp is undefined + }; + copilotKitCore.addTool(tool); + + const message = createToolCallMessage("defaultFollowUpTool"); + const followUpMessage = createAssistantMessage({ content: "Follow-up" }); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + if (callCount === 2) { + agent.setNewMessages([followUpMessage]); + } + }; + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.runAgentCalls).toHaveLength(2); + }); + + it("should trigger follow-up when at least one tool needs it", async () => { + const tool1 = createTool({ + name: "tool1", + handler: vi.fn(async () => "Result 1"), + followUp: false, + }); + const tool2 = createTool({ + name: "tool2", + handler: vi.fn(async () => "Result 2"), + followUp: true, + }); + const tool3 = createTool({ + name: "tool3", + handler: vi.fn(async () => "Result 3"), + followUp: false, + }); + copilotKitCore.addTool(tool1); + copilotKitCore.addTool(tool2); + copilotKitCore.addTool(tool3); + + const message = createMultipleToolCallsMessage([ + { name: "tool1" }, + { name: "tool2" }, + { name: "tool3" }, + ]); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + if (callCount === 2) { + agent.setNewMessages([]); + } + }; + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.runAgentCalls).toHaveLength(2); + }); + + it("should not trigger follow-up when all tools have followUp=false", async () => { + const tool1 = createTool({ + name: "tool1", + handler: vi.fn(async () => "Result 1"), + followUp: false, + }); + const tool2 = createTool({ + name: "tool2", + handler: vi.fn(async () => "Result 2"), + followUp: false, + }); + copilotKitCore.addTool(tool1); + copilotKitCore.addTool(tool2); + + const message = createMultipleToolCallsMessage([ + { name: "tool1" }, + { name: "tool2" }, + ]); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.runAgentCalls).toHaveLength(1); + }); + + it("should return final result after recursive follow-up", async () => { + const tool = createTool({ + name: "recursiveTool", + handler: vi.fn(async () => "Tool result"), + followUp: true, + }); + copilotKitCore.addTool(tool); + + const initialMessage = createToolCallMessage("recursiveTool"); + const finalMessage = createAssistantMessage({ content: "Final response" }); + + const agent = new MockAgent({ newMessages: [initialMessage] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + if (callCount === 2) { + agent.setNewMessages([finalMessage]); + } + }; + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result.newMessages).toEqual([finalMessage]); + }); + + it("should handle multiple recursive follow-ups (chain)", async () => { + const tool1 = createTool({ + name: "chainTool1", + handler: vi.fn(async () => "Result 1"), + followUp: true, + }); + const tool2 = createTool({ + name: "chainTool2", + handler: vi.fn(async () => "Result 2"), + followUp: true, + }); + copilotKitCore.addTool(tool1); + copilotKitCore.addTool(tool2); + + const msg1 = createToolCallMessage("chainTool1"); + const msg2 = createToolCallMessage("chainTool2"); + const finalMsg = createAssistantMessage({ content: "Done" }); + + const agent = new MockAgent({ newMessages: [msg1] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + if (callCount === 2) { + agent.setNewMessages([msg2]); + } else if (callCount === 3) { + agent.setNewMessages([finalMsg]); + } + }; + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.runAgentCalls).toHaveLength(3); + expect(result.newMessages).toEqual([finalMsg]); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/core-full.test.ts b/src/v2.x/packages/core/src/__tests__/core-full.test.ts new file mode 100644 index 0000000000..6e04244bcc --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-full.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { CopilotKitCore } from "../core"; +import { FrontendTool } from "../types"; +import { + MockAgent, + createMessage, + createAssistantMessage, + createToolCallMessage, + createToolResultMessage, + createTool, + createMultipleToolCallsMessage, +} from "./test-utils"; + +describe("CopilotKitCore.runAgent - Full Test Suite", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Tests that should pass", () => { + it("TEST 1: should run agent without tools", async () => { + const messages = [createMessage({ content: "Hello" }), createAssistantMessage({ content: "Hi there!" })]; + const agent = new MockAgent({ newMessages: messages }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result.newMessages).toEqual(messages); + expect(agent.runAgentCalls).toHaveLength(1); + }); + + it("TEST 2: should execute tool with string result", async () => { + const toolName = "stringTool"; + const tool = createTool({ + name: toolName, + handler: vi.fn(async () => "String result"), + followUp: false, + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage(toolName, { input: "test" }); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(tool.handler).toHaveBeenCalledWith( + { input: "test" }, + expect.objectContaining({ + id: expect.any(String), + function: expect.objectContaining({ + name: toolName, + arguments: '{"input":"test"}', + }), + }), + ); + expect(agent.messages.some((m) => m.role === "tool")).toBe(true); + }); + + it("TEST 3: should skip tool when not found", async () => { + const message = createToolCallMessage("nonExistentTool"); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.messages.filter((m) => m.role === "tool")).toHaveLength(0); + }); + }); + + describe("Tests that might reveal problems", () => { + it("TEST 4: should handle follow-up with recursion", async () => { + console.log("TEST 4: Starting follow-up test"); + const tool = createTool({ + name: "followUpTool", + handler: vi.fn(async () => "Result"), + followUp: true, // This should trigger recursion + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage("followUpTool"); + const followUpMessage = createAssistantMessage({ content: "Follow-up response" }); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + console.log(`TEST 4: Call count: ${callCount}`); + if (callCount === 2) { + agent.setNewMessages([followUpMessage]); + } + }; + + try { + const result = await copilotKitCore.runAgent({ agent: agent as any }); + console.log(`TEST 4: Success - calls: ${agent.runAgentCalls.length}`); + expect(agent.runAgentCalls).toHaveLength(2); + expect(result.newMessages).toContain(followUpMessage); + } catch (error) { + console.log(`TEST 4: Error - ${error}`); + throw error; + } + }); + + it("TEST 5: should handle multiple tools with at least one follow-up", async () => { + console.log("TEST 5: Starting multiple tools test"); + const tool1 = createTool({ + name: "tool1", + handler: vi.fn(async () => "Result 1"), + followUp: false, + }); + const tool2 = createTool({ + name: "tool2", + handler: vi.fn(async () => "Result 2"), + followUp: true, // This one needs follow-up + }); + copilotKitCore.addTool(tool1); + copilotKitCore.addTool(tool2); + + const message = createMultipleToolCallsMessage([{ name: "tool1" }, { name: "tool2" }]); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + console.log(`TEST 5: Call count: ${callCount}`); + if (callCount === 2) { + agent.setNewMessages([]); + } + }; + + try { + await copilotKitCore.runAgent({ agent: agent as any }); + console.log(`TEST 5: Success - calls: ${agent.runAgentCalls.length}`); + expect(agent.runAgentCalls).toHaveLength(2); + } catch (error) { + console.log(`TEST 5: Error - ${error}`); + throw error; + } + }); + + it("TEST 6: should handle tool with undefined follow-up (defaults to true)", async () => { + console.log("TEST 6: Starting undefined follow-up test"); + const tool: FrontendTool = { + name: "defaultFollowUpTool", + handler: vi.fn(async () => "Result"), + // followUp is undefined - should default to true + }; + copilotKitCore.addTool(tool); + + const message = createToolCallMessage("defaultFollowUpTool"); + const followUpMessage = createAssistantMessage({ content: "Follow-up" }); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + console.log(`TEST 6: Call count: ${callCount}`); + if (callCount === 2) { + agent.setNewMessages([followUpMessage]); + } + }; + + try { + await copilotKitCore.runAgent({ agent: agent as any }); + console.log(`TEST 6: Success - calls: ${agent.runAgentCalls.length}`); + expect(agent.runAgentCalls).toHaveLength(2); + } catch (error) { + console.log(`TEST 6: Error - ${error}`); + throw error; + } + }); + + it("TEST 7: should handle invalid JSON in tool arguments", async () => { + console.log("TEST 7: Starting invalid JSON test"); + const toolName = "invalidJsonTool"; + const tool = createTool({ + name: toolName, + handler: vi.fn(async () => "Should not be called"), + }); + copilotKitCore.addTool(tool); + + const message = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "tool-call-1", + type: "function", + function: { + name: toolName, + arguments: "{ invalid json", + }, + }, + ], + }); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + try { + await copilotKitCore.runAgent({ agent: agent as any }); + console.log("TEST 7: ERROR - Should have thrown!"); + expect(true).toBe(false); // Should not reach here + } catch (error) { + console.log(`TEST 7: Success - caught error: ${error}`); + expect(tool.handler).not.toHaveBeenCalled(); + } + }); + + it("TEST 8: should handle empty string arguments", async () => { + console.log("TEST 8: Starting empty arguments test"); + const tool = createTool({ + name: "emptyArgsTool", + handler: vi.fn(async (args) => `Received: ${JSON.stringify(args)}`), + }); + copilotKitCore.addTool(tool); + + const message = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "empty-args-call", + type: "function", + function: { + name: "emptyArgsTool", + arguments: "", + }, + }, + ], + }); + + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + try { + await copilotKitCore.runAgent({ agent: agent as any }); + console.log("TEST 8: ERROR - Should have thrown on empty string!"); + expect(true).toBe(false); + } catch (error) { + console.log(`TEST 8: Success - caught error: ${error}`); + expect(tool.handler).not.toHaveBeenCalled(); + } + }); + + it("TEST 9: should handle chain of follow-ups", async () => { + console.log("TEST 9: Starting chain test"); + const tool1 = createTool({ + name: "chainTool1", + handler: vi.fn(async () => "Result 1"), + followUp: true, + }); + const tool2 = createTool({ + name: "chainTool2", + handler: vi.fn(async () => "Result 2"), + followUp: true, + }); + copilotKitCore.addTool(tool1); + copilotKitCore.addTool(tool2); + + const msg1 = createToolCallMessage("chainTool1"); + const msg2 = createToolCallMessage("chainTool2"); + const finalMsg = createAssistantMessage({ content: "Done" }); + + const agent = new MockAgent({ newMessages: [msg1] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + let callCount = 0; + agent.runAgentCallback = () => { + callCount++; + console.log(`TEST 9: Call count: ${callCount}`); + if (callCount === 2) { + agent.setNewMessages([msg2]); + } else if (callCount === 3) { + agent.setNewMessages([finalMsg]); + } + }; + + try { + const result = await copilotKitCore.runAgent({ agent: agent as any }); + console.log(`TEST 9: Success - calls: ${agent.runAgentCalls.length}`); + expect(agent.runAgentCalls).toHaveLength(3); + expect(result.newMessages).toEqual([finalMsg]); + } catch (error) { + console.log(`TEST 9: Error - ${error}`); + throw error; + } + }); + + it("TEST 10: should handle concurrent tool calls", async () => { + console.log("TEST 10: Starting concurrent tools test"); + const delays = [50, 30, 70]; + const tools = delays.map((delay, i) => + createTool({ + name: `concurrentTool${i}`, + handler: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, delay)); + return `Result ${i} after ${delay}ms`; + }), + followUp: false, + }), + ); + + tools.forEach((tool) => copilotKitCore.addTool(tool)); + + const message = createMultipleToolCallsMessage(delays.map((_, i) => ({ name: `concurrentTool${i}` }))); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const startTime = Date.now(); + try { + await copilotKitCore.runAgent({ agent: agent as any }); + const duration = Date.now() - startTime; + console.log(`TEST 10: Success - duration: ${duration}ms`); + + // Should execute sequentially + const expectedMinDuration = delays.reduce((a, b) => a + b, 0); + expect(duration).toBeGreaterThanOrEqual(expectedMinDuration - 10); + + tools.forEach((tool) => { + expect(tool.handler).toHaveBeenCalled(); + }); + } catch (error) { + console.log(`TEST 10: Error - ${error}`); + throw error; + } + }); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-headers.test.ts b/src/v2.x/packages/core/src/__tests__/core-headers.test.ts new file mode 100644 index 0000000000..3aecff9fc0 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-headers.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { HttpAgent } from "@ag-ui/client"; +import { waitForCondition } from "./test-utils"; + +describe("CopilotKitCore headers", () => { + const originalFetch = global.fetch; + const originalWindow = (global as any).window; + + beforeEach(() => { + vi.restoreAllMocks(); + // Mock window to simulate browser environment + (global as any).window = {}; + }); + + afterEach(() => { + if (originalFetch) { + global.fetch = originalFetch; + } else { + delete (global as typeof globalThis & { fetch?: typeof fetch }).fetch; + } + // Restore window + if (originalWindow === undefined) { + delete (global as any).window; + } else { + (global as any).window = originalWindow; + } + }); + + it("includes provided headers when fetching runtime info", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ version: "1.0.0", agents: {} }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const headers = { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const core = new CopilotKitCore({ + runtimeUrl: "https://runtime.example", + headers, + }); + + await waitForCondition(() => fetchMock.mock.calls.length >= 1); + + expect(fetchMock).toHaveBeenCalledWith( + "https://runtime.example/info", + expect.objectContaining({ + headers: expect.objectContaining(headers), + }) + ); + }); + + it("uses updated headers for subsequent runtime requests", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ version: "1.0.0", agents: {} }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const core = new CopilotKitCore({ + runtimeUrl: "https://runtime.example", + headers: { Authorization: "Bearer initial" }, + }); + + await waitForCondition(() => fetchMock.mock.calls.length >= 1); + + core.setHeaders({ Authorization: "Bearer updated", "X-Trace": "123" }); + core.setRuntimeUrl(undefined); + core.setRuntimeUrl("https://runtime.example"); + + await waitForCondition(() => fetchMock.mock.calls.length >= 2); + + const secondCall = fetchMock.mock.calls[1]; + expect(secondCall?.[1]?.headers).toMatchObject({ + Authorization: "Bearer updated", + "X-Trace": "123", + }); + }); + + it("passes configured headers to HttpAgent runs", async () => { + const recorded: Array> = []; + + class RecordingHttpAgent extends HttpAgent { + constructor() { + super({ url: "https://runtime.example" }); + } + + async connectAgent(...args: Parameters) { + recorded.push({ ...this.headers }); + return Promise.resolve({ newMessages: [] }) as ReturnType; + } + + async runAgent(...args: Parameters) { + recorded.push({ ...this.headers }); + return Promise.resolve({ newMessages: [] }) as ReturnType; + } + } + + const agent = new RecordingHttpAgent(); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + headers: { Authorization: "Bearer cfg", "X-Team": "angular" }, + agents__unsafe_dev_only: { default: agent }, + }); + + await agent.runAgent(); + await core.connectAgent({ agent }); + await core.runAgent({ agent }); + + expect(recorded).toHaveLength(3); + for (const headers of recorded) { + expect(headers).toMatchObject({ + Authorization: "Bearer cfg", + "X-Team": "angular", + }); + } + }); + + it("applies updated headers to existing HttpAgent instances", () => { + const agent = new HttpAgent({ url: "https://runtime.example" }); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + headers: { Authorization: "Bearer cfg" }, + agents__unsafe_dev_only: { default: agent }, + }); + + expect(agent.headers).toMatchObject({ + Authorization: "Bearer cfg", + }); + + core.setHeaders({ + Authorization: "Bearer updated", + "X-Trace": "123", + }); + + expect(agent.headers).toMatchObject({ + Authorization: "Bearer updated", + "X-Trace": "123", + }); + }); + + it("applies headers to agents provided via setAgents", () => { + const originalAgent = new HttpAgent({ url: "https://runtime.example/original" }); + const replacementAgent = new HttpAgent({ + url: "https://runtime.example/replacement", + }); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + headers: { Authorization: "Bearer cfg" }, + agents__unsafe_dev_only: { original: originalAgent }, + }); + + expect(originalAgent.headers).toMatchObject({ + Authorization: "Bearer cfg", + }); + + core.setAgents__unsafe_dev_only({ replacement: replacementAgent }); + + expect(replacementAgent.headers).toMatchObject({ + Authorization: "Bearer cfg", + }); + }); + + it("applies headers when agents are added dynamically", () => { + const core = new CopilotKitCore({ + runtimeUrl: undefined, + headers: { Authorization: "Bearer cfg" }, + }); + + const addedAgent = new HttpAgent({ url: "https://runtime.example/new" }); + + core.setAgents__unsafe_dev_only({ added: addedAgent }); + + expect(addedAgent.headers).toMatchObject({ + Authorization: "Bearer cfg", + }); + }); + + it("uses the latest headers when running HttpAgent instances", async () => { + const recorded: Array> = []; + + class RecordingHttpAgent extends HttpAgent { + constructor() { + super({ url: "https://runtime.example" }); + } + + async runAgent(...args: Parameters) { + recorded.push({ ...this.headers }); + return Promise.resolve({ newMessages: [] }) as ReturnType; + } + } + + const agent = new RecordingHttpAgent(); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + headers: { Authorization: "Bearer initial" }, + agents__unsafe_dev_only: { default: agent }, + }); + + await core.runAgent({ agent }); + + core.setHeaders({ Authorization: "Bearer updated", "X-Trace": "123" }); + + await core.runAgent({ agent }); + + expect(recorded).toHaveLength(2); + expect(recorded[0]).toMatchObject({ Authorization: "Bearer initial" }); + expect(recorded[1]).toMatchObject({ + Authorization: "Bearer updated", + "X-Trace": "123", + }); + }); + + it("applies headers to remote agents fetched from runtime info", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ + version: "1.0.0", + agents: { + remote: { + name: "Remote Agent", + className: "RemoteClass", + description: "Remote description", + }, + }, + }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const core = new CopilotKitCore({ + runtimeUrl: "https://runtime.example", + headers: { Authorization: "Bearer cfg", "X-Team": "angular" }, + }); + + await waitForCondition(() => core.getAgent("remote") !== undefined); + + const remoteAgent = core.getAgent("remote") as HttpAgent | undefined; + expect(remoteAgent).toBeDefined(); + expect(remoteAgent?.headers).toMatchObject({ + Authorization: "Bearer cfg", + "X-Team": "angular", + }); + + core.setHeaders({ Authorization: "Bearer updated" }); + + expect(remoteAgent?.headers).toMatchObject({ + Authorization: "Bearer updated", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://runtime.example/info", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer cfg", + "X-Team": "angular", + }), + }) + ); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-simple.test.ts b/src/v2.x/packages/core/src/__tests__/core-simple.test.ts new file mode 100644 index 0000000000..1bf0dd66bb --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-simple.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { MockAgent, createMessage, createAssistantMessage } from "./test-utils"; + +describe("CopilotKitCore.runAgent Simple", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + }); + + it("should run agent without tools", async () => { + const messages = [ + createMessage({ content: "Hello" }), + createAssistantMessage({ content: "Hi there!" }), + ]; + const agent = new MockAgent({ newMessages: messages }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = await copilotKitCore.runAgent({ agent: agent as any }); + + expect(result.newMessages).toEqual(messages); + expect(agent.runAgentCalls).toHaveLength(1); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/core-suggestions-e2e.test.ts b/src/v2.x/packages/core/src/__tests__/core-suggestions-e2e.test.ts new file mode 100644 index 0000000000..ee70c12cb6 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-suggestions-e2e.test.ts @@ -0,0 +1,1482 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { Suggestion } from "../types"; +import { MockAgent, createSuggestionsConfig, createMessage, createAssistantMessage } from "./test-utils"; + +describe("CopilotKitCore - Suggestions E2E", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + }); + + describe("Basic suggestion generation flow", () => { + it("should generate suggestions by calling copilotkitSuggest tool", async () => { + // Setup provider and consumer agents + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ + agentId: "consumer", + messages: [createMessage({ content: "User asked something" })], + }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + // Setup suggestions config + const config = createSuggestionsConfig({ + instructions: "Suggest helpful actions", + minSuggestions: 2, + maxSuggestions: 3, + consumerAgentId: "consumer", + }); + copilotKitCore.addSuggestionsConfig(config); + + // Mock the provider agent to return suggestion tool call + // Note: arguments should be an array of strings for streaming support + // Each string is a chunk that can be partial JSON + const suggestionToolCall = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "suggest-1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: [ + '{"suggestions":[', + '{"title":"Action 1","message":"Do action 1"}', + ',{"title":"Action 2","message":"Do action 2"}', + "]}", + ] as any, + }, + }, + ], + } as any); + + providerAgent.setNewMessages([suggestionToolCall]); + + // Track subscriber calls + const onSuggestionsChanged = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsChanged }); + + // Trigger suggestion generation + copilotKitCore.reloadSuggestions("consumer"); + + // Wait for suggestions to be generated + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + // Verify suggestions were generated + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions).toHaveLength(2); + expect(result.suggestions[0]).toEqual({ title: "Action 1", message: "Do action 1", isLoading: false }); + expect(result.suggestions[1]).toEqual({ title: "Action 2", message: "Do action 2", isLoading: false }); + + // Verify subscriber was notified + await vi.waitFor(() => { + expect(onSuggestionsChanged).toHaveBeenCalled(); + }); + }); + + it("should include instructions and constraints in suggestion prompt", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ + instructions: "Focus on data analysis tasks", + minSuggestions: 2, + maxSuggestions: 4, + }); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + // Check that the cloned agent received the correct prompt + await vi.waitFor(() => { + expect(providerAgent.addMessage).toHaveBeenCalled(); + }); + + const addMessageCalls = providerAgent.addMessage.mock.calls; + expect(addMessageCalls.length).toBeGreaterThan(0); + + const promptMessage = addMessageCalls[0][0]; + expect(promptMessage.role).toBe("user"); + expect(promptMessage.content).toContain("copilotkitSuggest"); + expect(promptMessage.content).toContain("at least 2"); + expect(promptMessage.content).toContain("at most 4"); + expect(promptMessage.content).toContain("Focus on data analysis tasks"); + }); + + it("should force toolChoice to copilotkitSuggest", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + // Verify runAgent was called with forced tool choice + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + + const runAgentCall = providerAgent.runAgentCalls[0]; + expect(runAgentCall.forwardedProps.toolChoice).toEqual({ + type: "function", + function: { name: "copilotkitSuggest" }, + }); + }); + }); + + describe("Agent ID filtering patterns", () => { + it("should apply suggestions to specific consumer agent ID", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const targetAgent = new MockAgent({ agentId: "target" }); + const otherAgent = new MockAgent({ agentId: "other" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "target", agent: targetAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "other", agent: otherAgent as any }); + + const config = createSuggestionsConfig({ + consumerAgentId: "target", + }); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + // Reload for target agent - should generate + copilotKitCore.reloadSuggestions("target"); + + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + + // Reload for other agent - should NOT generate + providerAgent.runAgentCalls = []; + copilotKitCore.reloadSuggestions("other"); + + // Give it a moment + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(providerAgent.runAgentCalls.length).toBe(0); + }); + + it("should apply suggestions to all agents when consumer ID is *", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const agent1 = new MockAgent({ agentId: "agent1" }); + const agent2 = new MockAgent({ agentId: "agent2" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent1 as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent2", agent: agent2 as any }); + + const config = createSuggestionsConfig({ + consumerAgentId: "*", + }); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + // Should generate for both agents + copilotKitCore.reloadSuggestions("agent1"); + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + + const callCountAfterFirst = providerAgent.runAgentCalls.length; + + copilotKitCore.reloadSuggestions("agent2"); + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(callCountAfterFirst); + }); + }); + + it("should apply suggestions when consumer ID is undefined", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ + consumerAgentId: undefined, + }); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + }); + }); + + describe("Streaming and partial JSON suggestions", () => { + it("should handle streaming suggestions with incomplete JSON", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Track subscriber calls to see streaming updates + const suggestionUpdates: Suggestion[][] = []; + copilotKitCore.subscribe({ + onSuggestionsChanged: ({ suggestions }) => { + suggestionUpdates.push([...suggestions]); + }, + }); + + // Simulate streaming tool call with partial JSON + const partialToolCall = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "stream-1", + type: "function", + function: { + name: "copilotkitSuggest", + // Incomplete JSON - missing closing bracket + arguments: ['{"suggestions":[{"title":"First","message":"First action"}'] as any, + }, + }, + ], + }); + + providerAgent.setNewMessages([partialToolCall]); + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(suggestionUpdates.length).toBeGreaterThanOrEqual(2); + }); + + // Find the update with suggestions (skip empty initial updates) + const streamingUpdate = suggestionUpdates.find(update => update.length > 0 && update[0].isLoading === true); + expect(streamingUpdate).toBeDefined(); + expect(streamingUpdate![0]).toMatchObject({ + title: "First", + message: "First action", + isLoading: true, + }); + + // After finalization, isLoading should be false + await vi.waitFor(() => { + const lastUpdate = suggestionUpdates[suggestionUpdates.length - 1]; + expect(lastUpdate.length).toBeGreaterThan(0); + expect(lastUpdate[0].isLoading).toBe(false); + }); + + const finalUpdate = suggestionUpdates[suggestionUpdates.length - 1]; + expect(finalUpdate[0]).toMatchObject({ + title: "First", + message: "First action", + isLoading: false, + }); + }); + + it("should update suggestions as more JSON chunks arrive", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + const suggestionUpdates: number[] = []; + copilotKitCore.subscribe({ + onSuggestionsChanged: ({ suggestions }) => { + suggestionUpdates.push(suggestions.length); + }, + }); + + // Simulate multiple chunks arriving + const streamingToolCall = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "stream-2", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: [ + '{"suggestions":[', + '{"title":"First","message":"msg1"},', + '{"title":"Second","message":"msg2"}', + "]}", + ] as any, + }, + }, + ], + }); + + providerAgent.setNewMessages([streamingToolCall]); + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions.length).toBe(2); + }); + + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions).toHaveLength(2); + expect(result.suggestions[0].title).toBe("First"); + expect(result.suggestions[1].title).toBe("Second"); + }); + }); + + describe("Multiple configs and concurrent generation", () => { + it("should generate suggestions for multiple configs", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + // Add two different suggestion configs + const config1 = createSuggestionsConfig({ + instructions: "Suggest actions", + }); + const config2 = createSuggestionsConfig({ + instructions: "Suggest questions", + }); + + copilotKitCore.addSuggestionsConfig(config1); + copilotKitCore.addSuggestionsConfig(config2); + + // Mock provider to return different suggestions + const toolCall1 = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "suggest-1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Action","message":"Do action"}]}'] as any, + }, + }, + ], + }); + + providerAgent.setNewMessages([toolCall1]); + + copilotKitCore.reloadSuggestions("consumer"); + + // Should have called runAgent twice (once per config) + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThanOrEqual(2); + }); + }); + + it("should handle concurrent suggestion generation for different agents", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const agent1 = new MockAgent({ agentId: "agent1" }); + const agent2 = new MockAgent({ agentId: "agent2" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent1 as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent2", agent: agent2 as any }); + + const config = createSuggestionsConfig({ + consumerAgentId: "*", + }); + copilotKitCore.addSuggestionsConfig(config); + + const toolCall = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "suggest-x", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test msg"}]}'] as any, + }, + }, + ], + }); + + providerAgent.setNewMessages([toolCall]); + + // Trigger both concurrently + copilotKitCore.reloadSuggestions("agent1"); + copilotKitCore.reloadSuggestions("agent2"); + + // Both should have suggestions + await vi.waitFor(() => { + const result1 = copilotKitCore.getSuggestions("agent1"); + const result2 = copilotKitCore.getSuggestions("agent2"); + expect(result1.suggestions.length).toBeGreaterThan(0); + expect(result2.suggestions.length).toBeGreaterThan(0); + }); + }); + + it("should generate independent suggestion sets for same agent", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config1 = createSuggestionsConfig({ instructions: "Type 1" }); + const config2 = createSuggestionsConfig({ instructions: "Type 2" }); + + copilotKitCore.addSuggestionsConfig(config1); + copilotKitCore.addSuggestionsConfig(config2); + + // Set messages that will be returned for both configs + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Suggestion 1","message":"msg1"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + // Wait for both configs to be called + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThanOrEqual(2); + }); + + // Both config invocations should have generated suggestions + // Since they share the same response, we should have at least 1 suggestion + // (Could be 2 if both succeeded, but timing might cause only one) + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("Context and properties forwarding", () => { + it("should forward context to suggestion provider agent", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + // Add context + const contextId = copilotKitCore.addContext({ + description: "User preferences", + value: { theme: "dark", language: "en" } as any, + }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + + const runAgentCall = providerAgent.runAgentCalls[0]; + expect(runAgentCall.context).toEqual([ + { description: "User preferences", value: { theme: "dark", language: "en" } }, + ]); + + // Cleanup + copilotKitCore.removeContext(contextId); + }); + + it("should forward properties to suggestion provider agent", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + const core = new CopilotKitCore({ + properties: { userId: "123", sessionId: "abc" }, + }); + + core.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + core.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + core.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + core.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + + const runAgentCall = providerAgent.runAgentCalls[0]; + expect(runAgentCall.forwardedProps).toMatchObject({ + userId: "123", + sessionId: "abc", + }); + }); + + it("should include available frontend tools in suggestion prompt", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + // Add some frontend tools + copilotKitCore.addTool({ + name: "searchTool", + description: "Search for information", + }); + copilotKitCore.addTool({ + name: "analyzeTool", + description: "Analyze data", + }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.addMessage).toHaveBeenCalled(); + }); + + const promptMessage = providerAgent.addMessage.mock.calls[0][0]; + expect(promptMessage.content).toContain("searchTool"); + expect(promptMessage.content).toContain("analyzeTool"); + }); + }); + + describe("Agent cloning", () => { + it("should clone provider agent for suggestion generation", async () => { + const providerAgent = new MockAgent({ + agentId: "default", + state: { original: true }, + }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.clone).toHaveBeenCalled(); + }); + }); + + it("should copy consumer agent messages to suggestion agent", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerMessages = [ + createMessage({ content: "User question" }), + createAssistantMessage({ content: "Assistant response" }), + ]; + const consumerAgent = new MockAgent({ + agentId: "consumer", + messages: consumerMessages, + }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.reloadSuggestions("consumer"); + + // The cloned agent should receive the consumer's message history + await vi.waitFor(() => { + expect(providerAgent.clone).toHaveBeenCalled(); + }); + + // Verify clone was called and messages would be copied + expect(providerAgent.clone).toHaveBeenCalled(); + }); + + it("should copy consumer agent state to suggestion agent", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ + agentId: "consumer", + state: { conversationContext: "important data", stepCount: 5 }, + }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.clone).toHaveBeenCalled(); + }); + + // Verify clone preserves state + expect(providerAgent.clone).toHaveBeenCalled(); + }); + + it("should assign unique agentId and threadId to suggestion agent", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.reloadSuggestions("consumer"); + + // The cloned agent should have a unique ID + await vi.waitFor(() => { + expect(providerAgent.clone).toHaveBeenCalled(); + }); + + // Each clone should get a unique suggestion ID + expect(providerAgent.clone).toHaveBeenCalled(); + }); + }); + + describe("Suggestion abortion and reload", () => { + it("should abort running suggestions when a user message is submitted (runAgent)", async () => { + const providerAgent = new MockAgent({ agentId: "default", runAgentDelay: 100 }); + const consumerAgent = new MockAgent({ + agentId: "consumer", + messages: [createMessage({ content: "Initial message" })], + }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ consumerAgentId: "consumer" }); + copilotKitCore.addSuggestionsConfig(config); + + // Begin generating suggestions and wait until the provider is cloned (run in progress) + copilotKitCore.reloadSuggestions("consumer"); + await vi.waitFor(() => { + expect(providerAgent.clone).toHaveBeenCalled(); + }); + + // Grab the cloned suggestion agent instance to assert abort + const clonedSuggestionAgent = (providerAgent.clone as any).mock.results[0]?.value; + expect(clonedSuggestionAgent).toBeDefined(); + + // Ensure loading state is on before user submits new message + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(true); + }); + + // Additionally assert abort happens before the user's run starts + consumerAgent.runAgentCallback = () => { + expect(clonedSuggestionAgent.abortRun).toHaveBeenCalled(); + }; + + // Simulate user submitting a new message which triggers runAgent (and should clear/abort suggestions immediately) + consumerAgent.addMessages([createMessage({ content: "User follow-up" })]); + await copilotKitCore.runAgent({ + agent: consumerAgent as any, + }); + + // The in-flight suggestion run should be aborted + expect(clonedSuggestionAgent.abortRun).toHaveBeenCalled(); + + // Suggestions should be cleared and isLoading turned off + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(false); + expect(result.suggestions).toEqual([]); + }); + }); + it("should abort running suggestions when clearSuggestions is called", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + // Start generation + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + + // Clear should abort + copilotKitCore.clearSuggestions("consumer"); + + // Verify suggestions are cleared + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + + it("should create fresh agents on subsequent reloadSuggestions", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + // First reload + copilotKitCore.reloadSuggestions("consumer"); + await vi.waitFor(() => { + expect(providerAgent.clone).toHaveBeenCalledTimes(1); + }); + + // Clear + copilotKitCore.clearSuggestions("consumer"); + + // Second reload - should clone again + copilotKitCore.reloadSuggestions("consumer"); + await vi.waitFor(() => { + expect(providerAgent.clone).toHaveBeenCalledTimes(2); + }); + }); + + it("should notify subscribers when suggestions are cleared", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const onSuggestionsChanged = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsChanged }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response to trigger completion + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThan(0); + }); + + // Clear and verify notification + copilotKitCore.clearSuggestions("consumer"); + + await vi.waitFor(() => { + const clearCall = onSuggestionsChanged.mock.calls.find( + (call) => call[0].suggestions.length === 0 && call[0].agentId === "consumer", + ); + expect(clearCall).toBeDefined(); + }); + }); + }); + + describe("Error handling during suggestion generation", () => { + it("should handle error when suggestion generation fails", async () => { + const providerAgent = new MockAgent({ + agentId: "default", + error: new Error("Generation failed"), + }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Should not throw, error should be caught internally + copilotKitCore.reloadSuggestions("consumer"); + + // Give it a moment + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Suggestions should be empty due to error + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + + it("should handle malformed suggestion tool call", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + // Return tool call without proper structure + const malformedToolCall = createAssistantMessage({ + content: "", + toolCalls: [ + { + id: "malformed", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: "not valid json", + }, + }, + ], + }); + + providerAgent.setNewMessages([malformedToolCall]); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.reloadSuggestions("consumer"); + + // Should not crash + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Suggestions should be empty or handle gracefully + const result = copilotKitCore.getSuggestions("consumer"); + expect(Array.isArray(result.suggestions)).toBe(true); + expect(typeof result.isLoading).toBe("boolean"); + }); + }); + + describe("Loading state E2E", () => { + it("should track isLoading state during suggestion generation lifecycle", async () => { + const providerAgent = new MockAgent({ agentId: "default", runAgentDelay: 50 }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + // Before triggering, isLoading should be false + let result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(false); + + // Trigger suggestions + copilotKitCore.reloadSuggestions("consumer"); + + // During generation, isLoading should be true + await vi.waitFor(() => { + result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(true); + }); + + // After generation completes, isLoading should be false + await vi.waitFor(() => { + result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(false); + }); + }); + + it("should emit loading start and end events in correct order", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const events: string[] = []; + copilotKitCore.subscribe({ + onSuggestionsStartedLoading: () => { + events.push("start"); + }, + onSuggestionsFinishedLoading: () => { + events.push("end"); + }, + onSuggestionsChanged: () => { + events.push("changed"); + }, + }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(events).toContain("start"); + expect(events).toContain("end"); + }); + + // Start should come before end + const startIndex = events.indexOf("start"); + const endIndex = events.lastIndexOf("end"); + expect(startIndex).toBeLessThan(endIndex); + }); + + it("should handle isLoading with multiple concurrent configs", async () => { + const providerAgent = new MockAgent({ agentId: "default", runAgentDelay: 50 }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config1 = createSuggestionsConfig({ instructions: "First" }); + const config2 = createSuggestionsConfig({ instructions: "Second" }); + copilotKitCore.addSuggestionsConfig(config1); + copilotKitCore.addSuggestionsConfig(config2); + + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + // During generation, isLoading should be true + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(true); + }); + + // After all complete, isLoading should be false + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(false); + }); + }); + + it("should set isLoading to false after errors", async () => { + const providerAgent = new MockAgent({ + agentId: "default", + error: new Error("Generation failed"), + }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.reloadSuggestions("consumer"); + + // After error, isLoading should be false + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.isLoading).toBe(false); + }); + }); + }); + + describe("SuggestionAvailability - Dynamic Suggestions", () => { + it("should show dynamic suggestions with 'before-first-message' only when messages are empty", async () => { + const providerAgent = new MockAgent({ agentId: "provider" }); + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "provider", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ + instructions: "Suggest actions", + providerAgentId: "provider", + consumerAgentId: "consumer", + available: "before-first-message", + }); + copilotKitCore.addSuggestionsConfig(config); + + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "tc-1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Start","message":"Get started"}]}'] as any, + }, + }, + ], + } as any), + ]); + + // Should show suggestions when messages are empty + copilotKitCore.reloadSuggestions("consumer"); + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions).toHaveLength(1); + expect(result1.suggestions[0]?.title).toBe("Start"); + + // Add a message and reload - should not show suggestions + consumerAgent.messages = [createMessage({ content: "First message" })]; + copilotKitCore.reloadSuggestions("consumer"); + + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions).toHaveLength(0); + }); + + it("should show dynamic suggestions with 'after-first-message' only when messages exist", async () => { + const providerAgent = new MockAgent({ agentId: "provider" }); + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "provider", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ + instructions: "Suggest next actions", + providerAgentId: "provider", + consumerAgentId: "consumer", + available: "after-first-message", + }); + copilotKitCore.addSuggestionsConfig(config); + + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "tc-2", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Continue","message":"Keep going"}]}'] as any, + }, + }, + ], + } as any), + ]); + + // Should not show suggestions when messages are empty + copilotKitCore.reloadSuggestions("consumer"); + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions).toHaveLength(0); + + // Add a message and reload - should show suggestions + consumerAgent.messages = [createMessage({ content: "First message" })]; + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions).toHaveLength(1); + expect(result2.suggestions[0]?.title).toBe("Continue"); + }); + + it("should show dynamic suggestions with 'always' regardless of message count", async () => { + const providerAgent = new MockAgent({ agentId: "provider" }); + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "provider", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ + instructions: "Always suggest", + providerAgentId: "provider", + consumerAgentId: "consumer", + available: "always", + }); + copilotKitCore.addSuggestionsConfig(config); + + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "tc-3", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Always","message":"Always available"}]}'] as any, + }, + }, + ], + } as any), + ]); + + // Should show when empty + copilotKitCore.reloadSuggestions("consumer"); + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions[0]?.title).toBe("Always"); + + // Should also show when messages exist + consumerAgent.messages = [createMessage({ content: "Message" })]; + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions[0]?.title).toBe("Always"); + }); + + it("should not show dynamic suggestions with 'disabled'", async () => { + const providerAgent = new MockAgent({ agentId: "provider" }); + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "provider", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ + instructions: "Should not appear", + providerAgentId: "provider", + consumerAgentId: "consumer", + available: "disabled", + }); + copilotKitCore.addSuggestionsConfig(config); + + // Should not show regardless of message count + copilotKitCore.reloadSuggestions("consumer"); + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions).toHaveLength(0); + + consumerAgent.messages = [createMessage({ content: "Message" })]; + copilotKitCore.reloadSuggestions("consumer"); + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions).toHaveLength(0); + }); + + }); + + describe("SuggestionAvailability - Static Suggestions", () => { + it("should show static suggestions with 'before-first-message' only when messages are empty", () => { + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + copilotKitCore.addSuggestionsConfig({ + suggestions: [ + { title: "Start Here", message: "Begin your journey", isLoading: false }, + { title: "Learn More", message: "Get information", isLoading: false }, + ], + consumerAgentId: "consumer", + available: "before-first-message", + }); + + // Should show when empty + copilotKitCore.reloadSuggestions("consumer"); + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions).toHaveLength(2); + expect(result1.suggestions[0]?.title).toBe("Start Here"); + expect(result1.isLoading).toBe(false); + + // Should not show when messages exist + consumerAgent.messages = [createMessage({ content: "Message" })]; + copilotKitCore.reloadSuggestions("consumer"); + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions).toHaveLength(0); + }); + + it("should show static suggestions with 'after-first-message' only when messages exist", () => { + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + copilotKitCore.addSuggestionsConfig({ + suggestions: [{ title: "Next Step", message: "Continue", isLoading: false }], + consumerAgentId: "consumer", + available: "after-first-message", + }); + + // Should not show when empty + copilotKitCore.reloadSuggestions("consumer"); + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions).toHaveLength(0); + + // Should show when messages exist + consumerAgent.messages = [createMessage({ content: "Message" })]; + copilotKitCore.reloadSuggestions("consumer"); + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions).toHaveLength(1); + expect(result2.suggestions[0]?.title).toBe("Next Step"); + }); + + it("should show static suggestions with 'always' regardless of message count", () => { + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + copilotKitCore.addSuggestionsConfig({ + suggestions: [{ title: "Persistent", message: "Always here", isLoading: false }], + consumerAgentId: "consumer", + available: "always", + }); + + // Should show when empty + copilotKitCore.reloadSuggestions("consumer"); + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions).toHaveLength(1); + + // Should show when messages exist + consumerAgent.messages = [createMessage({ content: "Message" })]; + copilotKitCore.reloadSuggestions("consumer"); + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions).toHaveLength(1); + }); + + it("should not show static suggestions with 'disabled'", () => { + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + copilotKitCore.addSuggestionsConfig({ + suggestions: [{ title: "Hidden", message: "Should not appear", isLoading: false }], + consumerAgentId: "consumer", + available: "disabled", + }); + + copilotKitCore.reloadSuggestions("consumer"); + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions).toHaveLength(0); + }); + + it("should default to 'before-first-message' for static suggestions when availability not specified", () => { + const consumerAgent = new MockAgent({ agentId: "consumer", messages: [] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + copilotKitCore.addSuggestionsConfig({ + suggestions: [{ title: "Default Static", message: "Default behavior", isLoading: false }], + consumerAgentId: "consumer", + // No 'available' specified + }); + + // Should show when empty (default: before-first-message) + copilotKitCore.reloadSuggestions("consumer"); + const result1 = copilotKitCore.getSuggestions("consumer"); + expect(result1.suggestions).toHaveLength(1); + + // Should not show when messages exist + consumerAgent.messages = [createMessage({ content: "Message" })]; + copilotKitCore.reloadSuggestions("consumer"); + const result2 = copilotKitCore.getSuggestions("consumer"); + expect(result2.suggestions).toHaveLength(0); + }); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-suggestions.test.ts b/src/v2.x/packages/core/src/__tests__/core-suggestions.test.ts new file mode 100644 index 0000000000..d33307ab97 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-suggestions.test.ts @@ -0,0 +1,448 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { DynamicSuggestionsConfig, StaticSuggestionsConfig } from "../types"; +import { MockAgent, createSuggestionsConfig, createAssistantMessage } from "./test-utils"; + +describe("CopilotKitCore - Suggestions Config Management", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + }); + + describe("Adding suggestions configs", () => { + it("should add a dynamic suggestions config and return an ID", () => { + const config = createSuggestionsConfig({ + instructions: "Test instructions", + }); + + const id = copilotKitCore.addSuggestionsConfig(config); + + expect(id).toBeDefined(); + expect(typeof id).toBe("string"); + }); + + it("should notify subscribers when config is added", async () => { + const onConfigChanged = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsConfigChanged: onConfigChanged }); + + const config = createSuggestionsConfig(); + const id = copilotKitCore.addSuggestionsConfig(config); + + await vi.waitFor(() => { + expect(onConfigChanged).toHaveBeenCalledWith({ + copilotkit: copilotKitCore, + suggestionsConfig: expect.objectContaining({ + [id]: config, + }), + }); + }); + }); + + it("should add multiple configs independently", () => { + const config1 = createSuggestionsConfig({ instructions: "First" }); + const config2 = createSuggestionsConfig({ instructions: "Second" }); + + const id1 = copilotKitCore.addSuggestionsConfig(config1); + const id2 = copilotKitCore.addSuggestionsConfig(config2); + + expect(id1).not.toBe(id2); + }); + }); + + describe("Removing suggestions configs", () => { + it("should remove a suggestions config", async () => { + const config = createSuggestionsConfig(); + const id = copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.removeSuggestionsConfig(id); + + // Config should no longer trigger suggestions + const agent = new MockAgent({ agentId: "test" }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + copilotKitCore.reloadSuggestions("test"); + + const result = copilotKitCore.getSuggestions("test"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + + it("should notify subscribers when config is removed", async () => { + const config = createSuggestionsConfig(); + const id = copilotKitCore.addSuggestionsConfig(config); + + const onConfigChanged = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsConfigChanged: onConfigChanged }); + + copilotKitCore.removeSuggestionsConfig(id); + + await vi.waitFor(() => { + expect(onConfigChanged).toHaveBeenCalledWith({ + copilotkit: copilotKitCore, + suggestionsConfig: expect.not.objectContaining({ + [id]: expect.anything(), + }), + }); + }); + }); + }); + + describe("Getting suggestions", () => { + it("should return empty array when no suggestions exist", () => { + const result = copilotKitCore.getSuggestions("nonexistent"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + + it("should return empty array for agent without suggestions", () => { + const agent = new MockAgent({ agentId: "test" }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + const result = copilotKitCore.getSuggestions("test"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + }); + + describe("Clearing suggestions", () => { + it("should clear suggestions for an agent", () => { + const agent = new MockAgent({ agentId: "test" }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + copilotKitCore.clearSuggestions("test"); + + const result = copilotKitCore.getSuggestions("test"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + + it("should notify subscribers with empty suggestions array", async () => { + const onSuggestionsChanged = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsChanged }); + + copilotKitCore.clearSuggestions("test"); + + await vi.waitFor(() => { + expect(onSuggestionsChanged).toHaveBeenCalledWith({ + copilotkit: copilotKitCore, + agentId: "test", + suggestions: [], + }); + }); + }); + + it("should abort running suggestion agents", () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "test" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: consumerAgent as any }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Start suggestion generation + copilotKitCore.reloadSuggestions("test"); + + // Clear should abort the running agent + copilotKitCore.clearSuggestions("test"); + + // Verify abortRun was called on the cloned agent + // Note: We can't directly verify this without exposing internal state, + // but we can verify that subsequent getSuggestions returns empty + const result = copilotKitCore.getSuggestions("test"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + + it("should handle clearing suggestions when none are running", () => { + // Should not throw + expect(() => { + copilotKitCore.clearSuggestions("nonexistent"); + }).not.toThrow(); + }); + }); + + describe("Config type discrimination", () => { + it("should recognize dynamic suggestions config", () => { + const dynamicConfig: DynamicSuggestionsConfig = { + instructions: "Test", + minSuggestions: 1, + maxSuggestions: 3, + }; + + const id = copilotKitCore.addSuggestionsConfig(dynamicConfig); + expect(id).toBeDefined(); + }); + + it("should recognize static suggestions config", () => { + const staticConfig: StaticSuggestionsConfig = { + suggestions: [ + { title: "Test 1", message: "test1", isLoading: false }, + { title: "Test 2", message: "test2", isLoading: false }, + ], + }; + + const id = copilotKitCore.addSuggestionsConfig(staticConfig); + expect(id).toBeDefined(); + }); + }); + + describe("Error handling", () => { + it("should handle error when provider agent not found", async () => { + const consumerAgent = new MockAgent({ agentId: "consumer" }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const config = createSuggestionsConfig({ + providerAgentId: "nonexistent", + consumerAgentId: "consumer", + }); + copilotKitCore.addSuggestionsConfig(config); + + // reloadSuggestions is fire-and-forget, so it doesn't throw + // The error is caught and logged inside generateSuggestions + expect(() => { + copilotKitCore.reloadSuggestions("consumer"); + }).not.toThrow(); + + // Give async operation time to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify no suggestions were generated + const result = copilotKitCore.getSuggestions("consumer"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + + it("should not generate suggestions when consumer agent not found", async () => { + const providerAgent = new MockAgent({ agentId: "provider" }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "provider", agent: providerAgent as any }); + + const config = createSuggestionsConfig({ + providerAgentId: "provider", + consumerAgentId: "nonexistent", + }); + copilotKitCore.addSuggestionsConfig(config); + + // Since generateSuggestions is fire-and-forget (void), errors are logged but not thrown + // The method should complete without throwing + expect(() => { + copilotKitCore.reloadSuggestions("nonexistent"); + }).not.toThrow(); + + // Give async operation time to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify no suggestions were generated + const result = copilotKitCore.getSuggestions("nonexistent"); + expect(result.suggestions).toEqual([]); + expect(result.isLoading).toBe(false); + }); + }); + + describe("Initialization with configs", () => { + it("should accept suggestions configs in constructor", () => { + const config1 = createSuggestionsConfig({ instructions: "First" }); + const config2 = createSuggestionsConfig({ instructions: "Second" }); + + const core = new CopilotKitCore({ + suggestionsConfig: [config1, config2], + }); + + expect(core).toBeDefined(); + }); + }); + + describe("Loading state", () => { + it("should return isLoading false when no suggestions are running", () => { + const result = copilotKitCore.getSuggestions("test"); + expect(result.isLoading).toBe(false); + }); + + it("should emit loading start event when suggestions generation begins", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const onLoadingStart = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsStartedLoading: onLoadingStart }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(onLoadingStart).toHaveBeenCalledWith({ + copilotkit: copilotKitCore, + agentId: "consumer", + }); + }); + }); + + it("should emit loading end event when all suggestions generation completes", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const onLoadingEnd = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsFinishedLoading: onLoadingEnd }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + // Mock a response + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(onLoadingEnd).toHaveBeenCalledWith({ + copilotkit: copilotKitCore, + agentId: "consumer", + }); + }); + }); + + it("should only emit loading start once for multiple configs", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const onLoadingStart = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsStartedLoading: onLoadingStart }); + + const config1 = createSuggestionsConfig({ instructions: "First" }); + const config2 = createSuggestionsConfig({ instructions: "Second" }); + copilotKitCore.addSuggestionsConfig(config1); + copilotKitCore.addSuggestionsConfig(config2); + + // Mock a response + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(providerAgent.runAgentCalls.length).toBeGreaterThanOrEqual(2); + }); + + // Should only emit once even with multiple configs + expect(onLoadingStart).toHaveBeenCalledTimes(1); + }); + + it("should only emit loading end once after all configs complete", async () => { + const providerAgent = new MockAgent({ agentId: "default" }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const onLoadingEnd = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsFinishedLoading: onLoadingEnd }); + + const config1 = createSuggestionsConfig({ instructions: "First" }); + const config2 = createSuggestionsConfig({ instructions: "Second" }); + copilotKitCore.addSuggestionsConfig(config1); + copilotKitCore.addSuggestionsConfig(config2); + + // Mock a response + providerAgent.setNewMessages([ + createAssistantMessage({ + toolCalls: [ + { + id: "s1", + type: "function", + function: { + name: "copilotkitSuggest", + arguments: ['{"suggestions":[{"title":"Test","message":"Test"}]}'] as any, + }, + }, + ], + }), + ]); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(onLoadingEnd).toHaveBeenCalled(); + }); + + // Should only emit once after all complete + expect(onLoadingEnd).toHaveBeenCalledTimes(1); + }); + + it("should emit loading end even when errors occur", async () => { + const providerAgent = new MockAgent({ + agentId: "default", + error: new Error("Generation failed"), + }); + const consumerAgent = new MockAgent({ agentId: "consumer" }); + + copilotKitCore.addAgent__unsafe_dev_only({ id: "default", agent: providerAgent as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "consumer", agent: consumerAgent as any }); + + const onLoadingEnd = vi.fn(); + copilotKitCore.subscribe({ onSuggestionsFinishedLoading: onLoadingEnd }); + + const config = createSuggestionsConfig(); + copilotKitCore.addSuggestionsConfig(config); + + copilotKitCore.reloadSuggestions("consumer"); + + await vi.waitFor(() => { + expect(onLoadingEnd).toHaveBeenCalledWith({ + copilotkit: copilotKitCore, + agentId: "consumer", + }); + }); + }); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-tool-minimal.test.ts b/src/v2.x/packages/core/src/__tests__/core-tool-minimal.test.ts new file mode 100644 index 0000000000..42ce797b9c --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-tool-minimal.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { MockAgent, createToolCallMessage, createTool } from "./test-utils"; + +describe("CopilotKitCore Tool Minimal", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + }); + + it("should execute tool with string result", async () => { + const toolName = "stringTool"; + const tool = createTool({ + name: toolName, + handler: vi.fn(async () => "String result"), + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage(toolName, { input: "test" }); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(tool.handler).toHaveBeenCalledWith( + { input: "test" }, + expect.objectContaining({ + id: expect.any(String), + function: expect.objectContaining({ + name: toolName, + arguments: '{"input":"test"}', + }), + }), + ); + expect(agent.messages.some((m) => m.role === "tool")).toBe(true); + }); + + it("should skip tool call when tool not found", async () => { + const message = createToolCallMessage("nonExistentTool"); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + await copilotKitCore.runAgent({ agent: agent as any }); + + expect(agent.messages.filter((m) => m.role === "tool")).toHaveLength(0); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-tool-simple.test.ts b/src/v2.x/packages/core/src/__tests__/core-tool-simple.test.ts new file mode 100644 index 0000000000..beb114289d --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-tool-simple.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { MockAgent, createToolCallMessage, createTool } from "./test-utils"; + +describe("CopilotKitCore Tool Simple", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + }); + + it("should execute a simple tool", async () => { + console.log("Starting simple tool test"); + + const toolName = "simpleTool"; + const tool = createTool({ + name: toolName, + handler: vi.fn(async () => { + console.log("Tool handler called"); + return "Simple result"; + }), + followUp: false, // Important: no follow-up to avoid recursion + }); + copilotKitCore.addTool(tool); + + const message = createToolCallMessage(toolName, { input: "test" }); + const agent = new MockAgent({ newMessages: [message] }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + console.log("About to run agent"); + await copilotKitCore.runAgent({ agent: agent as any }); + console.log("Agent run complete"); + + expect(tool.handler).toHaveBeenCalledWith( + { input: "test" }, + expect.objectContaining({ + id: expect.any(String), + function: expect.objectContaining({ + name: toolName, + arguments: '{"input":"test"}', + }), + }), + ); + expect(agent.messages.length).toBeGreaterThan(0); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/core-wildcard.test.ts b/src/v2.x/packages/core/src/__tests__/core-wildcard.test.ts new file mode 100644 index 0000000000..a410abd011 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/core-wildcard.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CopilotKitCore } from "../core"; +import { FrontendTool } from "../types"; + +describe("CopilotKitCore - Wildcard Tool Simple", () => { + it("should add wildcard tool", () => { + const core = new CopilotKitCore({ + headers: {}, + properties: {}, + }); + + const wildcardTool: FrontendTool = { + name: "*", + handler: vi.fn(), + }; + + core.addTool(wildcardTool); + const retrievedTool = core.getTool({ toolName: "*" }); + expect(retrievedTool).toBeDefined(); + expect(retrievedTool?.name).toBe("*"); + }); + + it("should not interfere with specific tools", () => { + const core = new CopilotKitCore({ + headers: {}, + properties: {}, + }); + + const specificTool: FrontendTool = { + name: "specific", + handler: vi.fn(), + }; + + const wildcardTool: FrontendTool = { + name: "*", + handler: vi.fn(), + }; + + core.addTool(specificTool); + core.addTool(wildcardTool); + + expect(core.getTool({ toolName: "specific" })).toBeDefined(); + expect(core.getTool({ toolName: "*" })).toBeDefined(); + expect(core.tools.length).toBe(2); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/import.test.ts b/src/v2.x/packages/core/src/__tests__/import.test.ts new file mode 100644 index 0000000000..7aeba17509 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/import.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { Message } from "@ag-ui/client"; + +describe("Import Test", () => { + it("should import Message type", () => { + const msg: Message = { + id: "test", + role: "user", + content: "test", + }; + expect(msg.id).toBe("test"); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/proxied-runtime-transport.test.ts b/src/v2.x/packages/core/src/__tests__/proxied-runtime-transport.test.ts new file mode 100644 index 0000000000..4b242ed855 --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/proxied-runtime-transport.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { ProxiedCopilotRuntimeAgent } from "../agent"; +import { CopilotKitCore } from "../core"; +import { createSuggestionsConfig, MockAgent } from "./test-utils"; + +type TransportMatrixEntry = { + label: string; + runtimeUrl: string; + transport: "rest" | "single"; +}; + +const TEST_MATRIX: TransportMatrixEntry[] = [ + { + label: "Hono REST transport", + runtimeUrl: "https://runtime.example/hono", + transport: "rest", + }, + { + label: "Hono single-route transport", + runtimeUrl: "https://runtime.example/hono-rpc", + transport: "single", + }, + { + label: "Express REST transport", + runtimeUrl: "https://runtime.example/express", + transport: "rest", + }, + { + label: "Express single-route transport", + runtimeUrl: "https://runtime.example/express-rpc", + transport: "single", + }, +]; + +const encoder = new TextEncoder(); + +describe("ProxiedCopilotRuntimeAgent transport integration", () => { + const originalFetch = global.fetch; + + TEST_MATRIX.forEach(({ label, runtimeUrl, transport }) => { + describe(label, () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + // @ts-expect-error - Node typings allow reassigning fetch in tests + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + }); + + it("sends run requests with the expected payload", async () => { + const agentId = "workflow-agent"; + const agent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl, + agentId, + headers: { Authorization: "Bearer test-token" }, + transport, + }); + + fetchMock.mockResolvedValueOnce(createSseResponse()); + + await expect( + agent.runAgent({ + forwardedProps: { foo: "bar" }, + }), + ).resolves.toMatchObject({ + newMessages: expect.any(Array), + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + if (transport === "rest") { + expect(url).toBe(`${runtimeUrl}/agent/${encodeURIComponent(agentId)}/run`); + } else { + expect(url).toBe(runtimeUrl); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + method: "agent/run", + params: { + agentId, + }, + }); + } + + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers as HeadersInit); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("accept")).toBe("text/event-stream"); + }); + + it("sends connect requests with the expected payload", async () => { + const agentId = "connect-agent"; + const agent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl, + agentId, + headers: { Authorization: "Bearer test-token" }, + transport, + }); + + fetchMock.mockResolvedValueOnce(createSseResponse()); + + await expect(agent.connectAgent({})).resolves.toMatchObject({ + newMessages: expect.any(Array), + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + if (transport === "rest") { + expect(url).toBe(`${runtimeUrl}/agent/${encodeURIComponent(agentId)}/connect`); + } else { + expect(url).toBe(runtimeUrl); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + method: "agent/connect", + params: { + agentId, + }, + }); + } + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers as HeadersInit); + expect(headers.get("accept")).toBe("text/event-stream"); + }); + + it("sends stop requests with the expected payload", () => { + const agentId = "stop-agent"; + const threadId = "thread-123"; + const agent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl, + agentId, + headers: { Authorization: "Bearer test-token" }, + transport, + }); + + agent.threadId = threadId; + fetchMock.mockResolvedValueOnce( + new Response(null, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + agent.abortRun(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + if (transport === "rest") { + expect(url).toBe( + `${runtimeUrl}/agent/${encodeURIComponent(agentId)}/stop/${encodeURIComponent(threadId)}`, + ); + } else { + expect(url).toBe(runtimeUrl); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + method: "agent/stop", + params: { + agentId, + threadId, + }, + }); + } + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers as HeadersInit); + expect(headers.get("content-type")).toBe("application/json"); + }); + }); + }); +}); + +describe("ProxiedCopilotRuntimeAgent cloning", () => { + const originalFetch = global.fetch; + const runtimeUrl = "https://runtime.example/single"; + + beforeEach(() => { + // @ts-expect-error - Node typings allow reassigning fetch in tests + global.fetch = vi.fn(() => Promise.resolve(createSseResponse())); + }); + + afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + }); + + it("preserves single-endpoint envelope on cloned agents", async () => { + const agentId = "clone-agent"; + const agent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl, + agentId, + transport: "single", + }); + + const cloned = agent.clone(); + await expect(cloned.runAgent({})).resolves.toMatchObject({ + newMessages: expect.any(Array), + }); + + const fetchMock = global.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(runtimeUrl); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + method: "agent/run", + params: { + agentId, + }, + }); + }); +}); + +describe("Suggestions engine with single-endpoint runtime agents", () => { + const originalFetch = global.fetch; + const runtimeUrl = "https://runtime.example/single"; + + beforeEach(() => { + // @ts-expect-error - Node typings allow reassigning fetch in tests + global.fetch = vi.fn(() => Promise.resolve(createSseResponse())); + }); + + afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + }); + + it("envelopes suggestion runs with method and params when cloning runtime agents", async () => { + const providerAgent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl, + agentId: "provider", + transport: "single", + }); + const consumerAgent = new MockAgent({ agentId: "consumer" }) as unknown as any; + + const core = new CopilotKitCore({ + runtimeUrl, + runtimeTransport: "single", + agents__unsafe_dev_only: { + provider: providerAgent, + consumer: consumerAgent, + }, + }); + + core.addSuggestionsConfig( + createSuggestionsConfig({ + providerAgentId: "provider", + consumerAgentId: "consumer", + }), + ); + + core.reloadSuggestions("consumer"); + + const fetchMock = global.fetch as ReturnType; + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(runtimeUrl); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + method: "agent/run", + params: { + agentId: expect.any(String), + }, + }); + }); +}); + +function createSseResponse(): Response { + const stream = new ReadableStream({ + start(controller) { + const events = [ + { + type: "RUN_STARTED", + threadId: "test-thread", + runId: "test-run", + }, + { + type: "RUN_FINISHED", + threadId: "test-thread", + runId: "test-run", + result: { newMessages: [] }, + }, + ]; + const payload = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""); + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +describe("AgentRegistry runtime info requests", () => { + const originalFetch = global.fetch; + const originalWindow = (globalThis as { window?: unknown }).window; + + beforeEach(() => { + // Simulate browser environment to allow updateRuntimeConnection to proceed. + (globalThis as { window?: unknown }).window = {}; + }); + + afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = originalWindow; + } + }); + + const infoResponse = { + version: "1.0.0", + agents: { + remote: { + description: "Remote agent", + }, + }, + }; + + const matrix: TransportMatrixEntry[] = [ + { + label: "REST runtime info", + runtimeUrl: "https://runtime.example/rest", + transport: "rest", + }, + { + label: "Single-route runtime info", + runtimeUrl: "https://runtime.example/single", + transport: "single", + }, + ]; + + matrix.forEach(({ label, runtimeUrl, transport }) => { + it(`fetches runtime info correctly for ${label}`, async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const core = new CopilotKitCore({ + runtimeUrl, + runtimeTransport: transport, + headers: { Authorization: "Bearer token" }, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + if (transport === "rest") { + expect(url).toBe(`${runtimeUrl}/info`); + expect(init.method ?? "GET").toBe("GET"); + } else { + expect(url).toBe(runtimeUrl); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ method: "info" }); + } + + const headers = new Headers(init.headers as HeadersInit); + expect(headers.get("Authorization")).toBe("Bearer token"); + + // Ensure remote agent was registered using the chosen transport. + const remoteAgent = core.getAgent("remote"); + expect(remoteAgent).toBeDefined(); + expect(remoteAgent?.agentId).toBe("remote"); + }); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/simple.test.ts b/src/v2.x/packages/core/src/__tests__/simple.test.ts new file mode 100644 index 0000000000..bb3ee7f98a --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/simple.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from "vitest"; + +describe("Simple Test", () => { + it("should pass", () => { + expect(1 + 1).toBe(2); + }); +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/src/__tests__/state-manager.test.ts b/src/v2.x/packages/core/src/__tests__/state-manager.test.ts new file mode 100644 index 0000000000..c1aa3cf4fc --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/state-manager.test.ts @@ -0,0 +1,779 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { AbstractAgent, Message, State, RunAgentInput, EventType } from "@ag-ui/client"; +import { randomUUID } from "@copilotkitnext/shared"; + +/** + * Mock agent that can emit events to test state management + */ +class EventEmittingMockAgent extends AbstractAgent { + private subscribers: any[] = []; + + constructor(agentId: string, threadId: string, initialState: State = {}) { + super({ + agentId, + threadId, + initialState, + }); + } + + protected run(input: RunAgentInput): any { + // Not used in these tests + throw new Error("run() should not be called in these tests"); + } + + // Expose subscribe for testing + public testSubscribe(subscriber: any) { + return this.subscribe(subscriber); + } + + // Helper to emit run started event + public async emitRunStarted(runId: string, state: State = {}) { + this.state = state; + for (const sub of this.subscribers) { + if (sub.onRunStartedEvent) { + await sub.onRunStartedEvent({ + event: { + type: EventType.RUN_STARTED, + threadId: this.threadId, + runId, + }, + messages: this.messages, + state: this.state, + agent: this, + input: this.createRunInput(runId), + }); + } + } + } + + // Helper to emit run finished event + public async emitRunFinished(runId: string, state: State = {}) { + this.state = state; + for (const sub of this.subscribers) { + if (sub.onRunFinishedEvent) { + await sub.onRunFinishedEvent({ + event: { + type: EventType.RUN_FINISHED, + threadId: this.threadId, + runId, + }, + messages: this.messages, + state: this.state, + agent: this, + input: this.createRunInput(runId), + }); + } + } + } + + // Helper to emit state snapshot event + public async emitStateSnapshot(runId: string, snapshot: State) { + for (const sub of this.subscribers) { + if (sub.onStateSnapshotEvent) { + await sub.onStateSnapshotEvent({ + event: { + type: EventType.STATE_SNAPSHOT, + snapshot, + }, + messages: this.messages, + state: this.state, + agent: this, + input: this.createRunInput(runId), + }); + } + } + } + + // Helper to emit state delta event + public async emitStateDelta(runId: string, delta: any[], currentState: State) { + this.state = currentState; + for (const sub of this.subscribers) { + if (sub.onStateDeltaEvent) { + await sub.onStateDeltaEvent({ + event: { + type: EventType.STATE_DELTA, + delta, + }, + messages: this.messages, + state: this.state, + agent: this, + input: this.createRunInput(runId), + }); + } + } + } + + // Helper to emit messages snapshot event + public async emitMessagesSnapshot(runId: string, messages: Message[]) { + for (const sub of this.subscribers) { + if (sub.onMessagesSnapshotEvent) { + await sub.onMessagesSnapshotEvent({ + event: { + type: EventType.MESSAGES_SNAPSHOT, + messages, + }, + messages: this.messages, + state: this.state, + agent: this, + input: this.createRunInput(runId), + }); + } + } + } + + // Helper to emit new message event + public async emitNewMessage(runId: string, message: Message) { + this.messages.push(message); + for (const sub of this.subscribers) { + if (sub.onNewMessage) { + await sub.onNewMessage({ + message, + messages: this.messages, + state: this.state, + agent: this, + input: this.createRunInput(runId), + }); + } + } + } + + // Override subscribe to track subscribers + public override subscribe(subscriber: any) { + this.subscribers.push(subscriber); + return { + unsubscribe: () => { + const index = this.subscribers.indexOf(subscriber); + if (index > -1) { + this.subscribers.splice(index, 1); + } + }, + }; + } + + private createRunInput(runId: string): RunAgentInput { + return { + threadId: this.threadId, + runId, + state: this.state, + messages: this.messages, + }; + } +} + +describe("StateManager - Basic State Tracking", () => { + let copilotKitCore: CopilotKitCore; + let agent: EventEmittingMockAgent; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + agent = new EventEmittingMockAgent("agent1", "thread1", { count: 0 }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent as any }); + }); + + it("should track state when run starts", async () => { + const runId = "run1"; + const state = { count: 1, user: "alice" }; + + await agent.emitRunStarted(runId, state); + + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", runId); + expect(storedState).toEqual(state); + }); + + it("should track state when run finishes", async () => { + const runId = "run1"; + const finalState = { count: 5, user: "bob", completed: true }; + + await agent.emitRunStarted(runId, { count: 1 }); + await agent.emitRunFinished(runId, finalState); + + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", runId); + expect(storedState).toEqual(finalState); + }); + + it("should track state snapshots during run", async () => { + const runId = "run1"; + const initialState = { count: 0 }; + const snapshot = { count: 3, intermediate: true }; + + await agent.emitRunStarted(runId, initialState); + await agent.emitStateSnapshot(runId, snapshot); + + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", runId); + // State should be merged with snapshot + expect(storedState).toEqual({ count: 3, intermediate: true }); + }); + + it("should track state deltas during run", async () => { + const runId = "run1"; + const initialState = { count: 0, user: "alice" }; + const deltaState = { count: 2, user: "alice" }; + + await agent.emitRunStarted(runId, initialState); + await agent.emitStateDelta(runId, [{ op: "replace", path: "/count", value: 2 }], deltaState); + + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", runId); + expect(storedState).toEqual(deltaState); + }); + + it("should return undefined for non-existent run", () => { + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", "non-existent-run"); + expect(storedState).toBeUndefined(); + }); + + it("should return undefined for non-existent agent", () => { + const storedState = copilotKitCore.getStateByRun("non-existent-agent", "thread1", "run1"); + expect(storedState).toBeUndefined(); + }); + + it("should return undefined for non-existent thread", () => { + const storedState = copilotKitCore.getStateByRun("agent1", "non-existent-thread", "run1"); + expect(storedState).toBeUndefined(); + }); +}); + +describe("StateManager - Multiple Runs", () => { + let copilotKitCore: CopilotKitCore; + let agent: EventEmittingMockAgent; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + agent = new EventEmittingMockAgent("agent1", "thread1"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent as any }); + }); + + it("should track multiple sequential runs independently", async () => { + const run1State = { count: 1, step: "first" }; + const run2State = { count: 2, step: "second" }; + const run3State = { count: 3, step: "third" }; + + await agent.emitRunStarted("run1", run1State); + await agent.emitRunFinished("run1", run1State); + + await agent.emitRunStarted("run2", run2State); + await agent.emitRunFinished("run2", run2State); + + await agent.emitRunStarted("run3", run3State); + await agent.emitRunFinished("run3", run3State); + + expect(copilotKitCore.getStateByRun("agent1", "thread1", "run1")).toEqual(run1State); + expect(copilotKitCore.getStateByRun("agent1", "thread1", "run2")).toEqual(run2State); + expect(copilotKitCore.getStateByRun("agent1", "thread1", "run3")).toEqual(run3State); + }); + + it("should list all run IDs for a thread", async () => { + await agent.emitRunStarted("run1", { count: 1 }); + await agent.emitRunFinished("run1", { count: 1 }); + + await agent.emitRunStarted("run2", { count: 2 }); + await agent.emitRunFinished("run2", { count: 2 }); + + await agent.emitRunStarted("run3", { count: 3 }); + await agent.emitRunFinished("run3", { count: 3 }); + + const runIds = copilotKitCore.getRunIdsForThread("agent1", "thread1"); + expect(runIds).toHaveLength(3); + expect(runIds).toContain("run1"); + expect(runIds).toContain("run2"); + expect(runIds).toContain("run3"); + }); + + it("should return empty array for thread with no runs", () => { + const runIds = copilotKitCore.getRunIdsForThread("agent1", "thread-no-runs"); + expect(runIds).toEqual([]); + }); + + it("should handle state updates in the middle of a run", async () => { + const runId = "run1"; + + await agent.emitRunStarted(runId, { count: 0, status: "started" }); + + // Simulate state changes during the run + await agent.emitStateSnapshot(runId, { count: 1, status: "processing" }); + expect(copilotKitCore.getStateByRun("agent1", "thread1", runId)).toMatchObject({ + count: 1, + status: "processing", + }); + + await agent.emitStateDelta(runId, [], { count: 2, status: "processing" }); + expect(copilotKitCore.getStateByRun("agent1", "thread1", runId)).toMatchObject({ + count: 2, + status: "processing", + }); + + await agent.emitRunFinished(runId, { count: 3, status: "completed" }); + expect(copilotKitCore.getStateByRun("agent1", "thread1", runId)).toMatchObject({ + count: 3, + status: "completed", + }); + }); +}); + +describe("StateManager - Message Tracking", () => { + let copilotKitCore: CopilotKitCore; + let agent: EventEmittingMockAgent; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + agent = new EventEmittingMockAgent("agent1", "thread1"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent as any }); + }); + + it("should associate new messages with runs", async () => { + const runId = "run1"; + const message: Message = { + id: "msg1", + role: "user", + content: "Hello", + }; + + await agent.emitRunStarted(runId, {}); + await agent.emitNewMessage(runId, message); + await agent.emitRunFinished(runId, {}); + + const associatedRunId = copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg1"); + expect(associatedRunId).toBe(runId); + }); + + it("should associate messages from snapshot with runs", async () => { + const runId = "run1"; + const messages: Message[] = [ + { id: "msg1", role: "user", content: "Hello" }, + { id: "msg2", role: "assistant", content: "Hi there" }, + { id: "msg3", role: "user", content: "How are you?" }, + ]; + + await agent.emitRunStarted(runId, {}); + await agent.emitMessagesSnapshot(runId, messages); + await agent.emitRunFinished(runId, {}); + + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg1")).toBe(runId); + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg2")).toBe(runId); + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg3")).toBe(runId); + }); + + it("should track messages across multiple runs", async () => { + const msg1: Message = { id: "msg1", role: "user", content: "Run 1" }; + const msg2: Message = { id: "msg2", role: "user", content: "Run 2" }; + const msg3: Message = { id: "msg3", role: "user", content: "Run 3" }; + + await agent.emitRunStarted("run1", {}); + await agent.emitNewMessage("run1", msg1); + await agent.emitRunFinished("run1", {}); + + await agent.emitRunStarted("run2", {}); + await agent.emitNewMessage("run2", msg2); + await agent.emitRunFinished("run2", {}); + + await agent.emitRunStarted("run3", {}); + await agent.emitNewMessage("run3", msg3); + await agent.emitRunFinished("run3", {}); + + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg1")).toBe("run1"); + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg2")).toBe("run2"); + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg3")).toBe("run3"); + }); + + it("should return undefined for non-existent message", () => { + const runId = copilotKitCore.getRunIdForMessage("agent1", "thread1", "non-existent-msg"); + expect(runId).toBeUndefined(); + }); + + it("should handle messages with tool calls", async () => { + const runId = "run1"; + const message: Message = { + id: "msg1", + role: "assistant", + content: "", + toolCalls: [ + { + id: "tool1", + type: "function", + function: { + name: "searchWeb", + arguments: JSON.stringify({ query: "test" }), + }, + }, + ], + }; + + await agent.emitRunStarted(runId, {}); + await agent.emitNewMessage(runId, message); + await agent.emitRunFinished(runId, {}); + + const associatedRunId = copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg1"); + expect(associatedRunId).toBe(runId); + }); +}); + +describe("StateManager - Multiple Agents and Threads", () => { + let copilotKitCore: CopilotKitCore; + let agent1: EventEmittingMockAgent; + let agent2: EventEmittingMockAgent; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + agent1 = new EventEmittingMockAgent("agent1", "thread1"); + agent2 = new EventEmittingMockAgent("agent2", "thread2"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent1 as any }); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent2", agent: agent2 as any }); + }); + + it("should track state for multiple agents independently", async () => { + const agent1State = { agentName: "agent1", count: 1 }; + const agent2State = { agentName: "agent2", count: 2 }; + + await agent1.emitRunStarted("run1", agent1State); + await agent1.emitRunFinished("run1", agent1State); + + await agent2.emitRunStarted("run1", agent2State); + await agent2.emitRunFinished("run1", agent2State); + + expect(copilotKitCore.getStateByRun("agent1", "thread1", "run1")).toEqual(agent1State); + expect(copilotKitCore.getStateByRun("agent2", "thread2", "run1")).toEqual(agent2State); + }); + + it("should track messages for multiple agents independently", async () => { + const msg1: Message = { id: "msg1", role: "user", content: "Agent 1" }; + const msg2: Message = { id: "msg2", role: "user", content: "Agent 2" }; + + await agent1.emitRunStarted("run1", {}); + await agent1.emitNewMessage("run1", msg1); + await agent1.emitRunFinished("run1", {}); + + await agent2.emitRunStarted("run1", {}); + await agent2.emitNewMessage("run1", msg2); + await agent2.emitRunFinished("run1", {}); + + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg1")).toBe("run1"); + expect(copilotKitCore.getRunIdForMessage("agent2", "thread2", "msg2")).toBe("run1"); + // Cross-agent lookups should return undefined + expect(copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg2")).toBeUndefined(); + expect(copilotKitCore.getRunIdForMessage("agent2", "thread2", "msg1")).toBeUndefined(); + }); + + it("should handle same agent with multiple threads", async () => { + // Create first instance with thread-a + const agent3ThreadA = new EventEmittingMockAgent("agent3", "thread-a"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent3", agent: agent3ThreadA as any }); + + // Create second instance without agentId initially, then assign it after registration + const agent3ThreadB = new EventEmittingMockAgent("", "thread-b"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent3-threadb", agent: agent3ThreadB as any }); + + const threadAState = { thread: "a", count: 1 }; + const threadBState = { thread: "b", count: 2 }; + + await agent3ThreadA.emitRunStarted("run1", threadAState); + await agent3ThreadA.emitRunFinished("run1", threadAState); + + await agent3ThreadB.emitRunStarted("run1", threadBState); + await agent3ThreadB.emitRunFinished("run1", threadBState); + + expect(copilotKitCore.getStateByRun("agent3", "thread-a", "run1")).toEqual(threadAState); + expect(copilotKitCore.getStateByRun("agent3-threadb", "thread-b", "run1")).toEqual(threadBState); + }); +}); + +describe("StateManager - State Isolation", () => { + let copilotKitCore: CopilotKitCore; + let agent: EventEmittingMockAgent; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + agent = new EventEmittingMockAgent("agent1", "thread1"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent as any }); + }); + + it("should deep copy state to prevent external mutations", async () => { + const runId = "run1"; + const state = { nested: { count: 1 }, items: [1, 2, 3] }; + + await agent.emitRunStarted(runId, state); + + const retrievedState = copilotKitCore.getStateByRun("agent1", "thread1", runId) as any; + + // Mutate the retrieved state + retrievedState.nested.count = 999; + retrievedState.items.push(4); + + // Original stored state should be unchanged + const retrievedAgain = copilotKitCore.getStateByRun("agent1", "thread1", runId) as any; + expect(retrievedAgain.nested.count).toBe(1); + expect(retrievedAgain.items).toEqual([1, 2, 3]); + }); + + it("should handle complex nested state objects", async () => { + const runId = "run1"; + const complexState = { + user: { + id: "123", + profile: { + name: "Alice", + settings: { + theme: "dark", + notifications: true, + }, + }, + }, + conversations: [ + { id: "conv1", messages: 5 }, + { id: "conv2", messages: 3 }, + ], + metadata: { + timestamp: 1234567890, + version: "1.0", + }, + }; + + await agent.emitRunStarted(runId, complexState); + + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", runId); + expect(storedState).toEqual(complexState); + }); + + it("should handle state with null and undefined values", async () => { + const runId = "run1"; + const state = { + nullValue: null, + undefinedValue: undefined, + zeroValue: 0, + emptyString: "", + falseValue: false, + }; + + await agent.emitRunStarted(runId, state); + + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", runId) as any; + expect(storedState.nullValue).toBeNull(); + // Note: undefined may not survive JSON serialization + expect(storedState.zeroValue).toBe(0); + expect(storedState.emptyString).toBe(""); + expect(storedState.falseValue).toBe(false); + }); +}); + +describe("StateManager - Edge Cases", () => { + let copilotKitCore: CopilotKitCore; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + }); + + it("should handle agent without agentId gracefully", async () => { + const agent = new EventEmittingMockAgent("", "thread1"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "test", agent: agent as any }); + + // Agent will get assigned "test" as its agentId during registration + // So it should actually track the state under "test" + await agent.emitRunStarted("run1", { count: 1 }); + + // The state should be tracked under the assigned agentId "test" + const state = copilotKitCore.getStateByRun("test", "thread1", "run1"); + expect(state).toEqual({ count: 1 }); + }); + + it("should handle empty state object", async () => { + const agent = new EventEmittingMockAgent("agent1", "thread1"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent as any }); + + await agent.emitRunStarted("run1", {}); + await agent.emitRunFinished("run1", {}); + + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", "run1"); + expect(storedState).toEqual({}); + }); + + it("should handle rapid successive runs", async () => { + const agent = new EventEmittingMockAgent("agent1", "thread1"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent as any }); + + // Fire multiple runs rapidly + const promises = []; + for (let i = 0; i < 10; i++) { + const runId = `run${i}`; + const state = { count: i }; + promises.push( + agent.emitRunStarted(runId, state).then(() => agent.emitRunFinished(runId, state)) + ); + } + + await Promise.all(promises); + + // All runs should be tracked correctly + for (let i = 0; i < 10; i++) { + const storedState = copilotKitCore.getStateByRun("agent1", "thread1", `run${i}`); + expect(storedState).toEqual({ count: i }); + } + }); + + it("should handle messages without input parameter", async () => { + const agent = new EventEmittingMockAgent("agent1", "thread1"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "agent1", agent: agent as any }); + + const message: Message = { + id: "msg1", + role: "user", + content: "Test", + }; + + // Emit message without proper input (edge case) + for (const sub of (agent as any).subscribers) { + if (sub.onNewMessage) { + await sub.onNewMessage({ + message, + messages: agent.messages, + state: agent.state, + agent: agent, + // No input parameter + }); + } + } + + // Should not throw, but message won't be associated + const runId = copilotKitCore.getRunIdForMessage("agent1", "thread1", "msg1"); + expect(runId).toBeUndefined(); + }); +}); + +describe("StateManager - Real-world Scenarios", () => { + let copilotKitCore: CopilotKitCore; + let agent: EventEmittingMockAgent; + + beforeEach(() => { + copilotKitCore = new CopilotKitCore({}); + agent = new EventEmittingMockAgent("chatbot", "user-session-123"); + copilotKitCore.addAgent__unsafe_dev_only({ id: "chatbot", agent: agent as any }); + }); + + it("should track a complete conversation flow", async () => { + // First run - user asks a question + const run1Id = randomUUID(); + await agent.emitRunStarted(run1Id, { conversationContext: "greeting" }); + + const msg1: Message = { id: randomUUID(), role: "user", content: "Hello" }; + await agent.emitNewMessage(run1Id, msg1); + + const msg2: Message = { id: randomUUID(), role: "assistant", content: "Hi! How can I help?" }; + await agent.emitNewMessage(run1Id, msg2); + + await agent.emitRunFinished(run1Id, { conversationContext: "greeting", messageCount: 2 }); + + // Second run - user asks for help + const run2Id = randomUUID(); + await agent.emitRunStarted(run2Id, { conversationContext: "help_request", messageCount: 2 }); + + const msg3: Message = { id: randomUUID(), role: "user", content: "I need help with my order" }; + await agent.emitNewMessage(run2Id, msg3); + + await agent.emitStateSnapshot(run2Id, { conversationContext: "help_request", topic: "orders" }); + + const msg4: Message = { id: randomUUID(), role: "assistant", content: "I can help with that!" }; + await agent.emitNewMessage(run2Id, msg4); + + await agent.emitRunFinished(run2Id, { + conversationContext: "help_request", + topic: "orders", + messageCount: 4, + }); + + // Verify all messages are associated correctly + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", msg1.id)).toBe(run1Id); + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", msg2.id)).toBe(run1Id); + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", msg3.id)).toBe(run2Id); + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", msg4.id)).toBe(run2Id); + + // Verify states are tracked correctly + const run1State = copilotKitCore.getStateByRun("chatbot", "user-session-123", run1Id); + expect(run1State).toMatchObject({ conversationContext: "greeting", messageCount: 2 }); + + const run2State = copilotKitCore.getStateByRun("chatbot", "user-session-123", run2Id); + expect(run2State).toMatchObject({ + conversationContext: "help_request", + topic: "orders", + messageCount: 4, + }); + + // Verify we can list all runs + const runIds = copilotKitCore.getRunIdsForThread("chatbot", "user-session-123"); + expect(runIds).toHaveLength(2); + expect(runIds).toContain(run1Id); + expect(runIds).toContain(run2Id); + }); + + it("should handle tool execution flow with state changes", async () => { + const runId = randomUUID(); + + await agent.emitRunStarted(runId, { step: "initial", toolsExecuted: [] }); + + const userMsg: Message = { + id: randomUUID(), + role: "user", + content: "Search for cats", + }; + await agent.emitNewMessage(runId, userMsg); + + // Agent calls a tool + const toolCallMsg: Message = { + id: randomUUID(), + role: "assistant", + content: "", + toolCalls: [ + { + id: "tool-call-1", + type: "function", + function: { + name: "searchWeb", + arguments: JSON.stringify({ query: "cats" }), + }, + }, + ], + }; + await agent.emitNewMessage(runId, toolCallMsg); + + // Update state after tool call + await agent.emitStateDelta(runId, [], { + step: "tool_called", + toolsExecuted: ["searchWeb"], + lastToolCall: "tool-call-1", + }); + + // Tool result comes back + const toolResultMsg: Message = { + id: randomUUID(), + role: "tool", + content: "Found 10 results about cats", + toolCallId: "tool-call-1", + }; + await agent.emitNewMessage(runId, toolResultMsg); + + // Final response + const responseMsg: Message = { + id: randomUUID(), + role: "assistant", + content: "I found information about cats for you!", + }; + await agent.emitNewMessage(runId, responseMsg); + + await agent.emitRunFinished(runId, { + step: "completed", + toolsExecuted: ["searchWeb"], + totalMessages: 4, + }); + + // Verify all messages are associated + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", userMsg.id)).toBe(runId); + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", toolCallMsg.id)).toBe(runId); + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", toolResultMsg.id)).toBe(runId); + expect(copilotKitCore.getRunIdForMessage("chatbot", "user-session-123", responseMsg.id)).toBe(runId); + + // Verify final state + const finalState = copilotKitCore.getStateByRun("chatbot", "user-session-123", runId); + expect(finalState).toMatchObject({ + step: "completed", + toolsExecuted: ["searchWeb"], + totalMessages: 4, + }); + }); +}); diff --git a/src/v2.x/packages/core/src/__tests__/test-utils.ts b/src/v2.x/packages/core/src/__tests__/test-utils.ts new file mode 100644 index 0000000000..d313edae2c --- /dev/null +++ b/src/v2.x/packages/core/src/__tests__/test-utils.ts @@ -0,0 +1,260 @@ +import { Message } from "@ag-ui/client"; +import { vi } from "vitest"; +import { DynamicSuggestionsConfig, FrontendTool } from "../types"; + +export interface MockAgentOptions { + messages?: Message[]; + newMessages?: Message[]; + error?: Error | string; + runAgentDelay?: number; + runAgentCallback?: (input: any) => void; + agentId?: string; + threadId?: string; + state?: Record; +} + +export class MockAgent { + public messages: Message[] = []; + public state: Record = {}; + public agentId?: string; + public threadId?: string; + public addMessages = vi.fn((messages: Message[]) => { + this.messages.push(...messages); + }); + public addMessage = vi.fn((message: Message) => { + this.messages.push(message); + // Also track on parent if this is a clone + if (this._parentAgent) { + this._parentAgent.addMessage(message); + } + }); + public abortRun = vi.fn(); + public clone = vi.fn(() => this._cloneImpl()); + + private newMessages: Message[]; + private error?: Error | string; + private runAgentDelay: number; + public runAgentCallback?: (input: any) => void; + public runAgentCalls: any[] = []; + private _parentAgent?: MockAgent; + + constructor(options: MockAgentOptions = {}) { + this.messages = options.messages || []; + this.newMessages = options.newMessages || []; + this.error = options.error; + this.runAgentDelay = options.runAgentDelay || 0; + this.runAgentCallback = options.runAgentCallback; + this.agentId = options.agentId; + this.threadId = options.threadId; + this.state = options.state || {}; + } + + async runAgent(input: any, subscriber?: any): Promise<{ newMessages: Message[] }> { + this.runAgentCalls.push(input); + // Also track on parent if this is a clone + if (this._parentAgent) { + this._parentAgent.runAgentCalls.push(input); + } + + if (this.runAgentCallback) { + this.runAgentCallback(input); + } + + if (this.runAgentDelay > 0) { + await new Promise(resolve => setTimeout(resolve, this.runAgentDelay)); + } + + if (this.error) { + throw this.error; + } + + // If there's a subscriber with onMessagesChanged, call it with the messages + if (subscriber?.onMessagesChanged && this.newMessages.length > 0) { + // Trigger the subscriber callback with messages + subscriber.onMessagesChanged({ messages: [...this.messages, ...this.newMessages] }); + } + + return { newMessages: this.newMessages }; + } + + private _cloneImpl(): MockAgent { + const cloned = new MockAgent({ + messages: [...this.messages], + newMessages: [...this.newMessages], + error: this.error, + runAgentDelay: this.runAgentDelay, + runAgentCallback: this.runAgentCallback, + agentId: this.agentId, + threadId: this.threadId, + state: JSON.parse(JSON.stringify(this.state)), + }); + // Link the clone back to the parent so calls are visible + cloned._parentAgent = this; + return cloned; + } + + // Provide a no-op subscribe API so core can attach state listeners + // without errors during tests. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public subscribe(_subscriber?: any): { unsubscribe: () => void } { + return { unsubscribe: () => {} }; + } + + setNewMessages(messages: Message[]): void { + this.newMessages = messages; + } +} + +export function createMessage(overrides: Partial = {}): Message { + return { + id: `msg-${Math.random().toString(36).substr(2, 9)}`, + role: "user", + content: "Test message", + ...overrides, + } as Message; +} + +export function createAssistantMessage( + overrides: Partial = {} +): Message { + return createMessage({ + role: "assistant", + content: "Assistant message", + ...overrides, + }); +} + +export function createToolCallMessage( + toolCallName: string, + args: any = {}, + overrides: Partial = {} +): Message { + const toolCallId = `tool-call-${Math.random().toString(36).substr(2, 9)}`; + return createAssistantMessage({ + content: "", + toolCalls: [ + { + id: toolCallId, + type: "function", + function: { + name: toolCallName, + arguments: JSON.stringify(args), + }, + }, + ], + ...overrides, + }); +} + +export function createToolResultMessage( + toolCallId: string, + content: string, + overrides: Partial = {} +): Message { + return createMessage({ + role: "tool", + content, + toolCallId, + ...overrides, + }); +} + +export function createTool>( + overrides: Partial> = {} +): FrontendTool { + return { + name: `tool-${Math.random().toString(36).substr(2, 9)}`, + description: "Test tool", + handler: vi.fn(async () => "Tool result"), + followUp: false, // Default to false to avoid unexpected recursion in tests + ...overrides, + }; +} + +export function createMultipleToolCallsMessage( + toolCalls: Array<{ name: string; args?: any }>, + overrides: Partial = {} +): Message { + return createAssistantMessage({ + content: "", + toolCalls: toolCalls.map((tc) => ({ + id: `tool-call-${Math.random().toString(36).substr(2, 9)}`, + type: "function", + function: { + name: tc.name, + arguments: JSON.stringify(tc.args || {}), + }, + })), + ...overrides, + }); +} + +export async function waitForCondition( + condition: () => boolean, + timeout: number = 1000, + interval: number = 10 +): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeout) { + throw new Error("Timeout waiting for condition"); + } + await new Promise(resolve => setTimeout(resolve, interval)); + } +} + +/** + * Helper to create a dynamic suggestions config + */ +export function createSuggestionsConfig( + overrides: Partial = {} +): DynamicSuggestionsConfig { + return { + instructions: "Suggest helpful next actions", + minSuggestions: 1, + maxSuggestions: 3, + available: "always", + providerAgentId: "default", + consumerAgentId: "*", + ...overrides, + }; +} + +/** + * Helper to create a tool call message for copilotkitSuggest + */ +export function createSuggestionToolCall( + suggestions: Array<{ title: string; message: string }>, + overrides: Partial = {} +): Message { + const toolCallId = `suggest-call-${Math.random().toString(36).substr(2, 9)}`; + return createAssistantMessage({ + content: "", + toolCalls: [ + { + id: toolCallId, + type: "function", + function: { + name: "copilotkitSuggest", + arguments: JSON.stringify({ suggestions }), + }, + }, + ], + ...overrides, + }); +} + +/** + * Helper to create streaming suggestion messages with partial JSON + * Returns an array of JSON chunks that can be assembled into complete suggestions + */ +export function createStreamingSuggestionChunks(): string[] { + return [ + '{"suggestions":[', + '{"title":"First","message":"Do first thing"}', + ',{"title":"Second",', + '"message":"Do second thing"}', + ',{"title":"Third","message":"Do third thing"}', + ']}', + ]; +} diff --git a/src/v2.x/packages/core/src/agent.ts b/src/v2.x/packages/core/src/agent.ts new file mode 100644 index 0000000000..b5fb3eb81e --- /dev/null +++ b/src/v2.x/packages/core/src/agent.ts @@ -0,0 +1,175 @@ +import { + BaseEvent, + HttpAgent, + HttpAgentConfig, + RunAgentInput, + runHttpRequest, + transformHttpEventStream, +} from "@ag-ui/client"; +import { Observable } from "rxjs"; +import { CopilotRuntimeTransport } from "./types"; + +export interface ProxiedCopilotRuntimeAgentConfig extends Omit { + runtimeUrl?: string; + transport?: CopilotRuntimeTransport; +} + +export class ProxiedCopilotRuntimeAgent extends HttpAgent { + runtimeUrl?: string; + private transport: CopilotRuntimeTransport; + private singleEndpointUrl?: string; + + constructor(config: ProxiedCopilotRuntimeAgentConfig) { + const normalizedRuntimeUrl = config.runtimeUrl ? config.runtimeUrl.replace(/\/$/, "") : undefined; + const transport = config.transport ?? "rest"; + const runUrl = + transport === "single" + ? normalizedRuntimeUrl ?? config.runtimeUrl ?? "" + : `${normalizedRuntimeUrl ?? config.runtimeUrl}/agent/${encodeURIComponent(config.agentId ?? "")}/run`; + + if (!runUrl) { + throw new Error("ProxiedCopilotRuntimeAgent requires a runtimeUrl when transport is set to 'single'."); + } + + super({ + ...config, + url: runUrl, + }); + this.runtimeUrl = normalizedRuntimeUrl ?? config.runtimeUrl; + this.transport = transport; + if (this.transport === "single") { + this.singleEndpointUrl = this.runtimeUrl; + } + } + + abortRun(): void { + if (!this.agentId || !this.threadId) { + return; + } + + if (typeof fetch === "undefined") { + return; + } + + if (this.transport === "single") { + if (!this.singleEndpointUrl) { + return; + } + + const headers = new Headers({ ...this.headers, "Content-Type": "application/json" }); + void fetch(this.singleEndpointUrl, { + method: "POST", + headers, + body: JSON.stringify({ + method: "agent/stop", + params: { + agentId: this.agentId, + threadId: this.threadId, + }, + }), + }).catch((error) => { + console.error("ProxiedCopilotRuntimeAgent: stop request failed", error); + }); + return; + } + + if (!this.runtimeUrl) { + return; + } + + const stopPath = `${this.runtimeUrl}/agent/${encodeURIComponent(this.agentId)}/stop/${encodeURIComponent(this.threadId)}`; + const origin = typeof window !== "undefined" && window.location ? window.location.origin : "http://localhost"; + const base = new URL(this.runtimeUrl, origin); + const stopUrl = new URL(stopPath, base); + + void fetch(stopUrl.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.headers, + }, + }).catch((error) => { + console.error("ProxiedCopilotRuntimeAgent: stop request failed", error); + }); + } + + connect(input: RunAgentInput): Observable { + if (this.transport === "single") { + if (!this.singleEndpointUrl) { + throw new Error("Single endpoint transport requires a runtimeUrl"); + } + + const requestInit = this.createSingleRouteRequestInit(input, "agent/connect", { + agentId: this.agentId!, + }); + const httpEvents = runHttpRequest(this.singleEndpointUrl, requestInit); + return transformHttpEventStream(httpEvents); + } + + const httpEvents = runHttpRequest(`${this.runtimeUrl}/agent/${this.agentId}/connect`, this.requestInit(input)); + return transformHttpEventStream(httpEvents); + } + + public run(input: RunAgentInput): Observable { + if (this.transport === "single") { + if (!this.singleEndpointUrl) { + throw new Error("Single endpoint transport requires a runtimeUrl"); + } + + const requestInit = this.createSingleRouteRequestInit(input, "agent/run", { + agentId: this.agentId!, + }); + const httpEvents = runHttpRequest(this.singleEndpointUrl, requestInit); + return transformHttpEventStream(httpEvents); + } + + return super.run(input); + } + + public override clone(): ProxiedCopilotRuntimeAgent { + const cloned = super.clone() as ProxiedCopilotRuntimeAgent; + cloned.runtimeUrl = this.runtimeUrl; + cloned.transport = this.transport; + cloned.singleEndpointUrl = this.singleEndpointUrl; + return cloned; + } + + private createSingleRouteRequestInit(input: RunAgentInput, method: string, params?: Record): RequestInit { + if (!this.agentId) { + throw new Error("ProxiedCopilotRuntimeAgent requires agentId to make runtime requests"); + } + + const baseInit = super.requestInit(input); + const headers = new Headers(baseInit.headers ?? {}); + headers.set("Content-Type", "application/json"); + headers.set("Accept", headers.get("Accept") ?? "text/event-stream"); + + let originalBody: unknown = undefined; + if (typeof baseInit.body === "string") { + try { + originalBody = JSON.parse(baseInit.body); + } catch (error) { + console.warn("ProxiedCopilotRuntimeAgent: failed to parse request body for single route transport", error); + originalBody = undefined; + } + } + + const envelope: Record = { + method, + }; + + if (params && Object.keys(params).length > 0) { + envelope.params = params; + } + + if (originalBody !== undefined) { + envelope.body = originalBody; + } + + return { + ...baseInit, + headers, + body: JSON.stringify(envelope), + }; + } +} diff --git a/src/v2.x/packages/core/src/core/__tests__/run-handler-schema.test.ts b/src/v2.x/packages/core/src/core/__tests__/run-handler-schema.test.ts new file mode 100644 index 0000000000..788f8d9996 --- /dev/null +++ b/src/v2.x/packages/core/src/core/__tests__/run-handler-schema.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { RunHandler } from "../run-handler"; +import type { CopilotKitCore } from "../core"; + +function createRunHandler(): RunHandler { + return new RunHandler({} as CopilotKitCore); +} + +describe("RunHandler tool schema generation", () => { + it("strips boolean additionalProperties emitted by zod-to-json-schema", () => { + const runHandler = createRunHandler(); + runHandler.initialize([ + { + name: "sample", + parameters: z.object({ + foo: z.string(), + }), + }, + ]); + + const [tool] = runHandler.buildFrontendTools(); + expect(tool.parameters).toEqual({ + type: "object", + properties: { + foo: { type: "string" }, + }, + required: ["foo"], + }); + }); + + it("removes additionalProperties even for catchalls", () => { + const runHandler = createRunHandler(); + runHandler.initialize([ + { + name: "catchall", + parameters: z.object({}).catchall(z.string()), + }, + ]); + + const [tool] = runHandler.buildFrontendTools(); + expect(tool.parameters).not.toHaveProperty("additionalProperties"); + }); + + it("returns an empty schema for tools without parameters", () => { + const runHandler = createRunHandler(); + runHandler.initialize([ + { + name: "noSchema", + }, + ]); + + const [tool] = runHandler.buildFrontendTools(); + expect(tool.parameters).toEqual({ + type: "object", + properties: {}, + }); + }); +}); diff --git a/src/v2.x/packages/core/src/core/agent-registry.ts b/src/v2.x/packages/core/src/core/agent-registry.ts new file mode 100644 index 0000000000..2199a31f81 --- /dev/null +++ b/src/v2.x/packages/core/src/core/agent-registry.ts @@ -0,0 +1,323 @@ +import { AbstractAgent, HttpAgent } from "@ag-ui/client"; +import { logger, RuntimeInfo, AgentDescription } from "@copilotkitnext/shared"; +import { ProxiedCopilotRuntimeAgent } from "../agent"; +import type { CopilotKitCore } from "./core"; +import { CopilotKitCoreErrorCode, CopilotKitCoreRuntimeConnectionStatus, CopilotKitCoreFriendsAccess } from "./core"; +import { CopilotRuntimeTransport } from "../types"; + +export interface CopilotKitCoreAddAgentParams { + id: string; + agent: AbstractAgent; +} + +/** + * Manages agent registration, lifecycle, and runtime connectivity for CopilotKitCore. + * Handles both local development agents and remote runtime agents. + */ +export class AgentRegistry { + private _agents: Record = {}; + private localAgents: Record = {}; + private remoteAgents: Record = {}; + + private _runtimeUrl?: string; + private _runtimeVersion?: string; + private _runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus = + CopilotKitCoreRuntimeConnectionStatus.Disconnected; + private _runtimeTransport: CopilotRuntimeTransport = "rest"; + + constructor(private core: CopilotKitCore) {} + + /** + * Get all agents as a readonly record + */ + get agents(): Readonly> { + return this._agents; + } + + get runtimeUrl(): string | undefined { + return this._runtimeUrl; + } + + get runtimeVersion(): string | undefined { + return this._runtimeVersion; + } + + get runtimeConnectionStatus(): CopilotKitCoreRuntimeConnectionStatus { + return this._runtimeConnectionStatus; + } + + get runtimeTransport(): CopilotRuntimeTransport { + return this._runtimeTransport; + } + + /** + * Initialize agents from configuration + */ + initialize(agents: Record): void { + this.localAgents = this.assignAgentIds(agents); + this.applyHeadersToAgents(this.localAgents); + this._agents = this.localAgents; + } + + /** + * Set the runtime URL and update connection + */ + setRuntimeUrl(runtimeUrl: string | undefined): void { + const normalizedRuntimeUrl = runtimeUrl ? runtimeUrl.replace(/\/$/, "") : undefined; + + if (this._runtimeUrl === normalizedRuntimeUrl) { + return; + } + + this._runtimeUrl = normalizedRuntimeUrl; + void this.updateRuntimeConnection(); + } + + setRuntimeTransport(runtimeTransport: CopilotRuntimeTransport): void { + if (this._runtimeTransport === runtimeTransport) { + return; + } + + this._runtimeTransport = runtimeTransport; + void this.updateRuntimeConnection(); + } + + /** + * Set all agents at once (for development use) + */ + setAgents__unsafe_dev_only(agents: Record): void { + // Validate all agents before making any changes + Object.entries(agents).forEach(([id, agent]) => { + if (agent) { + this.validateAndAssignAgentId(id, agent); + } + }); + this.localAgents = agents; + this._agents = { ...this.localAgents, ...this.remoteAgents }; + this.applyHeadersToAgents(this._agents); + void this.notifyAgentsChanged(); + } + + /** + * Add a single agent (for development use) + */ + addAgent__unsafe_dev_only({ id, agent }: CopilotKitCoreAddAgentParams): void { + this.validateAndAssignAgentId(id, agent); + this.localAgents[id] = agent; + this.applyHeadersToAgent(agent); + this._agents = { ...this.localAgents, ...this.remoteAgents }; + void this.notifyAgentsChanged(); + } + + /** + * Remove an agent by ID (for development use) + */ + removeAgent__unsafe_dev_only(id: string): void { + delete this.localAgents[id]; + this._agents = { ...this.localAgents, ...this.remoteAgents }; + void this.notifyAgentsChanged(); + } + + /** + * Get an agent by ID + */ + getAgent(id: string): AbstractAgent | undefined { + if (id in this._agents) { + return this._agents[id] as AbstractAgent; + } + + // Silently return undefined if we're still loading runtime agents + if ( + this.runtimeUrl !== undefined && + (this.runtimeConnectionStatus === CopilotKitCoreRuntimeConnectionStatus.Disconnected || + this.runtimeConnectionStatus === CopilotKitCoreRuntimeConnectionStatus.Connecting) + ) { + return undefined; + } + + console.warn(`Agent ${id} not found`); + return undefined; + } + + /** + * Apply current headers to an agent + */ + applyHeadersToAgent(agent: AbstractAgent): void { + if (agent instanceof HttpAgent) { + agent.headers = { ...(this.core as unknown as CopilotKitCoreFriendsAccess).headers }; + } + } + + /** + * Apply current headers to all agents + */ + applyHeadersToAgents(agents: Record): void { + Object.values(agents).forEach((agent) => { + this.applyHeadersToAgent(agent); + }); + } + + /** + * Update runtime connection and fetch remote agents + */ + private async updateRuntimeConnection(): Promise { + // Skip fetching on the server (SSR) + if (typeof window === 'undefined') { + return; + } + + if (!this.runtimeUrl) { + this._runtimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Disconnected; + this._runtimeVersion = undefined; + this.remoteAgents = {}; + this._agents = this.localAgents; + + await this.notifyRuntimeStatusChanged(CopilotKitCoreRuntimeConnectionStatus.Disconnected); + await this.notifyAgentsChanged(); + return; + } + + this._runtimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Connecting; + await this.notifyRuntimeStatusChanged(CopilotKitCoreRuntimeConnectionStatus.Connecting); + + try { + const runtimeInfoResponse = await this.fetchRuntimeInfo(); + const { + version, + ...runtimeInfo + }: { + agents: Record; + version: string; + } = runtimeInfoResponse; + + const agents: Record = Object.fromEntries( + Object.entries(runtimeInfo.agents).map(([id, { description }]) => { + const agent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl: this.runtimeUrl, + agentId: id, // Runtime agents always have their ID set correctly + description: description, + transport: this._runtimeTransport, + }); + this.applyHeadersToAgent(agent); + return [id, agent]; + }), + ); + + this.remoteAgents = agents; + this._agents = { ...this.localAgents, ...this.remoteAgents }; + this._runtimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Connected; + this._runtimeVersion = version; + + await this.notifyRuntimeStatusChanged(CopilotKitCoreRuntimeConnectionStatus.Connected); + await this.notifyAgentsChanged(); + } catch (error) { + this._runtimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Error; + this._runtimeVersion = undefined; + this.remoteAgents = {}; + this._agents = this.localAgents; + + await this.notifyRuntimeStatusChanged(CopilotKitCoreRuntimeConnectionStatus.Error); + await this.notifyAgentsChanged(); + + const message = error instanceof Error ? error.message : JSON.stringify(error); + logger.warn(`Failed to load runtime info (${this.runtimeUrl}/info): ${message}`); + const runtimeError = error instanceof Error ? error : new Error(String(error)); + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error: runtimeError, + code: CopilotKitCoreErrorCode.RUNTIME_INFO_FETCH_FAILED, + context: { + runtimeUrl: this.runtimeUrl, + }, + }); + } + } + + private async fetchRuntimeInfo(): Promise { + if (!this.runtimeUrl) { + throw new Error("Runtime URL is not set"); + } + + const baseHeaders = (this.core as unknown as CopilotKitCoreFriendsAccess).headers; + const headers: Record = { + ...baseHeaders, + }; + + if (this._runtimeTransport === "single") { + if (!headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + const response = await fetch(this.runtimeUrl, { + method: "POST", + headers, + body: JSON.stringify({ method: "info" }), + }); + if ("ok" in response && !(response as Response).ok) { + throw new Error(`Runtime info request failed with status ${response.status}`); + } + return (await response.json()) as RuntimeInfo; + } + + const response = await fetch(`${this.runtimeUrl}/info`, { + headers, + }); + if ("ok" in response && !(response as Response).ok) { + throw new Error(`Runtime info request failed with status ${response.status}`); + } + return (await response.json()) as RuntimeInfo; + } + + /** + * Assign agent IDs to a record of agents + */ + private assignAgentIds(agents: Record): Record { + Object.entries(agents).forEach(([id, agent]) => { + if (agent) { + this.validateAndAssignAgentId(id, agent); + } + }); + return agents; + } + + /** + * Validate and assign an agent ID + */ + private validateAndAssignAgentId(registrationId: string, agent: AbstractAgent): void { + if (agent.agentId && agent.agentId !== registrationId) { + throw new Error( + `Agent registration mismatch: Agent with ID "${agent.agentId}" cannot be registered under key "${registrationId}". ` + + `The agent ID must match the registration key or be undefined.`, + ); + } + if (!agent.agentId) { + agent.agentId = registrationId; + } + } + + /** + * Notify subscribers of runtime status changes + */ + private async notifyRuntimeStatusChanged(status: CopilotKitCoreRuntimeConnectionStatus): Promise { + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onRuntimeConnectionStatusChanged?.({ + copilotkit: this.core, + status, + }), + "Error in CopilotKitCore subscriber (onRuntimeConnectionStatusChanged):", + ); + } + + /** + * Notify subscribers of agent changes + */ + private async notifyAgentsChanged(): Promise { + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onAgentsChanged?.({ + copilotkit: this.core, + agents: this._agents, + }), + "Subscriber onAgentsChanged error:", + ); + } +} diff --git a/src/v2.x/packages/core/src/core/context-store.ts b/src/v2.x/packages/core/src/core/context-store.ts new file mode 100644 index 0000000000..f7baf9df5d --- /dev/null +++ b/src/v2.x/packages/core/src/core/context-store.ts @@ -0,0 +1,54 @@ +import { Context } from "@ag-ui/client"; +import { randomUUID } from "@copilotkitnext/shared"; +import type { CopilotKitCore } from "./core"; +import { CopilotKitCoreFriendsAccess } from "./core"; + +/** + * Manages context storage and lifecycle for CopilotKitCore. + * Context represents additional information available to agents during execution. + */ +export class ContextStore { + private _context: Record = {}; + + constructor(private core: CopilotKitCore) {} + + /** + * Get all context entries as a readonly record + */ + get context(): Readonly> { + return this._context; + } + + /** + * Add a new context entry + * @returns The ID of the created context entry + */ + addContext({ description, value }: Context): string { + const id = randomUUID(); + this._context[id] = { description, value }; + void this.notifySubscribers(); + return id; + } + + /** + * Remove a context entry by ID + */ + removeContext(id: string): void { + delete this._context[id]; + void this.notifySubscribers(); + } + + /** + * Notify all subscribers of context changes + */ + private async notifySubscribers(): Promise { + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onContextChanged?.({ + copilotkit: this.core, + context: this._context, + }), + "Subscriber onContextChanged error:", + ); + } +} diff --git a/src/v2.x/packages/core/src/core/core.ts b/src/v2.x/packages/core/src/core/core.ts new file mode 100644 index 0000000000..d24ca51d5e --- /dev/null +++ b/src/v2.x/packages/core/src/core/core.ts @@ -0,0 +1,442 @@ +import { AbstractAgent, Context, State } from "@ag-ui/client"; +import { FrontendTool, SuggestionsConfig, Suggestion, CopilotRuntimeTransport } from "../types"; +import { AgentRegistry, CopilotKitCoreAddAgentParams } from "./agent-registry"; +import { ContextStore } from "./context-store"; +import { SuggestionEngine } from "./suggestion-engine"; +import { + RunHandler, + CopilotKitCoreRunAgentParams, + CopilotKitCoreConnectAgentParams, + CopilotKitCoreGetToolParams, +} from "./run-handler"; +import { StateManager } from "./state-manager"; + +/** Configuration options for `CopilotKitCore`. */ +export interface CopilotKitCoreConfig { + /** The endpoint of the CopilotRuntime. */ + runtimeUrl?: string; + /** Transport style for CopilotRuntime endpoints. Defaults to REST. */ + runtimeTransport?: CopilotRuntimeTransport; + /** Mapping from agent name to its `AbstractAgent` instance. For development only - production requires CopilotRuntime. */ + agents__unsafe_dev_only?: Record; + /** Headers appended to every HTTP request made by `CopilotKitCore`. */ + headers?: Record; + /** Properties sent as `forwardedProps` to the AG-UI agent. */ + properties?: Record; + /** Ordered collection of frontend tools available to the core. */ + tools?: FrontendTool[]; + /** Suggestions config for the core. */ + suggestionsConfig?: SuggestionsConfig[]; +} + +export type { CopilotKitCoreAddAgentParams }; +export type { CopilotKitCoreRunAgentParams, CopilotKitCoreConnectAgentParams, CopilotKitCoreGetToolParams }; + +export interface CopilotKitCoreStopAgentParams { + agent: AbstractAgent; +} + +export type CopilotKitCoreGetSuggestionsResult = { + suggestions: Suggestion[]; + isLoading: boolean; +}; + +export enum CopilotKitCoreErrorCode { + RUNTIME_INFO_FETCH_FAILED = "runtime_info_fetch_failed", + AGENT_CONNECT_FAILED = "agent_connect_failed", + AGENT_RUN_FAILED = "agent_run_failed", + AGENT_RUN_FAILED_EVENT = "agent_run_failed_event", + AGENT_RUN_ERROR_EVENT = "agent_run_error_event", + TOOL_ARGUMENT_PARSE_FAILED = "tool_argument_parse_failed", + TOOL_HANDLER_FAILED = "tool_handler_failed", +} + +export interface CopilotKitCoreSubscriber { + onRuntimeConnectionStatusChanged?: (event: { + copilotkit: CopilotKitCore; + status: CopilotKitCoreRuntimeConnectionStatus; + }) => void | Promise; + onToolExecutionStart?: (event: { + copilotkit: CopilotKitCore; + toolCallId: string; + agentId: string; + toolName: string; + args: unknown; + }) => void | Promise; + onToolExecutionEnd?: (event: { + copilotkit: CopilotKitCore; + toolCallId: string; + agentId: string; + toolName: string; + result: string; + error?: string; + }) => void | Promise; + onAgentsChanged?: (event: { + copilotkit: CopilotKitCore; + agents: Readonly>; + }) => void | Promise; + onContextChanged?: (event: { + copilotkit: CopilotKitCore; + context: Readonly>; + }) => void | Promise; + onSuggestionsConfigChanged?: (event: { + copilotkit: CopilotKitCore; + suggestionsConfig: Readonly>; + }) => void | Promise; + onSuggestionsChanged?: (event: { + copilotkit: CopilotKitCore; + agentId: string; + suggestions: Suggestion[]; + }) => void | Promise; + onSuggestionsStartedLoading?: (event: { copilotkit: CopilotKitCore; agentId: string }) => void | Promise; + onSuggestionsFinishedLoading?: (event: { copilotkit: CopilotKitCore; agentId: string }) => void | Promise; + onPropertiesChanged?: (event: { + copilotkit: CopilotKitCore; + properties: Readonly>; + }) => void | Promise; + onHeadersChanged?: (event: { + copilotkit: CopilotKitCore; + headers: Readonly>; + }) => void | Promise; + onError?: (event: { + copilotkit: CopilotKitCore; + error: Error; + code: CopilotKitCoreErrorCode; + context: Record; + }) => void | Promise; +} + +// Subscription object returned by subscribe() +export interface CopilotKitCoreSubscription { + unsubscribe: () => void; +} + +export enum CopilotKitCoreRuntimeConnectionStatus { + Disconnected = "disconnected", + Connected = "connected", + Connecting = "connecting", + Error = "error", +} + +/** + * Internal interface for delegate classes to access CopilotKitCore methods. + * This provides type safety while allowing controlled access to private functionality. + */ +export interface CopilotKitCoreFriendsAccess { + // Notification methods + notifySubscribers( + handler: (subscriber: CopilotKitCoreSubscriber) => void | Promise, + errorMessage: string, + ): Promise; + + emitError(params: { error: Error; code: CopilotKitCoreErrorCode; context?: Record }): Promise; + + // Getters for internal state + readonly headers: Readonly>; + readonly properties: Readonly>; + readonly context: Readonly>; + + // Internal methods + buildFrontendTools(agentId?: string): import("@ag-ui/client").Tool[]; + getAgent(id: string): AbstractAgent | undefined; + + // References to delegate subsystems + readonly suggestionEngine: { + clearSuggestions(agentId: string): void; + reloadSuggestions(agentId: string): void; + }; +} + +export class CopilotKitCore { + private _headers: Record; + private _properties: Record; + + private subscribers: Set = new Set(); + + // Delegate classes + private agentRegistry: AgentRegistry; + private contextStore: ContextStore; + private suggestionEngine: SuggestionEngine; + private runHandler: RunHandler; + private stateManager: StateManager; + + constructor({ + runtimeUrl, + runtimeTransport = "rest", + headers = {}, + properties = {}, + agents__unsafe_dev_only = {}, + tools = [], + suggestionsConfig = [], + }: CopilotKitCoreConfig) { + this._headers = headers; + this._properties = properties; + + // Initialize delegate classes + this.agentRegistry = new AgentRegistry(this); + this.contextStore = new ContextStore(this); + this.suggestionEngine = new SuggestionEngine(this); + this.runHandler = new RunHandler(this); + this.stateManager = new StateManager(this); + + // Initialize each subsystem + this.agentRegistry.initialize(agents__unsafe_dev_only); + this.runHandler.initialize(tools); + this.suggestionEngine.initialize(suggestionsConfig); + this.stateManager.initialize(); + + this.agentRegistry.setRuntimeTransport(runtimeTransport); + this.agentRegistry.setRuntimeUrl(runtimeUrl); + + // Subscribe to agent changes to track state for new agents + this.subscribe({ + onAgentsChanged: ({ agents }) => { + Object.values(agents).forEach((agent) => { + if (agent.agentId) { + this.stateManager.subscribeToAgent(agent); + } + }); + }, + }); + } + + /** + * Internal method used by delegate classes and subclasses to notify subscribers + */ + protected async notifySubscribers( + handler: (subscriber: CopilotKitCoreSubscriber) => void | Promise, + errorMessage: string, + ): Promise { + await Promise.all( + Array.from(this.subscribers).map(async (subscriber) => { + try { + await handler(subscriber); + } catch (error) { + console.error(errorMessage, error); + } + }), + ); + } + + /** + * Internal method used by delegate classes to emit errors + */ + private async emitError({ + error, + code, + context = {}, + }: { + error: Error; + code: CopilotKitCoreErrorCode; + context?: Record; + }): Promise { + await this.notifySubscribers( + (subscriber) => + subscriber.onError?.({ + copilotkit: this, + error, + code, + context, + }), + "Subscriber onError error:", + ); + } + + /** + * Snapshot accessors + */ + get context(): Readonly> { + return this.contextStore.context; + } + + get agents(): Readonly> { + return this.agentRegistry.agents; + } + + get tools(): Readonly[]> { + return this.runHandler.tools; + } + + get runtimeUrl(): string | undefined { + return this.agentRegistry.runtimeUrl; + } + + setRuntimeUrl(runtimeUrl: string | undefined): void { + this.agentRegistry.setRuntimeUrl(runtimeUrl); + } + + get runtimeTransport(): CopilotRuntimeTransport { + return this.agentRegistry.runtimeTransport; + } + + setRuntimeTransport(runtimeTransport: CopilotRuntimeTransport): void { + this.agentRegistry.setRuntimeTransport(runtimeTransport); + } + + get runtimeVersion(): string | undefined { + return this.agentRegistry.runtimeVersion; + } + + get headers(): Readonly> { + return this._headers; + } + + get properties(): Readonly> { + return this._properties; + } + + get runtimeConnectionStatus(): CopilotKitCoreRuntimeConnectionStatus { + return this.agentRegistry.runtimeConnectionStatus; + } + + /** + * Configuration updates + */ + setHeaders(headers: Record): void { + this._headers = headers; + this.agentRegistry.applyHeadersToAgents(this.agentRegistry.agents as Record); + void this.notifySubscribers( + (subscriber) => + subscriber.onHeadersChanged?.({ + copilotkit: this, + headers: this.headers, + }), + "Subscriber onHeadersChanged error:", + ); + } + + setProperties(properties: Record): void { + this._properties = properties; + void this.notifySubscribers( + (subscriber) => + subscriber.onPropertiesChanged?.({ + copilotkit: this, + properties: this.properties, + }), + "Subscriber onPropertiesChanged error:", + ); + } + + /** + * Agent management (delegated to AgentRegistry) + */ + setAgents__unsafe_dev_only(agents: Record): void { + this.agentRegistry.setAgents__unsafe_dev_only(agents); + } + + addAgent__unsafe_dev_only(params: CopilotKitCoreAddAgentParams): void { + this.agentRegistry.addAgent__unsafe_dev_only(params); + } + + removeAgent__unsafe_dev_only(id: string): void { + this.agentRegistry.removeAgent__unsafe_dev_only(id); + } + + getAgent(id: string): AbstractAgent | undefined { + return this.agentRegistry.getAgent(id); + } + + /** + * Context management (delegated to ContextStore) + */ + addContext(context: Context): string { + return this.contextStore.addContext(context); + } + + removeContext(id: string): void { + this.contextStore.removeContext(id); + } + + /** + * Suggestions management (delegated to SuggestionEngine) + */ + addSuggestionsConfig(config: SuggestionsConfig): string { + return this.suggestionEngine.addSuggestionsConfig(config); + } + + removeSuggestionsConfig(id: string): void { + this.suggestionEngine.removeSuggestionsConfig(id); + } + + reloadSuggestions(agentId: string): void { + this.suggestionEngine.reloadSuggestions(agentId); + } + + clearSuggestions(agentId: string): void { + this.suggestionEngine.clearSuggestions(agentId); + } + + getSuggestions(agentId: string): CopilotKitCoreGetSuggestionsResult { + return this.suggestionEngine.getSuggestions(agentId); + } + + /** + * Tool management (delegated to RunHandler) + */ + addTool = Record>(tool: FrontendTool): void { + this.runHandler.addTool(tool); + } + + removeTool(id: string, agentId?: string): void { + this.runHandler.removeTool(id, agentId); + } + + getTool(params: CopilotKitCoreGetToolParams): FrontendTool | undefined { + return this.runHandler.getTool(params); + } + + setTools(tools: FrontendTool[]): void { + this.runHandler.setTools(tools); + } + + /** + * Subscription lifecycle + */ + subscribe(subscriber: CopilotKitCoreSubscriber): CopilotKitCoreSubscription { + this.subscribers.add(subscriber); + + // Return subscription with unsubscribe method + return { + unsubscribe: () => { + this.subscribers.delete(subscriber); + }, + }; + } + + /** + * Agent connectivity (delegated to RunHandler) + */ + async connectAgent(params: CopilotKitCoreConnectAgentParams): Promise { + return this.runHandler.connectAgent(params); + } + + stopAgent(params: CopilotKitCoreStopAgentParams): void { + params.agent.abortRun(); + } + + async runAgent(params: CopilotKitCoreRunAgentParams): Promise { + return this.runHandler.runAgent(params); + } + + /** + * State management (delegated to StateManager) + */ + getStateByRun(agentId: string, threadId: string, runId: string): State | undefined { + return this.stateManager.getStateByRun(agentId, threadId, runId); + } + + getRunIdForMessage(agentId: string, threadId: string, messageId: string): string | undefined { + return this.stateManager.getRunIdForMessage(agentId, threadId, messageId); + } + + getRunIdsForThread(agentId: string, threadId: string): string[] { + return this.stateManager.getRunIdsForThread(agentId, threadId); + } + + /** + * Internal method used by RunHandler to build frontend tools + */ + private buildFrontendTools(agentId?: string): import("@ag-ui/client").Tool[] { + return this.runHandler.buildFrontendTools(agentId); + } +} diff --git a/src/v2.x/packages/core/src/core/index.ts b/src/v2.x/packages/core/src/core/index.ts new file mode 100644 index 0000000000..3fc88064a2 --- /dev/null +++ b/src/v2.x/packages/core/src/core/index.ts @@ -0,0 +1,6 @@ +export * from "./core"; +export * from "./agent-registry"; +export * from "./context-store"; +export * from "./suggestion-engine"; +export * from "./run-handler"; +export * from "./state-manager"; diff --git a/src/v2.x/packages/core/src/core/run-handler.ts b/src/v2.x/packages/core/src/core/run-handler.ts new file mode 100644 index 0000000000..d8ddefc212 --- /dev/null +++ b/src/v2.x/packages/core/src/core/run-handler.ts @@ -0,0 +1,601 @@ +import { AbstractAgent, AgentSubscriber, HttpAgent, Message, RunAgentResult, Tool } from "@ag-ui/client"; +import { randomUUID, logger } from "@copilotkitnext/shared"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { CopilotKitCore } from "./core"; +import { CopilotKitCoreErrorCode, CopilotKitCoreFriendsAccess } from "./core"; +import { FrontendTool } from "../types"; + +export interface CopilotKitCoreRunAgentParams { + agent: AbstractAgent; +} + +export interface CopilotKitCoreConnectAgentParams { + agent: AbstractAgent; +} + +export interface CopilotKitCoreGetToolParams { + toolName: string; + agentId?: string; +} + +/** + * Handles agent execution, tool calling, and agent connectivity for CopilotKitCore. + * Manages the complete lifecycle of agent runs including tool execution and follow-ups. + */ +export class RunHandler { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _tools: FrontendTool[] = []; + + constructor(private core: CopilotKitCore) {} + + /** + * Get all tools as a readonly array + */ + get tools(): Readonly[]> { + return this._tools; + } + + /** + * Initialize with tools + */ + initialize(tools: FrontendTool[]): void { + this._tools = tools; + } + + /** + * Add a tool to the registry + */ + addTool = Record>(tool: FrontendTool): void { + // Check if a tool with the same name and agentId already exists + const existingToolIndex = this._tools.findIndex((t) => t.name === tool.name && t.agentId === tool.agentId); + + if (existingToolIndex !== -1) { + logger.warn(`Tool already exists: '${tool.name}' for agent '${tool.agentId || "global"}', skipping.`); + return; + } + + this._tools.push(tool); + } + + /** + * Remove a tool by name and optionally by agentId + */ + removeTool(id: string, agentId?: string): void { + this._tools = this._tools.filter((tool) => { + // Remove tool if both name and agentId match + if (agentId !== undefined) { + return !(tool.name === id && tool.agentId === agentId); + } + // If no agentId specified, only remove global tools with matching name + return !(tool.name === id && !tool.agentId); + }); + } + + /** + * Get a tool by name and optionally by agentId. + * If agentId is provided, it will first look for an agent-specific tool, + * then fall back to a global tool with the same name. + */ + getTool(params: CopilotKitCoreGetToolParams): FrontendTool | undefined { + const { toolName, agentId } = params; + + // If agentId is provided, first look for agent-specific tool + if (agentId) { + const agentTool = this._tools.find((tool) => tool.name === toolName && tool.agentId === agentId); + if (agentTool) { + return agentTool; + } + } + + // Fall back to global tool (no agentId) + return this._tools.find((tool) => tool.name === toolName && !tool.agentId); + } + + /** + * Set all tools at once. Replaces existing tools. + */ + setTools(tools: FrontendTool[]): void { + this._tools = [...tools]; + } + + /** + * Connect an agent (establish initial connection) + */ + async connectAgent({ agent }: CopilotKitCoreConnectAgentParams): Promise { + try { + // Detach any active run before connecting to avoid previous runs interfering + await agent.detachActiveRun(); + agent.setMessages([]); + agent.setState({}); + + if (agent instanceof HttpAgent) { + agent.headers = { ...(this.core as unknown as CopilotKitCoreFriendsAccess).headers }; + } + + const runAgentResult = await agent.connectAgent( + { + forwardedProps: (this.core as unknown as CopilotKitCoreFriendsAccess).properties, + tools: this.buildFrontendTools(agent.agentId), + }, + this.createAgentErrorSubscriber(agent), + ); + + return this.processAgentResult({ runAgentResult, agent }); + } catch (error) { + const connectError = error instanceof Error ? error : new Error(String(error)); + const context: Record = {}; + if (agent.agentId) { + context.agentId = agent.agentId; + } + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error: connectError, + code: CopilotKitCoreErrorCode.AGENT_CONNECT_FAILED, + context, + }); + throw error; + } + } + + /** + * Run an agent + */ + async runAgent({ agent }: CopilotKitCoreRunAgentParams): Promise { + // Agent ID is guaranteed to be set by validateAndAssignAgentId + if (agent.agentId) { + void (this.core as unknown as CopilotKitCoreFriendsAccess).suggestionEngine.clearSuggestions(agent.agentId); + } + + if (agent instanceof HttpAgent) { + agent.headers = { ...(this.core as unknown as CopilotKitCoreFriendsAccess).headers }; + } + + try { + const runAgentResult = await agent.runAgent( + { + forwardedProps: (this.core as unknown as CopilotKitCoreFriendsAccess).properties, + tools: this.buildFrontendTools(agent.agentId), + context: Object.values((this.core as unknown as CopilotKitCoreFriendsAccess).context), + }, + this.createAgentErrorSubscriber(agent), + ); + return this.processAgentResult({ runAgentResult, agent }); + } catch (error) { + const runError = error instanceof Error ? error : new Error(String(error)); + const context: Record = {}; + if (agent.agentId) { + context.agentId = agent.agentId; + } + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error: runError, + code: CopilotKitCoreErrorCode.AGENT_RUN_FAILED, + context, + }); + throw error; + } + } + + /** + * Process agent result and execute tools + */ + private async processAgentResult({ + runAgentResult, + agent, + }: { + runAgentResult: RunAgentResult; + agent: AbstractAgent; + }): Promise { + const { newMessages } = runAgentResult; + // Agent ID is guaranteed to be set by validateAndAssignAgentId + const agentId = agent.agentId!; + + let needsFollowUp = false; + + for (const message of newMessages) { + if (message.role === "assistant") { + for (const toolCall of message.toolCalls || []) { + if (newMessages.findIndex((m) => m.role === "tool" && m.toolCallId === toolCall.id) === -1) { + const tool = this.getTool({ + toolName: toolCall.function.name, + agentId: agent.agentId, + }); + if (tool) { + const followUp = await this.executeSpecificTool(tool, toolCall, message, agent, agentId); + if (followUp) { + needsFollowUp = true; + } + } else { + // Wildcard fallback for undefined tools + const wildcardTool = this.getTool({ toolName: "*", agentId: agent.agentId }); + if (wildcardTool) { + const followUp = await this.executeWildcardTool(wildcardTool, toolCall, message, agent, agentId); + if (followUp) { + needsFollowUp = true; + } + } + } + } + } + } + } + + if (needsFollowUp) { + return await this.runAgent({ agent }); + } + + void (this.core as unknown as CopilotKitCoreFriendsAccess).suggestionEngine.reloadSuggestions(agentId); + + return runAgentResult; + } + + /** + * Execute a specific tool + */ + private async executeSpecificTool( + tool: FrontendTool, + toolCall: any, + message: Message, + agent: AbstractAgent, + agentId: string, + ): Promise { + // Check if tool is constrained to a specific agent + if (tool?.agentId && tool.agentId !== agent.agentId) { + // Tool is not available for this agent, skip it + return false; + } + + let toolCallResult = ""; + let errorMessage: string | undefined; + let isArgumentError = false; + + if (tool?.handler) { + let parsedArgs: unknown; + try { + parsedArgs = JSON.parse(toolCall.function.arguments); + } catch (error) { + const parseError = error instanceof Error ? error : new Error(String(error)); + errorMessage = parseError.message; + isArgumentError = true; + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error: parseError, + code: CopilotKitCoreErrorCode.TOOL_ARGUMENT_PARSE_FAILED, + context: { + agentId: agentId, + toolCallId: toolCall.id, + toolName: toolCall.function.name, + rawArguments: toolCall.function.arguments, + toolType: "specific", + messageId: message.id, + }, + }); + } + + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onToolExecutionStart?.({ + copilotkit: this.core, + toolCallId: toolCall.id, + agentId: agentId, + toolName: toolCall.function.name, + args: parsedArgs, + }), + "Subscriber onToolExecutionStart error:", + ); + + if (!errorMessage) { + try { + const result = await tool.handler(parsedArgs as any, toolCall); + if (result === undefined || result === null) { + toolCallResult = ""; + } else if (typeof result === "string") { + toolCallResult = result; + } else { + toolCallResult = JSON.stringify(result); + } + } catch (error) { + const handlerError = error instanceof Error ? error : new Error(String(error)); + errorMessage = handlerError.message; + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error: handlerError, + code: CopilotKitCoreErrorCode.TOOL_HANDLER_FAILED, + context: { + agentId: agentId, + toolCallId: toolCall.id, + toolName: toolCall.function.name, + parsedArgs, + toolType: "specific", + messageId: message.id, + }, + }); + } + } + + if (errorMessage) { + toolCallResult = `Error: ${errorMessage}`; + } + + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onToolExecutionEnd?.({ + copilotkit: this.core, + toolCallId: toolCall.id, + agentId: agentId, + toolName: toolCall.function.name, + result: errorMessage ? "" : toolCallResult, + error: errorMessage, + }), + "Subscriber onToolExecutionEnd error:", + ); + + if (isArgumentError) { + throw new Error(errorMessage ?? "Tool execution failed"); + } + } + + if (!errorMessage || !isArgumentError) { + const messageIndex = agent.messages.findIndex((m) => m.id === message.id); + const toolMessage = { + id: randomUUID(), + role: "tool" as const, + toolCallId: toolCall.id, + content: toolCallResult, + }; + agent.messages.splice(messageIndex + 1, 0, toolMessage); + + if (!errorMessage && tool?.followUp !== false) { + return true; // Needs follow-up + } + } + + return false; + } + + /** + * Execute a wildcard tool + */ + private async executeWildcardTool( + wildcardTool: FrontendTool, + toolCall: any, + message: Message, + agent: AbstractAgent, + agentId: string, + ): Promise { + // Check if wildcard tool is constrained to a specific agent + if (wildcardTool?.agentId && wildcardTool.agentId !== agent.agentId) { + // Wildcard tool is not available for this agent, skip it + return false; + } + + let toolCallResult = ""; + let errorMessage: string | undefined; + let isArgumentError = false; + + if (wildcardTool?.handler) { + let parsedArgs: unknown; + try { + parsedArgs = JSON.parse(toolCall.function.arguments); + } catch (error) { + const parseError = error instanceof Error ? error : new Error(String(error)); + errorMessage = parseError.message; + isArgumentError = true; + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error: parseError, + code: CopilotKitCoreErrorCode.TOOL_ARGUMENT_PARSE_FAILED, + context: { + agentId: agentId, + toolCallId: toolCall.id, + toolName: toolCall.function.name, + rawArguments: toolCall.function.arguments, + toolType: "wildcard", + messageId: message.id, + }, + }); + } + + const wildcardArgs = { + toolName: toolCall.function.name, + args: parsedArgs, + }; + + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onToolExecutionStart?.({ + copilotkit: this.core, + toolCallId: toolCall.id, + agentId: agentId, + toolName: toolCall.function.name, + args: wildcardArgs, + }), + "Subscriber onToolExecutionStart error:", + ); + + if (!errorMessage) { + try { + const result = await wildcardTool.handler(wildcardArgs as any, toolCall); + if (result === undefined || result === null) { + toolCallResult = ""; + } else if (typeof result === "string") { + toolCallResult = result; + } else { + toolCallResult = JSON.stringify(result); + } + } catch (error) { + const handlerError = error instanceof Error ? error : new Error(String(error)); + errorMessage = handlerError.message; + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error: handlerError, + code: CopilotKitCoreErrorCode.TOOL_HANDLER_FAILED, + context: { + agentId: agentId, + toolCallId: toolCall.id, + toolName: toolCall.function.name, + parsedArgs: wildcardArgs, + toolType: "wildcard", + messageId: message.id, + }, + }); + } + } + + if (errorMessage) { + toolCallResult = `Error: ${errorMessage}`; + } + + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onToolExecutionEnd?.({ + copilotkit: this.core, + toolCallId: toolCall.id, + agentId: agentId, + toolName: toolCall.function.name, + result: errorMessage ? "" : toolCallResult, + error: errorMessage, + }), + "Subscriber onToolExecutionEnd error:", + ); + + if (isArgumentError) { + throw new Error(errorMessage ?? "Tool execution failed"); + } + } + + if (!errorMessage || !isArgumentError) { + const messageIndex = agent.messages.findIndex((m) => m.id === message.id); + const toolMessage = { + id: randomUUID(), + role: "tool" as const, + toolCallId: toolCall.id, + content: toolCallResult, + }; + agent.messages.splice(messageIndex + 1, 0, toolMessage); + + if (!errorMessage && wildcardTool?.followUp !== false) { + return true; // Needs follow-up + } + } + + return false; + } + + /** + * Build frontend tools for an agent + */ + buildFrontendTools(agentId?: string): Tool[] { + return this._tools + .filter((tool) => !tool.agentId || tool.agentId === agentId) + .map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + parameters: createToolSchema(tool), + })); + } + + /** + * Create an agent error subscriber + */ + private createAgentErrorSubscriber(agent: AbstractAgent): AgentSubscriber { + const emitAgentError = async ( + error: Error, + code: CopilotKitCoreErrorCode, + extraContext: Record = {}, + ) => { + const context: Record = { ...extraContext }; + if (agent.agentId) { + context.agentId = agent.agentId; + } + await (this.core as unknown as CopilotKitCoreFriendsAccess).emitError({ + error, + code, + context, + }); + }; + + return { + onRunFailed: async ({ error }: { error: Error }) => { + await emitAgentError(error, CopilotKitCoreErrorCode.AGENT_RUN_FAILED_EVENT, { + source: "onRunFailed", + }); + }, + onRunErrorEvent: async ({ event }) => { + const eventError = + event?.rawEvent instanceof Error + ? event.rawEvent + : event?.rawEvent?.error instanceof Error + ? event.rawEvent.error + : undefined; + + const errorMessage = + typeof event?.rawEvent?.error === "string" ? event.rawEvent.error : (event?.message ?? "Agent run error"); + + const rawError = eventError ?? new Error(errorMessage); + + if (event?.code && !(rawError as any).code) { + (rawError as any).code = event.code; + } + + await emitAgentError(rawError, CopilotKitCoreErrorCode.AGENT_RUN_ERROR_EVENT, { + source: "onRunErrorEvent", + event, + runtimeErrorCode: event?.code, + }); + }, + }; + } +} + +/** + * Empty tool schema constant + */ +const EMPTY_TOOL_SCHEMA = { + type: "object", + properties: {}, +} as const satisfies Record; + +/** + * Create a JSON schema from a tool's parameters + */ +function createToolSchema(tool: FrontendTool): Record { + if (!tool.parameters) { + return { ...EMPTY_TOOL_SCHEMA }; + } + + const rawSchema = zodToJsonSchema(tool.parameters, { + $refStrategy: "none", + }); + + if (!rawSchema || typeof rawSchema !== "object") { + return { ...EMPTY_TOOL_SCHEMA }; + } + + const { $schema, ...schema } = rawSchema as Record; + + if (typeof schema.type !== "string") { + schema.type = "object"; + } + if (typeof schema.properties !== "object" || schema.properties === null) { + schema.properties = {}; + } + + stripAdditionalProperties(schema); + return schema; +} + +function stripAdditionalProperties(schema: unknown): void { + if (!schema || typeof schema !== "object") { + return; + } + + if (Array.isArray(schema)) { + schema.forEach(stripAdditionalProperties); + return; + } + + const record = schema as Record; + + if (record.additionalProperties !== undefined) { + delete record.additionalProperties; + } + + for (const value of Object.values(record)) { + stripAdditionalProperties(value); + } +} diff --git a/src/v2.x/packages/core/src/core/state-manager.ts b/src/v2.x/packages/core/src/core/state-manager.ts new file mode 100644 index 0000000000..83a40f3283 --- /dev/null +++ b/src/v2.x/packages/core/src/core/state-manager.ts @@ -0,0 +1,248 @@ +import { + AbstractAgent, + Message, + State, + RunAgentInput, + RunStartedEvent, + RunFinishedEvent, + StateSnapshotEvent, + StateDeltaEvent, + MessagesSnapshotEvent, +} from "@ag-ui/client"; +import type { CopilotKitCore } from "./core"; + +/** + * Manages state and message tracking by run for CopilotKitCore. + * Tracks agent state snapshots and message-to-run associations. + */ +export class StateManager { + // State tracking: agentId -> threadId -> runId -> state + private stateByRun: Map>> = new Map(); + + // Message tracking: agentId -> threadId -> messageId -> runId + private messageToRun: Map>> = new Map(); + + // Agent subscriptions for cleanup + private agentSubscriptions: Map void> = new Map(); + + constructor(private core: CopilotKitCore) {} + + /** + * Initialize state tracking for an agent + */ + initialize(): void { + // Will be called when CopilotKitCore is initialized + } + + /** + * Subscribe to an agent's events to track state and messages + */ + subscribeToAgent(agent: AbstractAgent): void { + if (!agent.agentId) { + return; // Skip agents without IDs + } + + const agentId = agent.agentId; + + // Unsubscribe existing subscription if any + this.unsubscribeFromAgent(agentId); + + // Subscribe to agent events + const { unsubscribe } = agent.subscribe({ + onRunStartedEvent: ({ event, state }) => { + this.handleRunStarted(agent, event, state); + }, + onRunFinishedEvent: ({ event, state }) => { + this.handleRunFinished(agent, event, state); + }, + onStateSnapshotEvent: ({ event, input, state }) => { + this.handleStateSnapshot(agent, event, input, state); + }, + onStateDeltaEvent: ({ event, input, state }) => { + this.handleStateDelta(agent, event, input, state); + }, + onMessagesSnapshotEvent: ({ event, input, messages }) => { + this.handleMessagesSnapshot(agent, event, input, messages); + }, + onNewMessage: ({ message, input }) => { + this.handleNewMessage(agent, message, input); + }, + }); + + this.agentSubscriptions.set(agentId, unsubscribe); + } + + /** + * Unsubscribe from an agent's events + */ + unsubscribeFromAgent(agentId: string): void { + const unsubscribe = this.agentSubscriptions.get(agentId); + if (unsubscribe) { + unsubscribe(); + this.agentSubscriptions.delete(agentId); + } + } + + /** + * Get state for a specific run + * Returns a deep copy to prevent external mutations + */ + getStateByRun(agentId: string, threadId: string, runId: string): State | undefined { + const state = this.stateByRun.get(agentId)?.get(threadId)?.get(runId); + if (!state) return undefined; + // Return a deep copy to prevent mutations + return JSON.parse(JSON.stringify(state)); + } + + /** + * Get runId associated with a message + */ + getRunIdForMessage(agentId: string, threadId: string, messageId: string): string | undefined { + return this.messageToRun.get(agentId)?.get(threadId)?.get(messageId); + } + + /** + * Get all states for an agent's thread + */ + getStatesForThread(agentId: string, threadId: string): Map { + return this.stateByRun.get(agentId)?.get(threadId) ?? new Map(); + } + + /** + * Get all run IDs for an agent's thread + */ + getRunIdsForThread(agentId: string, threadId: string): string[] { + const threadStates = this.stateByRun.get(agentId)?.get(threadId); + return threadStates ? Array.from(threadStates.keys()) : []; + } + + /** + * Handle run started event + */ + private handleRunStarted(agent: AbstractAgent, event: RunStartedEvent, state: State): void { + if (!agent.agentId) return; + + const { threadId, runId } = event; + this.saveState(agent.agentId, threadId, runId, state); + } + + /** + * Handle run finished event + */ + private handleRunFinished(agent: AbstractAgent, event: RunFinishedEvent, state: State): void { + if (!agent.agentId) return; + + const { threadId, runId } = event; + this.saveState(agent.agentId, threadId, runId, state); + } + + /** + * Handle state snapshot event + */ + private handleStateSnapshot( + agent: AbstractAgent, + event: StateSnapshotEvent, + input: RunAgentInput, + state: State, + ): void { + if (!agent.agentId) return; + + const { threadId, runId } = input; + // Merge snapshot into current state + const mergedState = { ...state, ...event.snapshot }; + this.saveState(agent.agentId, threadId, runId, mergedState); + } + + /** + * Handle state delta event + */ + private handleStateDelta(agent: AbstractAgent, event: StateDeltaEvent, input: RunAgentInput, state: State): void { + if (!agent.agentId) return; + + const { threadId, runId } = input; + // State is already updated by the agent, just save it + this.saveState(agent.agentId, threadId, runId, state); + } + + /** + * Handle messages snapshot event + */ + private handleMessagesSnapshot( + agent: AbstractAgent, + event: MessagesSnapshotEvent, + input: RunAgentInput, + messages: Message[], + ): void { + if (!agent.agentId) return; + + const { threadId, runId } = input; + + // Associate all messages in the snapshot with this run + for (const message of event.messages) { + this.associateMessageWithRun(agent.agentId, threadId, message.id, runId); + } + } + + /** + * Handle new message event + */ + private handleNewMessage(agent: AbstractAgent, message: Message, input?: RunAgentInput): void { + if (!agent.agentId || !input) return; + + const { threadId, runId } = input; + this.associateMessageWithRun(agent.agentId, threadId, message.id, runId); + } + + /** + * Save state for a specific run + */ + private saveState(agentId: string, threadId: string, runId: string, state: State): void { + // Ensure nested maps exist + if (!this.stateByRun.has(agentId)) { + this.stateByRun.set(agentId, new Map()); + } + const agentStates = this.stateByRun.get(agentId)!; + + if (!agentStates.has(threadId)) { + agentStates.set(threadId, new Map()); + } + const threadStates = agentStates.get(threadId)!; + + // Deep copy the state to prevent mutations + threadStates.set(runId, JSON.parse(JSON.stringify(state))); + } + + /** + * Associate a message with a run + */ + private associateMessageWithRun(agentId: string, threadId: string, messageId: string, runId: string): void { + // Ensure nested maps exist + if (!this.messageToRun.has(agentId)) { + this.messageToRun.set(agentId, new Map()); + } + const agentMessages = this.messageToRun.get(agentId)!; + + if (!agentMessages.has(threadId)) { + agentMessages.set(threadId, new Map()); + } + const threadMessages = agentMessages.get(threadId)!; + + threadMessages.set(messageId, runId); + } + + /** + * Clear all state for an agent + */ + clearAgentState(agentId: string): void { + this.stateByRun.delete(agentId); + this.messageToRun.delete(agentId); + } + + /** + * Clear all state for a thread + */ + clearThreadState(agentId: string, threadId: string): void { + this.stateByRun.get(agentId)?.delete(threadId); + this.messageToRun.get(agentId)?.delete(threadId); + } +} diff --git a/src/v2.x/packages/core/src/core/suggestion-engine.ts b/src/v2.x/packages/core/src/core/suggestion-engine.ts new file mode 100644 index 0000000000..9f68e70de8 --- /dev/null +++ b/src/v2.x/packages/core/src/core/suggestion-engine.ts @@ -0,0 +1,444 @@ +import { AbstractAgent, Message, Tool, Context } from "@ag-ui/client"; +import { randomUUID, partialJSONParse } from "@copilotkitnext/shared"; +import type { CopilotKitCore } from "./core"; +import type { CopilotKitCoreGetSuggestionsResult } from "./core"; +import { CopilotKitCoreFriendsAccess } from "./core"; +import { DynamicSuggestionsConfig, StaticSuggestionsConfig, Suggestion, SuggestionsConfig } from "../types"; + +/** + * Manages suggestion generation, streaming, and lifecycle for CopilotKitCore. + * Handles both dynamic (AI-generated) and static suggestions. + */ +export class SuggestionEngine { + private _suggestionsConfig: Record = {}; + private _suggestions: Record> = {}; + private _runningSuggestions: Record = {}; + + constructor(private core: CopilotKitCore) {} + + /** + * Initialize with suggestion configs + */ + initialize(suggestionsConfig: SuggestionsConfig[]): void { + for (const config of suggestionsConfig) { + this._suggestionsConfig[randomUUID()] = config; + } + } + + /** + * Add a suggestion configuration + * @returns The ID of the created config + */ + addSuggestionsConfig(config: SuggestionsConfig): string { + const id = randomUUID(); + this._suggestionsConfig[id] = config; + void this.notifySuggestionsConfigChanged(); + return id; + } + + /** + * Remove a suggestion configuration by ID + */ + removeSuggestionsConfig(id: string): void { + delete this._suggestionsConfig[id]; + void this.notifySuggestionsConfigChanged(); + } + + /** + * Reload suggestions for a specific agent + * This triggers generation of new suggestions based on current configs + */ + public reloadSuggestions(agentId: string): void { + this.clearSuggestions(agentId); + + // Get agent to check message count for availability filtering + const agent = (this.core as unknown as CopilotKitCoreFriendsAccess).getAgent(agentId); + if (!agent) { + return; + } + + const messageCount = agent.messages?.length ?? 0; + let hasAnySuggestions = false; + + for (const config of Object.values(this._suggestionsConfig)) { + // Check if config applies to this agent + if ( + config.consumerAgentId !== undefined && + config.consumerAgentId !== "*" && + config.consumerAgentId !== agentId + ) { + continue; + } + + // Check availability based on message count + if (!this.shouldShowSuggestions(config, messageCount)) { + continue; + } + + const suggestionId = randomUUID(); + + if (isDynamicSuggestionsConfig(config)) { + if (!hasAnySuggestions) { + hasAnySuggestions = true; + void this.notifySuggestionsStartedLoading(agentId); + } + void this.generateSuggestions(suggestionId, config, agentId); + } else if (isStaticSuggestionsConfig(config)) { + this.addStaticSuggestions(suggestionId, config, agentId); + } + } + } + + /** + * Clear all suggestions for a specific agent + */ + public clearSuggestions(agentId: string): void { + const runningAgents = this._runningSuggestions[agentId]; + if (runningAgents) { + for (const agent of runningAgents) { + agent.abortRun(); + } + delete this._runningSuggestions[agentId]; + } + this._suggestions[agentId] = {}; + + void this.notifySuggestionsChanged(agentId, []); + } + + /** + * Get current suggestions for an agent + */ + public getSuggestions(agentId: string): CopilotKitCoreGetSuggestionsResult { + const suggestions = Object.values(this._suggestions[agentId] ?? {}).flat(); + const isLoading = (this._runningSuggestions[agentId]?.length ?? 0) > 0; + return { suggestions, isLoading }; + } + + /** + * Generate suggestions using a provider agent + */ + private async generateSuggestions( + suggestionId: string, + config: DynamicSuggestionsConfig, + consumerAgentId: string, + ): Promise { + let agent: AbstractAgent | undefined = undefined; + try { + const suggestionsProviderAgent = (this.core as unknown as CopilotKitCoreFriendsAccess).getAgent( + config.providerAgentId ?? "default", + ); + if (!suggestionsProviderAgent) { + throw new Error(`Suggestions provider agent not found: ${config.providerAgentId}`); + } + const suggestionsConsumerAgent = (this.core as unknown as CopilotKitCoreFriendsAccess).getAgent(consumerAgentId); + if (!suggestionsConsumerAgent) { + throw new Error(`Suggestions consumer agent not found: ${consumerAgentId}`); + } + + const clonedAgent: AbstractAgent = suggestionsProviderAgent.clone(); + agent = clonedAgent; + //agent.agentId = suggestionId; + agent.threadId = suggestionId; + agent.messages = JSON.parse(JSON.stringify(suggestionsConsumerAgent.messages)); + agent.state = JSON.parse(JSON.stringify(suggestionsConsumerAgent.state)); + + // Initialize suggestion storage for this agent/suggestion combo + this._suggestions[consumerAgentId] = { + ...(this._suggestions[consumerAgentId] ?? {}), + [suggestionId]: [], + }; + this._runningSuggestions[consumerAgentId] = [...(this._runningSuggestions[consumerAgentId] ?? []), agent]; + + agent.addMessage({ + id: suggestionId, + role: "user", + content: [ + `Suggest what the user could say next. Provide clear, highly relevant suggestions by calling the \`copilotkitSuggest\` tool.`, + `Provide at least ${config.minSuggestions ?? 1} and at most ${config.maxSuggestions ?? 3} suggestions.`, + `The user has the following tools available: ${JSON.stringify((this.core as unknown as CopilotKitCoreFriendsAccess).buildFrontendTools(consumerAgentId))}.`, + ` ${config.instructions}`, + ].join("\n"), + }); + + await agent.runAgent( + { + context: Object.values((this.core as unknown as CopilotKitCoreFriendsAccess).context), + forwardedProps: { + ...(this.core as unknown as CopilotKitCoreFriendsAccess).properties, + toolChoice: { type: "function", function: { name: "copilotkitSuggest" } }, + }, + tools: [SUGGEST_TOOL], + }, + { + onMessagesChanged: ({ messages }: { messages: Message[] }) => { + this.extractSuggestions(messages, suggestionId, consumerAgentId, true); + }, + }, + ); + } catch (error) { + console.warn("Error generating suggestions:", error); + } finally { + // Finalize suggestions by marking them as no longer loading + this.finalizeSuggestions(suggestionId, consumerAgentId); + + // Remove this agent from running suggestions + const runningAgents = this._runningSuggestions[consumerAgentId]; + if (agent && runningAgents) { + const filteredAgents = runningAgents.filter((a) => a !== agent); + this._runningSuggestions[consumerAgentId] = filteredAgents; + + // If no more suggestions are running, emit loading end event + if (filteredAgents.length === 0) { + delete this._runningSuggestions[consumerAgentId]; + await this.notifySuggestionsFinishedLoading(consumerAgentId); + } + } + } + } + + /** + * Finalize suggestions by marking them as no longer loading + */ + private finalizeSuggestions(suggestionId: string, consumerAgentId: string): void { + const agentSuggestions = this._suggestions[consumerAgentId]; + const currentSuggestions = agentSuggestions?.[suggestionId]; + + if (agentSuggestions && currentSuggestions && currentSuggestions.length > 0) { + // Filter out empty suggestions and mark remaining as no longer loading + const finalizedSuggestions = currentSuggestions + .filter((suggestion) => suggestion.title !== "" || suggestion.message !== "") + .map((suggestion) => ({ + ...suggestion, + isLoading: false, + })); + + if (finalizedSuggestions.length > 0) { + agentSuggestions[suggestionId] = finalizedSuggestions; + } else { + delete agentSuggestions[suggestionId]; + } + + // Get all aggregated suggestions for this agent + const allSuggestions = Object.values(this._suggestions[consumerAgentId] ?? {}).flat(); + + void this.notifySuggestionsChanged(consumerAgentId, allSuggestions, "finalized"); + } + } + + /** + * Extract suggestions from messages (called during streaming) + */ + extractSuggestions(messages: Message[], suggestionId: string, consumerAgentId: string, isRunning: boolean): void { + const idx = messages.findIndex((message) => message.id === suggestionId); + if (idx == -1) { + return; + } + + const suggestions: Suggestion[] = []; + const newMessages = messages.slice(idx + 1); + + for (const message of newMessages) { + if (message.role === "assistant" && message.toolCalls) { + for (const toolCall of message.toolCalls) { + if (toolCall.function.name === "copilotkitSuggest") { + // Join all argument chunks into a single string for parsing + // arguments can be either a string or an array of strings + const fullArgs = Array.isArray(toolCall.function.arguments) + ? toolCall.function.arguments.join("") + : toolCall.function.arguments; + const parsed = partialJSONParse(fullArgs); + if (parsed && typeof parsed === "object" && "suggestions" in parsed) { + const parsedSuggestions = (parsed as any).suggestions; + if (Array.isArray(parsedSuggestions)) { + for (const item of parsedSuggestions) { + if (item && typeof item === "object" && "title" in item) { + suggestions.push({ + title: item.title ?? "", + message: item.message ?? "", + isLoading: false, // Will be set correctly below + }); + } + } + } + } + } + } + } + } + + // Set isLoading for the last suggestion if still running + if (isRunning && suggestions.length > 0) { + suggestions[suggestions.length - 1]!.isLoading = true; + } + + const agentSuggestions = this._suggestions[consumerAgentId]; + if (agentSuggestions) { + agentSuggestions[suggestionId] = suggestions; + + // Get all aggregated suggestions for this agent + const allSuggestions = Object.values(this._suggestions[consumerAgentId] ?? {}).flat(); + + void this.notifySuggestionsChanged(consumerAgentId, allSuggestions, "suggestions changed"); + } + } + + /** + * Notify subscribers of suggestions config changes + */ + private async notifySuggestionsConfigChanged(): Promise { + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onSuggestionsConfigChanged?.({ + copilotkit: this.core, + suggestionsConfig: this._suggestionsConfig, + }), + "Subscriber onSuggestionsConfigChanged error:", + ); + } + + /** + * Notify subscribers of suggestions changes + */ + private async notifySuggestionsChanged( + agentId: string, + suggestions: Suggestion[], + context: string = "", + ): Promise { + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onSuggestionsChanged?.({ + copilotkit: this.core, + agentId, + suggestions, + }), + `Subscriber onSuggestionsChanged error: ${context}`, + ); + } + + /** + * Notify subscribers that suggestions started loading + */ + private async notifySuggestionsStartedLoading(agentId: string): Promise { + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onSuggestionsStartedLoading?.({ + copilotkit: this.core, + agentId, + }), + "Subscriber onSuggestionsStartedLoading error:", + ); + } + + /** + * Notify subscribers that suggestions finished loading + */ + private async notifySuggestionsFinishedLoading(agentId: string): Promise { + await (this.core as unknown as CopilotKitCoreFriendsAccess).notifySubscribers( + (subscriber) => + subscriber.onSuggestionsFinishedLoading?.({ + copilotkit: this.core, + agentId, + }), + "Subscriber onSuggestionsFinishedLoading error:", + ); + } + + /** + * Check if suggestions should be shown based on availability and message count + */ + private shouldShowSuggestions(config: SuggestionsConfig, messageCount: number): boolean { + const availability = config.available; + + // Default behavior if no availability specified + if (!availability) { + if (isDynamicSuggestionsConfig(config)) { + return messageCount > 0; // Default: after-first-message + } else { + return messageCount === 0; // Default: before-first-message + } + } + + switch (availability) { + case "disabled": + return false; + case "before-first-message": + return messageCount === 0; + case "after-first-message": + return messageCount > 0; + case "always": + return true; + default: + return false; + } + } + + /** + * Add static suggestions directly without AI generation + */ + private addStaticSuggestions(suggestionId: string, config: StaticSuggestionsConfig, consumerAgentId: string): void { + // Mark all as not loading since they're static + const suggestions = config.suggestions.map((s) => ({ + ...s, + isLoading: false, + })); + + // Store suggestions + this._suggestions[consumerAgentId] = { + ...(this._suggestions[consumerAgentId] ?? {}), + [suggestionId]: suggestions, + }; + + // Notify subscribers + const allSuggestions = Object.values(this._suggestions[consumerAgentId] ?? {}).flat(); + + void this.notifySuggestionsChanged(consumerAgentId, allSuggestions, "static suggestions added"); + } +} + +/** + * Type guard for dynamic suggestions config + */ +function isDynamicSuggestionsConfig(config: SuggestionsConfig): config is DynamicSuggestionsConfig { + return "instructions" in config; +} + +/** + * Type guard for static suggestions config + */ +function isStaticSuggestionsConfig(config: SuggestionsConfig): config is StaticSuggestionsConfig { + return "suggestions" in config; +} + +/** + * The tool definition for AI-generated suggestions + */ +const SUGGEST_TOOL: Tool = { + name: "copilotkitSuggest", + description: "Suggest what the user could say next", + parameters: { + type: "object", + properties: { + suggestions: { + type: "array", + description: "List of suggestions shown to the user as buttons.", + items: { + type: "object", + properties: { + title: { + type: "string", + description: "The title of the suggestion. This is shown as a button and should be short.", + }, + message: { + type: "string", + description: + "The message to send when the suggestion is clicked. This should be a clear, complete sentence " + + "and will be sent as an instruction to the AI.", + }, + }, + required: ["title", "message"], + }, + }, + }, + required: ["suggestions"], + }, +}; diff --git a/src/v2.x/packages/core/src/index.ts b/src/v2.x/packages/core/src/index.ts new file mode 100644 index 0000000000..5dd8618c51 --- /dev/null +++ b/src/v2.x/packages/core/src/index.ts @@ -0,0 +1,4 @@ +export * from "./core"; +export * from "./types"; +export * from "./agent"; +export * from "./utils/markdown"; diff --git a/src/v2.x/packages/core/src/types.ts b/src/v2.x/packages/core/src/types.ts new file mode 100644 index 0000000000..cd29f4f316 --- /dev/null +++ b/src/v2.x/packages/core/src/types.ts @@ -0,0 +1,86 @@ +import { ToolCall } from "@ag-ui/client"; +import { z } from "zod"; + +/** + * Status of a tool call execution + */ +export enum ToolCallStatus { + InProgress = "inProgress", + Executing = "executing", + Complete = "complete", +} + +export type CopilotRuntimeTransport = "rest" | "single"; + +export type FrontendTool = Record> = { + name: string; + description?: string; + parameters?: z.ZodType; + handler?: (args: T, toolCall: ToolCall) => Promise; + followUp?: boolean; + /** + * Optional agent ID to constrain this tool to a specific agent. + * If specified, this tool will only be available to the specified agent. + */ + agentId?: string; +}; + +export type Suggestion = { + title: string; + message: string; + /** Indicates whether this suggestion is still being generated. */ + isLoading: boolean; +}; + +export type SuggestionAvailability = "before-first-message" | "after-first-message" | "always" | "disabled"; + +export type DynamicSuggestionsConfig = { + /** + * A prompt or instructions for the GPT to generate suggestions. + */ + instructions: string; + /** + * The minimum number of suggestions to generate. Defaults to `1`. + * @default 1 + */ + minSuggestions?: number; + /** + * The maximum number of suggestions to generate. Defaults to `3`. + * @default 1 + */ + maxSuggestions?: number; + + /** + * When the suggestions are available. Defaults to "after-first-message". + */ + available?: SuggestionAvailability; + + /** + * The agent ID of the provider of the suggestions. Defaults to `"default"`. + */ + providerAgentId?: string; + + /** + * The agent ID of the consumer of the suggestions. Defaults to `"*"` (all agents). + */ + consumerAgentId?: string; +}; + +export type StaticSuggestionsConfig = { + /** + * The suggestions to display. + */ + suggestions: Omit[]; + + /** + * When the suggestions are available. Defaults to "before-first-message". + */ + available?: SuggestionAvailability; + + /** + * The agent ID of the consumer of the suggestions. Defaults to `"*"` (all agents). + */ + consumerAgentId?: string; +}; + +export type SuggestionsConfig = DynamicSuggestionsConfig | StaticSuggestionsConfig; diff --git a/src/v2.x/packages/core/src/utils/markdown.ts b/src/v2.x/packages/core/src/utils/markdown.ts new file mode 100644 index 0000000000..6383663551 --- /dev/null +++ b/src/v2.x/packages/core/src/utils/markdown.ts @@ -0,0 +1,271 @@ +export function completePartialMarkdown(input: string): string { + let s = input; + + // Handle code fences first - use FIRST unmatched fence for proper nesting + const fenceMatches = Array.from(s.matchAll(/^(\s*)(`{3,}|~{3,})/gm)); + if (fenceMatches.length % 2 === 1) { + const [, indent, fence] = fenceMatches[0]!; + s += `\n${indent}${fence}`; + } + + // Identify incomplete links at the end and close them + const incompleteLinkMatch = s.match(/\[([^\]]*)\]\(([^)]*)$/); + if (incompleteLinkMatch) { + s += ")"; + } + + // State-based parsing + interface OpenElement { + type: string; + marker: string; + position: number; + } + + const openElements: OpenElement[] = []; + const chars = Array.from(s); + + // First pass: identify code block boundaries and inline code to avoid processing their content + const codeBlockRanges: Array<{ start: number; end: number }> = []; + const inlineCodeRanges: Array<{ start: number; end: number }> = []; + + // Find code block ranges + let tempCodeFenceCount = 0; + let currentCodeBlockStart = -1; + + for (let i = 0; i < chars.length; i++) { + if (i === 0 || chars[i - 1] === "\n") { + const lineMatch = s.substring(i).match(/^(\s*)(`{3,}|~{3,})/); + if (lineMatch) { + tempCodeFenceCount++; + if (tempCodeFenceCount % 2 === 1) { + currentCodeBlockStart = i; + } else if (currentCodeBlockStart !== -1) { + codeBlockRanges.push({ + start: currentCodeBlockStart, + end: i + lineMatch[0].length, + }); + currentCodeBlockStart = -1; + } + i += lineMatch[0].length - 1; + } + } + } + + // Find inline code ranges + for (let i = 0; i < chars.length; i++) { + if (chars[i] === "`") { + // Check if escaped + let backslashCount = 0; + for (let j = i - 1; j >= 0 && chars[j] === "\\"; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + // Not escaped - find the closing backtick + for (let j = i + 1; j < chars.length; j++) { + if (chars[j] === "`") { + let closingBackslashCount = 0; + for (let k = j - 1; k >= 0 && chars[k] === "\\"; k--) { + closingBackslashCount++; + } + if (closingBackslashCount % 2 === 0) { + inlineCodeRanges.push({ start: i, end: j + 1 }); + i = j; + break; + } + } + } + } + } + } + + // Helper function to check if position is in code + const isInCode = (pos: number): boolean => { + return ( + codeBlockRanges.some((range) => pos >= range.start && pos < range.end) || + inlineCodeRanges.some((range) => pos >= range.start && pos < range.end) + ); + }; + + // Second pass: process markdown elements, skipping code regions + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + const nextChar = chars[i + 1]; + const prevChar = chars[i - 1]; + + if (isInCode(i)) { + continue; + } + + // Handle brackets (but not if they're part of already-complete links) + if (char === "[") { + // Check if this is part of a complete link [text](url) + let isCompleteLink = false; + let bracketDepth = 1; + let j = i + 1; + + // Find the matching ] + while (j < chars.length && bracketDepth > 0) { + if (chars[j] === "[" && !isInCode(j)) bracketDepth++; + if (chars[j] === "]" && !isInCode(j)) bracketDepth--; + j++; + } + + // Check if followed by ( + if (bracketDepth === 0 && chars[j] === "(") { + // Find the closing ) + let parenDepth = 1; + j++; + while (j < chars.length && parenDepth > 0) { + if (chars[j] === "(" && !isInCode(j)) parenDepth++; + if (chars[j] === ")" && !isInCode(j)) parenDepth--; + j++; + } + if (parenDepth === 0) { + isCompleteLink = true; + i = j - 1; + continue; + } + } + + // This is a standalone bracket, treat as markdown + if (!isCompleteLink) { + const existingIndex = openElements.findIndex( + (el) => el.type === "bracket" + ); + if (existingIndex !== -1) { + openElements.splice(existingIndex, 1); + } else { + openElements.push({ type: "bracket", marker: "[", position: i }); + } + } + } + + // Handle double emphasis first (**, __, ~~) - these take precedence + else if (char === "*" && nextChar === "*") { + const existingIndex = openElements.findIndex( + (el) => el.type === "bold_star" + ); + if (existingIndex !== -1) { + openElements.splice(existingIndex, 1); + } else { + openElements.push({ type: "bold_star", marker: "**", position: i }); + } + i++; // Skip next character + } else if (char === "_" && nextChar === "_") { + const existingIndex = openElements.findIndex( + (el) => el.type === "bold_underscore" + ); + if (existingIndex !== -1) { + openElements.splice(existingIndex, 1); + } else { + openElements.push({ + type: "bold_underscore", + marker: "__", + position: i, + }); + } + i++; // Skip next character + } else if (char === "~" && nextChar === "~") { + const existingIndex = openElements.findIndex( + (el) => el.type === "strike" + ); + if (existingIndex !== -1) { + openElements.splice(existingIndex, 1); + } else { + openElements.push({ type: "strike", marker: "~~", position: i }); + } + i++; // Skip next character + } + + // Handle single emphasis (*, _) - only if not part of double + else if (char === "*" && prevChar !== "*" && nextChar !== "*") { + const existingIndex = openElements.findIndex( + (el) => el.type === "italic_star" + ); + if (existingIndex !== -1) { + openElements.splice(existingIndex, 1); + } else { + openElements.push({ type: "italic_star", marker: "*", position: i }); + } + } else if (char === "_" && prevChar !== "_" && nextChar !== "_") { + const existingIndex = openElements.findIndex( + (el) => el.type === "italic_underscore" + ); + if (existingIndex !== -1) { + openElements.splice(existingIndex, 1); + } else { + openElements.push({ + type: "italic_underscore", + marker: "_", + position: i, + }); + } + } + } + + // Handle remaining unmatched backticks (outside of inline code ranges) + let backtickCount = 0; + for (let i = 0; i < chars.length; i++) { + if (chars[i] === "`" && !isInCode(i)) { + backtickCount++; + } + } + if (backtickCount % 2 === 1) { + s += "`"; + } + + // Close remaining open elements in reverse order (LIFO stack semantics) + openElements.sort((a, b) => b.position - a.position); + + const closers = openElements.map((el) => { + switch (el.type) { + case "bracket": + return "]"; + case "bold_star": + return "**"; + case "bold_underscore": + return "__"; + case "strike": + return "~~"; + case "italic_star": + return "*"; + case "italic_underscore": + return "_"; + default: + return ""; + } + }); + + let result = s + closers.join(""); + + // Handle parentheses ONLY if not inside code + const finalFenceMatches = Array.from( + result.matchAll(/^(\s*)(`{3,}|~{3,})/gm) + ); + const hasUnclosedBacktick = (result.match(/`/g) || []).length % 2 === 1; + const hasUnclosedCodeFence = finalFenceMatches.length % 2 === 1; + + let shouldCloseParens = !hasUnclosedBacktick && !hasUnclosedCodeFence; + + if (shouldCloseParens) { + const lastOpenParen = result.lastIndexOf("("); + if (lastOpenParen !== -1) { + // Check if this paren is inside a backtick pair + const beforeParen = result.substring(0, lastOpenParen); + const backticksBeforeParen = (beforeParen.match(/`/g) || []).length; + if (backticksBeforeParen % 2 === 1) { + shouldCloseParens = false; + } + } + } + + if (shouldCloseParens) { + const openParens = (result.match(/\(/g) || []).length; + const closeParens = (result.match(/\)/g) || []).length; + if (openParens > closeParens) { + result += ")".repeat(openParens - closeParens); + } + } + + return result; +} \ No newline at end of file diff --git a/src/v2.x/packages/core/tsconfig.json b/src/v2.x/packages/core/tsconfig.json new file mode 100644 index 0000000000..c3ccee0fe5 --- /dev/null +++ b/src/v2.x/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@copilotkitnext/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/src/v2.x/packages/core/tsup.config.ts b/src/v2.x/packages/core/tsup.config.ts new file mode 100644 index 0000000000..c43d98b97e --- /dev/null +++ b/src/v2.x/packages/core/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'es2022', + outDir: 'dist', +}); \ No newline at end of file diff --git a/src/v2.x/packages/core/vitest.config.mjs b/src/v2.x/packages/core/vitest.config.mjs new file mode 100644 index 0000000000..7cd1a28456 --- /dev/null +++ b/src/v2.x/packages/core/vitest.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["src/**/__tests__/**/*.{test,spec}.ts"], + coverage: { + reporter: ["text", "lcov", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/__tests__/**"], + }, + setupFiles: [], + }, +}); \ No newline at end of file diff --git a/src/v2.x/packages/demo-agents/package.json b/src/v2.x/packages/demo-agents/package.json new file mode 100644 index 0000000000..c0ae507b87 --- /dev/null +++ b/src/v2.x/packages/demo-agents/package.json @@ -0,0 +1,33 @@ +{ + "name": "@copilotkitnext/demo-agents", + "version": "0.0.0", + "private": true, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "check-types": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@ag-ui/client": "*", + "openai": "^4.73.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@copilotkitnext/eslint-config": "workspace:*", + "@copilotkitnext/typescript-config": "workspace:*", + "@types/node": "^22.10.5", + "eslint": "^9.17.0", + "tsup": "^8.0.1", + "typescript": "^5.8.2", + "vitest": "^2.1.8" + } +} \ No newline at end of file diff --git a/src/v2.x/packages/demo-agents/src/index.ts b/src/v2.x/packages/demo-agents/src/index.ts new file mode 100644 index 0000000000..14a6ff7257 --- /dev/null +++ b/src/v2.x/packages/demo-agents/src/index.ts @@ -0,0 +1,2 @@ +export { OpenAIAgent } from "./openai"; +export { SlowToolCallStreamingAgent } from "./slow-tool-call-streaming"; diff --git a/src/v2.x/packages/demo-agents/src/openai.ts b/src/v2.x/packages/demo-agents/src/openai.ts new file mode 100644 index 0000000000..492d249200 --- /dev/null +++ b/src/v2.x/packages/demo-agents/src/openai.ts @@ -0,0 +1,99 @@ +import { + AbstractAgent, + RunAgentInput, + EventType, + BaseEvent, +} from "@ag-ui/client"; +import { Observable } from "rxjs"; +import { OpenAI } from "openai"; + +export class OpenAIAgent extends AbstractAgent { + private openai: OpenAI; + + constructor(openai?: OpenAI) { + super(); + this.openai = openai ?? new OpenAI(); + } + + clone(): OpenAIAgent { + return new OpenAIAgent(this.openai); + } + + protected run(input: RunAgentInput): Observable { + return new Observable((observer) => { + observer.next({ + type: EventType.RUN_STARTED, + threadId: input.threadId, + runId: input.runId, + } as BaseEvent); + + this.openai.chat.completions + .create({ + model: "gpt-4o", + stream: true, + tools: input.tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + })), + messages: input.messages.map((message) => { + if (message.role === "tool") { + return { + role: "tool" as const, + content: message.content ?? "", + tool_call_id: message.toolCallId ?? "", + }; + } else if (message.role === "assistant" && message.toolCalls) { + return { + role: "assistant" as const, + content: message.content ?? "", + tool_calls: message.toolCalls, + }; + } else { + return { + role: message.role as "system" | "user" | "assistant", + content: message.content ?? "", + }; + } + }), + }) + .then(async (response) => { + const messageId = Date.now().toString(); + for await (const chunk of response) { + if (chunk.choices[0]?.delta?.content) { + observer.next({ + type: EventType.TEXT_MESSAGE_CHUNK, + messageId, + delta: chunk.choices[0].delta.content, + } as BaseEvent); + } else if (chunk.choices[0]?.delta?.tool_calls?.[0]) { + const toolCall = chunk.choices[0].delta.tool_calls[0]; + observer.next({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId: toolCall.id, + toolCallName: toolCall.function?.name, + parentMessageId: messageId, + delta: toolCall.function?.arguments, + } as BaseEvent); + } + } + observer.next({ + type: EventType.RUN_FINISHED, + threadId: input.threadId, + runId: input.runId, + } as BaseEvent); + observer.complete(); + }) + .catch((error) => { + observer.next({ + type: EventType.RUN_ERROR, + message: error.message, + } as BaseEvent); + observer.error(error); + }); + }); + } +} diff --git a/src/v2.x/packages/demo-agents/src/slow-tool-call-streaming.ts b/src/v2.x/packages/demo-agents/src/slow-tool-call-streaming.ts new file mode 100644 index 0000000000..61f9370169 --- /dev/null +++ b/src/v2.x/packages/demo-agents/src/slow-tool-call-streaming.ts @@ -0,0 +1,127 @@ +import { AbstractAgent, RunAgentInput, EventType, BaseEvent, ToolCallResultEvent } from "@ag-ui/client"; +import { Observable } from "rxjs"; + +export class SlowToolCallStreamingAgent extends AbstractAgent { + private delay = 200; // 0.2 seconds delay between chunks + + constructor(delayMs: number = 200) { + super(); + this.delay = delayMs; + } + + clone(): SlowToolCallStreamingAgent { + return new SlowToolCallStreamingAgent(this.delay); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + protected run(input: RunAgentInput): Observable { + return new Observable((observer) => { + let cancelled = false; + + const runAsync = async () => { + await this.sleep(1000); + try { + const messageId = Date.now().toString(); + const toolCallId = `call_${Date.now()}`; + + // Start the run + observer.next({ + type: EventType.RUN_STARTED, + threadId: input.threadId, + runId: input.runId, + } as BaseEvent); + + // Stream the initial text message + const textMessage = "I'll check the weather for you. Let me fetch that information ok?."; + const chunks = textMessage.split(" "); + + for (let i = 0; i < chunks.length; i++) { + if (cancelled) return; + observer.next({ + type: EventType.TEXT_MESSAGE_CHUNK, + messageId, + delta: (i > 0 ? " " : "") + chunks[i], + } as BaseEvent); + await this.sleep(this.delay); + } + + // Stream the tool call + const toolCallArgs = JSON.stringify({ + location: "San Francisco", + unit: "celsius", + }); + const toolCallChunks: string[] = []; + for (let i = 0; i < toolCallArgs.length; i += 5) { + toolCallChunks.push(toolCallArgs.slice(i, i + 5)); + } + + for (let i = 0; i < toolCallChunks.length; i++) { + if (cancelled) return; + if (i === 0) { + // First chunk includes tool name + observer.next({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + toolCallName: "getWeather", + parentMessageId: messageId, + delta: toolCallChunks[0], + } as BaseEvent); + } else { + // Subsequent chunks only include arguments + observer.next({ + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + parentMessageId: messageId, + delta: toolCallChunks[i], + } as BaseEvent); + } + await this.sleep(this.delay); + } + + // Send tool result + if (cancelled) return; + const toolResultMessageId = `${Date.now()}_tool_result`; + observer.next({ + type: EventType.TOOL_CALL_RESULT, + toolCallId, + content: JSON.stringify({ + temperature: 18, + unit: "celsius", + condition: "partly cloudy", + humidity: 65, + windSpeed: 12, + }), + messageId: toolResultMessageId, + } as ToolCallResultEvent); + + // Complete the run + if (cancelled) return; + observer.next({ + type: EventType.RUN_FINISHED, + threadId: input.threadId, + runId: input.runId, + } as BaseEvent); + observer.complete(); + } catch (error) { + if (!cancelled) { + observer.next({ + type: EventType.RUN_ERROR, + message: error instanceof Error ? error.message : "Unknown error occurred", + } as BaseEvent); + observer.error(error); + } + } + }; + + runAsync(); + + // Cleanup function + return () => { + cancelled = true; + }; + }); + } +} diff --git a/src/v2.x/packages/demo-agents/tsconfig.json b/src/v2.x/packages/demo-agents/tsconfig.json new file mode 100644 index 0000000000..6f041104a4 --- /dev/null +++ b/src/v2.x/packages/demo-agents/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@copilotkitnext/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} \ No newline at end of file diff --git a/src/v2.x/packages/demo-agents/tsup.config.ts b/src/v2.x/packages/demo-agents/tsup.config.ts new file mode 100644 index 0000000000..44a9f8bc97 --- /dev/null +++ b/src/v2.x/packages/demo-agents/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + sourcemap: true, + external: ["@ag-ui/client", "openai", "rxjs"], +}); \ No newline at end of file diff --git a/src/v2.x/packages/demo-agents/vitest.config.ts b/src/v2.x/packages/demo-agents/vitest.config.ts new file mode 100644 index 0000000000..ed9db65d17 --- /dev/null +++ b/src/v2.x/packages/demo-agents/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + passWithNoTests: true, + }, +}); diff --git a/src/v2.x/packages/eslint-config/README.md b/src/v2.x/packages/eslint-config/README.md new file mode 100644 index 0000000000..8b42d901b0 --- /dev/null +++ b/src/v2.x/packages/eslint-config/README.md @@ -0,0 +1,3 @@ +# `@turbo/eslint-config` + +Collection of internal eslint configurations. diff --git a/src/v2.x/packages/eslint-config/base.js b/src/v2.x/packages/eslint-config/base.js new file mode 100644 index 0000000000..09d316efdb --- /dev/null +++ b/src/v2.x/packages/eslint-config/base.js @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import turboPlugin from "eslint-plugin-turbo"; +import tseslint from "typescript-eslint"; +import onlyWarn from "eslint-plugin-only-warn"; + +/** + * A shared ESLint configuration for the repository. + * + * @type {import("eslint").Linter.Config[]} + * */ +export const config = [ + js.configs.recommended, + eslintConfigPrettier, + ...tseslint.configs.recommended, + { + plugins: { + turbo: turboPlugin, + }, + rules: { + "turbo/no-undeclared-env-vars": "warn", + }, + }, + { + plugins: { + onlyWarn, + }, + }, + { + ignores: ["dist/**"], + }, +]; diff --git a/src/v2.x/packages/eslint-config/next.js b/src/v2.x/packages/eslint-config/next.js new file mode 100644 index 0000000000..6bf01a74ed --- /dev/null +++ b/src/v2.x/packages/eslint-config/next.js @@ -0,0 +1,49 @@ +import js from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import tseslint from "typescript-eslint"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginReact from "eslint-plugin-react"; +import globals from "globals"; +import pluginNext from "@next/eslint-plugin-next"; +import { config as baseConfig } from "./base.js"; + +/** + * A custom ESLint configuration for libraries that use Next.js. + * + * @type {import("eslint").Linter.Config[]} + * */ +export const nextJsConfig = [ + ...baseConfig, + js.configs.recommended, + eslintConfigPrettier, + ...tseslint.configs.recommended, + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.serviceworker, + }, + }, + }, + { + plugins: { + "@next/next": pluginNext, + }, + rules: { + ...pluginNext.configs.recommended.rules, + ...pluginNext.configs["core-web-vitals"].rules, + }, + }, + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { react: { version: "detect" } }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + // React scope no longer necessary with new JSX transform. + "react/react-in-jsx-scope": "off", + }, + }, +]; diff --git a/src/v2.x/packages/eslint-config/package.json b/src/v2.x/packages/eslint-config/package.json new file mode 100644 index 0000000000..2c3a6dcdc3 --- /dev/null +++ b/src/v2.x/packages/eslint-config/package.json @@ -0,0 +1,24 @@ +{ + "name": "@copilotkitnext/eslint-config", + "version": "0.0.0", + "type": "module", + "private": true, + "exports": { + "./base": "./base.js", + "./next-js": "./next.js", + "./react-internal": "./react-internal.js" + }, + "devDependencies": { + "@eslint/js": "^9.30.0", + "@next/eslint-plugin-next": "^15.3.0", + "eslint": "^9.30.0", + "eslint-config-prettier": "^10.1.1", + "eslint-plugin-only-warn": "^1.1.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-turbo": "^2.5.0", + "globals": "^16.2.0", + "typescript": "^5.8.2", + "typescript-eslint": "^8.35.0" + } +} diff --git a/src/v2.x/packages/eslint-config/react-internal.js b/src/v2.x/packages/eslint-config/react-internal.js new file mode 100644 index 0000000000..daeccba24d --- /dev/null +++ b/src/v2.x/packages/eslint-config/react-internal.js @@ -0,0 +1,39 @@ +import js from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import tseslint from "typescript-eslint"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginReact from "eslint-plugin-react"; +import globals from "globals"; +import { config as baseConfig } from "./base.js"; + +/** + * A custom ESLint configuration for libraries that use React. + * + * @type {import("eslint").Linter.Config[]} */ +export const config = [ + ...baseConfig, + js.configs.recommended, + eslintConfigPrettier, + ...tseslint.configs.recommended, + pluginReact.configs.flat.recommended, + { + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.serviceworker, + ...globals.browser, + }, + }, + }, + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { react: { version: "detect" } }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + // React scope no longer necessary with new JSX transform. + "react/react-in-jsx-scope": "off", + }, + }, +]; diff --git a/src/v2.x/packages/react/.npmignore b/src/v2.x/packages/react/.npmignore new file mode 100644 index 0000000000..9fd792176a --- /dev/null +++ b/src/v2.x/packages/react/.npmignore @@ -0,0 +1,40 @@ +# Source files (only publish dist) +src/ +tsup.config.ts +tsconfig.json +vitest.config.ts +tailwind.config.js +postcss.config.js + +# Turbo cache +.turbo/ + +# Test files +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx +**/__tests__/ + +# Config files +.eslintrc.js +.prettierrc + +# IDE +.vscode/ +.idea/ + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# Documentation +*.md +!README.md + +# Git +.git/ +.gitignore \ No newline at end of file diff --git a/src/v2.x/packages/react/components.json b/src/v2.x/packages/react/components.json new file mode 100644 index 0000000000..99ccffe54b --- /dev/null +++ b/src/v2.x/packages/react/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/src/v2.x/packages/react/eslint.config.mjs b/src/v2.x/packages/react/eslint.config.mjs new file mode 100644 index 0000000000..8aced6f096 --- /dev/null +++ b/src/v2.x/packages/react/eslint.config.mjs @@ -0,0 +1,11 @@ +import { config as reactConfig } from "@copilotkitnext/eslint-config/react-internal"; + +export default [ + ...reactConfig, + { + rules: { + // Disable PropTypes validation since we use TypeScript for type checking + "react/prop-types": "off", + }, + }, +]; diff --git a/src/v2.x/packages/react/package.json b/src/v2.x/packages/react/package.json new file mode 100644 index 0000000000..926560f719 --- /dev/null +++ b/src/v2.x/packages/react/package.json @@ -0,0 +1,83 @@ +{ + "name": "@copilotkitnext/react", + "version": "0.0.33", + "description": "React components for CopilotKit2", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./styles.css": "./dist/styles.css" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev:css": "npx @tailwindcss/cli -i ./src/styles/globals.css -o ./dist/styles.css --watch --minify", + "build:css": "npx @tailwindcss/cli -i ./src/styles/globals.css -o ./dist/styles.css -m", + "dev": "concurrently \"npm:dev:css\" \"tsup --watch\"", + "build": "tsup && npm run build:css", + "prepublishOnly": "pnpm run build", + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:ui": "vitest --ui", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@copilotkitnext/eslint-config": "workspace:*", + "@copilotkitnext/typescript-config": "workspace:*", + "@tailwindcss/cli": "^4.1.11", + "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/typography": "^0.5.16", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.0.0", + "@types/node": "^22.15.3", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.0.2", + "@vitest/ui": "^3.2.4", + "autoprefixer": "^10.4.20", + "concurrently": "^9.1.0", + "eslint": "^9.30.0", + "jsdom": "^26.1.0", + "tailwindcss": "^4.0.8", + "rxjs": "^7.8.1", + "tsup": "^8.5.0", + "typescript": "5.8.2", + "vitest": "^3.2.4" + }, + "dependencies": { + "@ag-ui/client": "0.0.42", + "@ag-ui/core": "0.0.42", + "@copilotkitnext/core": "workspace:*", + "@copilotkitnext/shared": "workspace:*", + "@copilotkitnext/web-inspector": "workspace:*", + "@lit-labs/react": "^2.0.2", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "katex": "^0.16.22", + "lucide-react": "^0.525.0", + "streamdown": "^1.3.0", + "tailwind-merge": "^3.3.1", + "ts-deepmerge": "^7.0.3", + "tw-animate-css": "^1.3.5", + "use-stick-to-bottom": "^1.1.1", + "zod": "^3.25.75" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/src/v2.x/packages/react/postcss.config.js b/src/v2.x/packages/react/postcss.config.js new file mode 100644 index 0000000000..fe139bf16f --- /dev/null +++ b/src/v2.x/packages/react/postcss.config.js @@ -0,0 +1,7 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { "@tailwindcss/postcss": {} }, +}; + +// eslint-disable-next-line no-undef +module.exports = config; diff --git a/src/v2.x/packages/react/src/__tests__/setup.ts b/src/v2.x/packages/react/src/__tests__/setup.ts new file mode 100644 index 0000000000..bd80a7cc97 --- /dev/null +++ b/src/v2.x/packages/react/src/__tests__/setup.ts @@ -0,0 +1,89 @@ +// Test setup file for Vitest +// Add any global test configuration here +import { afterEach, vi } from "vitest"; +import { cleanup } from "@testing-library/react"; +import React from "react"; + +// Mock ResizeObserver which is not available in jsdom +global.ResizeObserver = class ResizeObserver { + constructor(callback: ResizeObserverCallback) { + // Store callback for potential future use + this.callback = callback; + } + callback: ResizeObserverCallback; + observe() {} + unobserve() {} + disconnect() {} +}; + +// Mock scrollIntoView which is not available in jsdom +HTMLElement.prototype.scrollIntoView = vi.fn(); + +// Ensure we cleanup between tests to avoid lingering handles +afterEach(() => { + cleanup(); +}); + +// Mock canvas getContext used by audio recorder during tests +HTMLCanvasElement.prototype.getContext = function(contextId: any) { + if (contextId === '2d') { + return { + fillRect: () => {}, + clearRect: () => {}, + getImageData: () => ({ data: [] }), + putImageData: () => {}, + createImageData: () => ({ data: [] }), + setTransform: () => {}, + drawImage: () => {}, + save: () => {}, + restore: () => {}, + beginPath: () => {}, + closePath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + translate: () => {}, + scale: () => {}, + rotate: () => {}, + arc: () => {}, + fill: () => {}, + measureText: (text: string) => ({ width: text.length * 8 }), + transform: () => {}, + rect: () => {}, + clip: () => {}, + } as unknown as CanvasRenderingContext2D; + } + return null; +} as any; + +// Simplify Radix tooltip behavior to avoid act() noise in jsdom +vi.mock("@radix-ui/react-tooltip", async () => { + const forward = ( + renderFn: React.ForwardRefRenderFunction + ) => React.forwardRef(renderFn); + + const SimpleProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => + React.createElement(React.Fragment, null, children); + + const SimplePortal: React.FC<{ children?: React.ReactNode }> = ({ children }) => + React.createElement(React.Fragment, null, children); + + const createWrapper = () => + forward(({ children, asChild, sideOffset: _sideOffset, ...rest }: any, ref) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { ref, ...rest }); + } + return React.createElement("div", { ref, ...rest }, children); + }); + + const passthrough = createWrapper(); + + return { + Provider: SimpleProvider, + Root: passthrough, + Trigger: passthrough, + Content: passthrough, + Portal: SimplePortal, + Arrow: () => null, + }; +}); diff --git a/src/v2.x/packages/react/src/__tests__/utils/test-helpers.tsx b/src/v2.x/packages/react/src/__tests__/utils/test-helpers.tsx new file mode 100644 index 0000000000..e1669eccda --- /dev/null +++ b/src/v2.x/packages/react/src/__tests__/utils/test-helpers.tsx @@ -0,0 +1,323 @@ +import React from "react"; +import { render, act } from "@testing-library/react"; +import { CopilotKitProvider } from "@/providers/CopilotKitProvider"; +import { CopilotChat } from "@/components/chat/CopilotChat"; +import { CopilotChatConfigurationProvider } from "@/providers/CopilotChatConfigurationProvider"; +import { DEFAULT_AGENT_ID } from "@copilotkitnext/shared"; +import { + AbstractAgent, + EventType, + type BaseEvent, + type RunAgentInput, +} from "@ag-ui/client"; +import { Observable, Subject } from "rxjs"; +import { ReactActivityMessageRenderer, ReactToolCallRenderer } from "@/types"; +import { ReactCustomMessageRenderer } from "@/types/react-custom-message-renderer"; + +/** + * A controllable mock agent for deterministic E2E testing. + * Exposes emit() and complete() methods to drive agent events step-by-step. + */ +export class MockStepwiseAgent extends AbstractAgent { + private subject = new Subject(); + + /** + * Emit a single agent event + */ + emit(event: BaseEvent) { + if (event.type === EventType.RUN_STARTED) { + this.isRunning = true; + } else if ( + event.type === EventType.RUN_FINISHED || + event.type === EventType.RUN_ERROR + ) { + this.isRunning = false; + } + act(() => { + this.subject.next(event); + }); + } + + /** + * Complete the agent stream + */ + complete() { + this.isRunning = false; + act(() => { + this.subject.complete(); + }); + } + + clone(): MockStepwiseAgent { + // For tests, return same instance so we can keep controlling it + return this; + } + + run(_input: RunAgentInput): Observable { + return this.subject.asObservable(); + } +} + +/** + * Helper to render components with CopilotKitProvider for E2E tests + */ +export function renderWithCopilotKit({ + agent, + agents, + renderToolCalls, + renderCustomMessages, + renderActivityMessages, + frontendTools, + humanInTheLoop, + agentId, + threadId, + children, +}: { + agent?: AbstractAgent; + agents?: Record; + renderToolCalls?: ReactToolCallRenderer[]; + renderCustomMessages?: ReactCustomMessageRenderer[]; + renderActivityMessages?: ReactActivityMessageRenderer[]; + frontendTools?: any[]; + humanInTheLoop?: any[]; + agentId?: string; + threadId?: string; + children?: React.ReactNode; +}): ReturnType { + const resolvedAgents = agents || (agent ? { default: agent } : undefined); + const resolvedAgentId = agentId ?? DEFAULT_AGENT_ID; + const resolvedThreadId = threadId ?? "test-thread"; + + return render( + + + {children || ( +
+ +
+ )} +
+
+ ); +} + +/** + * Helper to create a RUN_STARTED event + */ +export function runStartedEvent(): BaseEvent { + return { type: EventType.RUN_STARTED } as BaseEvent; +} + +/** + * Helper to create a RUN_FINISHED event + */ +export function runFinishedEvent(): BaseEvent { + return { type: EventType.RUN_FINISHED } as BaseEvent; +} + +/** + * Helper to create a STATE_SNAPSHOT event + */ +export function stateSnapshotEvent(snapshot: unknown): BaseEvent { + return { + type: EventType.STATE_SNAPSHOT, + snapshot, + } as BaseEvent; +} + +/** + * Helper to create an ACTIVITY_SNAPSHOT event + */ +export function activitySnapshotEvent({ + messageId, + activityType, + content, +}: { + messageId: string; + activityType: string; + content: Record; +}): BaseEvent { + return { + type: EventType.ACTIVITY_SNAPSHOT, + messageId, + activityType, + content, + } as BaseEvent; +} + +/** + * Helper to start an assistant text message + */ +export function textMessageStartEvent( + messageId: string, + role: "assistant" | "developer" | "system" | "user" = "assistant", +): BaseEvent { + return { + type: EventType.TEXT_MESSAGE_START, + messageId, + role, + } as BaseEvent; +} + +/** + * Helper to stream text message content + */ +export function textMessageContentEvent(messageId: string, delta: string): BaseEvent { + return { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta, + } as BaseEvent; +} + +/** + * Helper to end a text message + */ +export function textMessageEndEvent(messageId: string): BaseEvent { + return { + type: EventType.TEXT_MESSAGE_END, + messageId, + } as BaseEvent; +} + +/** + * Helper to create a TEXT_MESSAGE_CHUNK event + */ +export function textChunkEvent(messageId: string, delta: string): BaseEvent { + return { + type: EventType.TEXT_MESSAGE_CHUNK, + messageId, + delta, + } as BaseEvent; +} + +/** + * Helper to create a TOOL_CALL_CHUNK event + */ +export function toolCallChunkEvent({ + toolCallId, + toolCallName, + parentMessageId, + delta, +}: { + toolCallId: string; + toolCallName?: string; + parentMessageId: string; + delta: string; +}): BaseEvent { + return { + type: EventType.TOOL_CALL_CHUNK, + toolCallId, + toolCallName, + parentMessageId, + delta, + } as BaseEvent; +} + +/** + * Helper to create a TOOL_CALL_RESULT event + */ +export function toolCallResultEvent({ + toolCallId, + messageId, + content, +}: { + toolCallId: string; + messageId: string; + content: string; +}): BaseEvent { + return { + type: EventType.TOOL_CALL_RESULT, + toolCallId, + messageId, + content, + } as BaseEvent; +} + +/** + * Helper to generate unique IDs for tests + */ +export function testId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Helper to emit a complete suggestion tool call with streaming chunks + */ +export function emitSuggestionToolCall( + agent: MockStepwiseAgent, + { + toolCallId, + parentMessageId, + suggestions, + }: { + toolCallId: string; + parentMessageId: string; + suggestions: Array<{ title: string; message: string }>; + } +) { + // Convert suggestions to JSON string + const suggestionsJson = JSON.stringify({ suggestions }); + + // Emit the tool call name first + agent.emit(toolCallChunkEvent({ + toolCallId, + toolCallName: "copilotkitSuggest", + parentMessageId, + delta: "", + })); + + // Stream the JSON in chunks to simulate streaming + const chunkSize = 10; // Characters per chunk + for (let i = 0; i < suggestionsJson.length; i += chunkSize) { + const chunk = suggestionsJson.substring(i, i + chunkSize); + agent.emit(toolCallChunkEvent({ + toolCallId, + parentMessageId, + delta: chunk, + })); + } +} + +/** + * A MockStepwiseAgent that emits suggestion events when run() is called + */ +export class SuggestionsProviderAgent extends MockStepwiseAgent { + private _suggestions: Array<{ title: string; message: string }> = []; + + setSuggestions(suggestions: Array<{ title: string; message: string }>) { + this._suggestions = suggestions; + } + + run(_input: RunAgentInput): Observable { + // Call the parent's run() to get the Subject that's already set up + const parentObservable = super.run(_input); + + // Use setTimeout to emit events asynchronously through the existing subject + setTimeout(() => { + const messageId = testId("suggest-msg"); + this.emit({ type: EventType.RUN_STARTED } as BaseEvent); + + emitSuggestionToolCall(this, { + toolCallId: testId("tc"), + parentMessageId: messageId, + suggestions: this._suggestions, + }); + + this.emit({ type: EventType.RUN_FINISHED } as BaseEvent); + this.complete(); + }, 0); + + return parentObservable; + } +} diff --git a/src/v2.x/packages/react/src/components/CopilotKitInspector.tsx b/src/v2.x/packages/react/src/components/CopilotKitInspector.tsx new file mode 100644 index 0000000000..6d635abbd6 --- /dev/null +++ b/src/v2.x/packages/react/src/components/CopilotKitInspector.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { createComponent } from "@lit-labs/react"; +import type { CopilotKitCore } from "@copilotkitnext/core"; + +type CopilotKitInspectorBaseProps = { + core?: CopilotKitCore | null; + [key: string]: unknown; +}; + +type InspectorComponent = React.ComponentType; + +export interface CopilotKitInspectorProps extends CopilotKitInspectorBaseProps {} + +export const CopilotKitInspector: React.FC = ({ core, ...rest }) => { + const [InspectorComponent, setInspectorComponent] = React.useState(null); + + React.useEffect(() => { + let mounted = true; + + // Load the web component only on the client to keep SSR output stable. + import("@copilotkitnext/web-inspector").then((mod) => { + mod.defineWebInspector?.(); + + const Component = createComponent({ + tagName: mod.WEB_INSPECTOR_TAG, + elementClass: mod.WebInspectorElement, + react: React, + }) as InspectorComponent; + + if (mounted) { + setInspectorComponent(() => Component); + } + }); + + return () => { + mounted = false; + }; + }, []); + + // During SSR (and until the client finishes loading), render nothing to keep markup consistent. + if (!InspectorComponent) return null; + + return ; +}; + +CopilotKitInspector.displayName = "CopilotKitInspector"; diff --git a/src/v2.x/packages/react/src/components/MCPAppsActivityRenderer.tsx b/src/v2.x/packages/react/src/components/MCPAppsActivityRenderer.tsx new file mode 100644 index 0000000000..47e7beb79c --- /dev/null +++ b/src/v2.x/packages/react/src/components/MCPAppsActivityRenderer.tsx @@ -0,0 +1,390 @@ +"use client"; + +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { z } from "zod"; +import type { AbstractAgent } from "@ag-ui/client"; + +// Protocol version supported +const PROTOCOL_VERSION = "2025-06-18"; + +/** + * Activity type for MCP Apps events - must match the middleware's MCPAppsActivityType + */ +export const MCPAppsActivityType = "mcp-apps"; + +// Zod schema for activity content validation +export const MCPAppsActivityContentSchema = z.object({ + result: z.object({ + content: z.array(z.any()).optional(), + structuredContent: z.any().optional(), + isError: z.boolean().optional(), + }), + resource: z.object({ + uri: z.string(), + mimeType: z.string().optional(), + text: z.string().optional(), + blob: z.string().optional(), + }), + // Server ID for proxying requests (MD5 hash of server config) + serverId: z.string().optional(), + // Original tool input arguments + toolInput: z.record(z.unknown()).optional(), +}); + +export type MCPAppsActivityContent = z.infer; + +interface JSONRPCRequest { + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: Record; +} + +interface JSONRPCResponse { + jsonrpc: "2.0"; + id: string | number; + result?: unknown; + error?: { code: number; message: string }; +} + +interface JSONRPCNotification { + jsonrpc: "2.0"; + method: string; + params?: Record; +} + +type JSONRPCMessage = JSONRPCRequest | JSONRPCResponse | JSONRPCNotification; + +function isRequest(msg: JSONRPCMessage): msg is JSONRPCRequest { + return "id" in msg && "method" in msg; +} + +function isNotification(msg: JSONRPCMessage): msg is JSONRPCNotification { + return !("id" in msg) && "method" in msg; +} + +/** + * Extract HTML content from a resource object + */ +function extractHtmlFromResource(resource: MCPAppsActivityContent["resource"]): string { + if (resource.text) { + return resource.text; + } + if (resource.blob) { + // Base64 decode + return atob(resource.blob); + } + throw new Error("Resource has no text or blob content"); +} + +/** + * Props for the activity renderer component + */ +interface MCPAppsActivityRendererProps { + activityType: string; + content: MCPAppsActivityContent; + message: unknown; // ActivityMessage from @ag-ui/core + agent: AbstractAgent | undefined; +} + +/** + * MCP Apps Extension Activity Renderer + * + * Renders MCP Apps UI in a sandboxed iframe with full protocol support. + */ +export const MCPAppsActivityRenderer: React.FC = ({ content, agent }) => { + const containerRef = useRef(null); + const iframeRef = useRef(null); + const [iframeReady, setIframeReady] = useState(false); + const [error, setError] = useState(null); + const [iframeSize, setIframeSize] = useState<{ width?: number; height?: number }>({}); + + // Use refs for values that shouldn't trigger re-renders but need latest values + const contentRef = useRef(content); + contentRef.current = content; + + // Store agent in a ref for use in async handlers + const agentRef = useRef(agent); + agentRef.current = agent; + + // Callback to send a message to the iframe + const sendToIframe = useCallback((msg: JSONRPCMessage) => { + if (iframeRef.current?.contentWindow) { + console.log("[MCPAppsRenderer] Sending to iframe:", msg); + iframeRef.current.contentWindow.postMessage(msg, "*"); + } + }, []); + + // Callback to send a JSON-RPC response + const sendResponse = useCallback((id: string | number, result: unknown) => { + sendToIframe({ + jsonrpc: "2.0", + id, + result, + }); + }, [sendToIframe]); + + // Callback to send a JSON-RPC error response + const sendErrorResponse = useCallback((id: string | number, code: number, message: string) => { + sendToIframe({ + jsonrpc: "2.0", + id, + error: { code, message }, + }); + }, [sendToIframe]); + + // Callback to send a notification + const sendNotification = useCallback((method: string, params?: Record) => { + sendToIframe({ + jsonrpc: "2.0", + method, + params: params || {}, + }); + }, [sendToIframe]); + + // Effect 1: Setup sandbox proxy iframe and communication + useEffect(() => { + if (!containerRef.current) return; + + let mounted = true; + let messageHandler: ((event: MessageEvent) => void) | null = null; + + const setup = async () => { + try { + // Create sandbox proxy iframe + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.height = "300px"; + iframe.style.border = "none"; + iframe.style.backgroundColor = "transparent"; + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + + // Wait for sandbox proxy to be ready + const sandboxReady = new Promise((resolve) => { + const initialListener = (event: MessageEvent) => { + if (event.source === iframe.contentWindow) { + if (event.data?.method === "ui/notifications/sandbox-proxy-ready") { + window.removeEventListener("message", initialListener); + resolve(); + } + } + }; + window.addEventListener("message", initialListener); + }); + + // Set iframe source and add to DOM + iframe.src = "/sandbox.html"; + iframeRef.current = iframe; + containerRef.current?.appendChild(iframe); + + // Wait for sandbox proxy to signal ready + await sandboxReady; + if (!mounted) return; + + console.log("[MCPAppsRenderer] Sandbox proxy ready"); + + // Setup message handler for JSON-RPC messages from the inner iframe + messageHandler = async (event: MessageEvent) => { + if (event.source !== iframe.contentWindow) return; + + const msg = event.data as JSONRPCMessage; + if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0") return; + + console.log("[MCPAppsRenderer] Received from iframe:", msg); + + // Handle requests (need response) + if (isRequest(msg)) { + switch (msg.method) { + case "ui/initialize": { + // Respond with host capabilities + sendResponse(msg.id, { + protocolVersion: PROTOCOL_VERSION, + hostInfo: { + name: "CopilotKit MCP Apps Host", + version: "1.0.0", + }, + hostCapabilities: { + openLinks: {}, + logging: {}, + }, + hostContext: { + theme: "light", + platform: "web", + }, + }); + break; + } + + case "ui/message": { + // For now, just acknowledge the message + // TODO: Hook into CopilotKit chat to add messages + console.log("[MCPAppsRenderer] ui/message request:", msg.params); + sendResponse(msg.id, { isError: false }); + break; + } + + case "ui/open-link": { + // Open URL in new tab + const url = msg.params?.url as string | undefined; + if (url) { + window.open(url, "_blank", "noopener,noreferrer"); + sendResponse(msg.id, { isError: false }); + } else { + sendErrorResponse(msg.id, -32602, "Missing url parameter"); + } + break; + } + + case "tools/call": { + // Proxy tool call to MCP server via agent.runAgent() + const { serverId } = contentRef.current; + const currentAgent = agentRef.current; + + if (!serverId) { + sendErrorResponse(msg.id, -32603, "No server ID available for proxying"); + break; + } + + if (!currentAgent) { + sendErrorResponse(msg.id, -32603, "No agent available for proxying"); + break; + } + + try { + // Use agent.runAgent() to proxy the MCP request + // The middleware will intercept forwardedProps.__proxiedMCPRequest + const runResult = await currentAgent.runAgent({ + forwardedProps: { + __proxiedMCPRequest: { + serverId, + method: "tools/call", + params: msg.params, + }, + }, + }); + + // The result from runAgent contains the MCP response + sendResponse(msg.id, runResult.result || {}); + } catch (err) { + console.error("[MCPAppsRenderer] tools/call error:", err); + sendErrorResponse(msg.id, -32603, String(err)); + } + break; + } + + default: + sendErrorResponse(msg.id, -32601, `Method not found: ${msg.method}`); + } + } + + // Handle notifications (no response needed) + if (isNotification(msg)) { + switch (msg.method) { + case "ui/notifications/initialized": { + console.log("[MCPAppsRenderer] Inner iframe initialized"); + if (mounted) { + setIframeReady(true); + } + break; + } + + case "ui/notifications/size-change": { + const { width, height } = msg.params || {}; + console.log("[MCPAppsRenderer] Size change:", { width, height }); + if (mounted) { + setIframeSize({ + width: typeof width === "number" ? width : undefined, + height: typeof height === "number" ? height : undefined, + }); + } + break; + } + + case "notifications/message": { + // Logging notification from the app + console.log("[MCPAppsRenderer] App log:", msg.params); + break; + } + } + } + }; + + window.addEventListener("message", messageHandler); + + // Send the HTML resource to the sandbox proxy + const html = extractHtmlFromResource(content.resource); + sendNotification("ui/notifications/sandbox-resource-ready", { html }); + + } catch (err) { + console.error("[MCPAppsRenderer] Setup error:", err); + if (mounted) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } + }; + + setup(); + + return () => { + mounted = false; + if (messageHandler) { + window.removeEventListener("message", messageHandler); + } + // Remove iframe from DOM + if (iframeRef.current && containerRef.current?.contains(iframeRef.current)) { + containerRef.current.removeChild(iframeRef.current); + } + iframeRef.current = null; + }; + }, [content.resource, sendNotification, sendResponse, sendErrorResponse]); + + // Effect 2: Update iframe size when it changes + useEffect(() => { + if (iframeRef.current) { + if (iframeSize.width !== undefined) { + iframeRef.current.style.width = `${iframeSize.width}px`; + } + if (iframeSize.height !== undefined) { + iframeRef.current.style.height = `${iframeSize.height}px`; + } + } + }, [iframeSize]); + + // Effect 3: Send tool input when iframe ready + useEffect(() => { + if (iframeReady && content.toolInput) { + console.log("[MCPAppsRenderer] Sending tool input:", content.toolInput); + sendNotification("ui/notifications/tool-input", { + arguments: content.toolInput, + }); + } + }, [iframeReady, content.toolInput, sendNotification]); + + // Effect 4: Send tool result when iframe ready + useEffect(() => { + if (iframeReady && content.result) { + console.log("[MCPAppsRenderer] Sending tool result:", content.result); + sendNotification("ui/notifications/tool-result", content.result); + } + }, [iframeReady, content.result, sendNotification]); + + return ( +
+ {error && ( +
+ Error: {error.message} +
+ )} +
+ ); +}; diff --git a/src/v2.x/packages/react/src/components/WildcardToolCallRender.tsx b/src/v2.x/packages/react/src/components/WildcardToolCallRender.tsx new file mode 100644 index 0000000000..6dba08623f --- /dev/null +++ b/src/v2.x/packages/react/src/components/WildcardToolCallRender.tsx @@ -0,0 +1,86 @@ +import { defineToolCallRenderer } from "../types/defineToolCallRenderer"; +import { useState } from "react"; + +export const WildcardToolCallRender = defineToolCallRenderer({ + name: "*", + render: ({ args, result, name, status }) => { + const [isExpanded, setIsExpanded] = useState(false); + + const statusString = String(status) as + | "inProgress" + | "executing" + | "complete"; + const isActive = + statusString === "inProgress" || statusString === "executing"; + const isComplete = statusString === "complete"; + const statusStyles = isActive + ? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400" + : isComplete + ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-400" + : "bg-zinc-100 text-zinc-800 dark:bg-zinc-700/40 dark:text-zinc-300"; + + return ( +
+
+
setIsExpanded(!isExpanded)} + > +
+ + + + + + {name} + +
+ + {String(status)} + +
+ + {isExpanded && ( +
+
+
+ Arguments +
+
+                  {JSON.stringify(args ?? {}, null, 2)}
+                
+
+ + {result !== undefined && ( +
+
+ Result +
+
+                    {typeof result === "string"
+                      ? result
+                      : JSON.stringify(result, null, 2)}
+                  
+
+ )} +
+ )} +
+
+ ); + }, +}); diff --git a/src/v2.x/packages/react/src/components/chat/CopilotChat.tsx b/src/v2.x/packages/react/src/components/chat/CopilotChat.tsx new file mode 100644 index 0000000000..232118560a --- /dev/null +++ b/src/v2.x/packages/react/src/components/chat/CopilotChat.tsx @@ -0,0 +1,168 @@ +import { useAgent } from "@/hooks/use-agent"; +import { useSuggestions } from "@/hooks/use-suggestions"; +import { CopilotChatView, CopilotChatViewProps } from "./CopilotChatView"; +import CopilotChatInput, { CopilotChatInputProps } from "./CopilotChatInput"; +import { + CopilotChatConfigurationProvider, + CopilotChatLabels, + useCopilotChatConfiguration, +} from "@/providers/CopilotChatConfigurationProvider"; +import { DEFAULT_AGENT_ID, randomUUID } from "@copilotkitnext/shared"; +import { Suggestion } from "@copilotkitnext/core"; +import { useCallback, useEffect, useMemo } from "react"; +import { merge } from "ts-deepmerge"; +import { useCopilotKit } from "@/providers/CopilotKitProvider"; +import { AbstractAgent, AGUIConnectNotImplementedError } from "@ag-ui/client"; +import { renderSlot, SlotValue } from "@/lib/slots"; + +export type CopilotChatProps = Omit< + CopilotChatViewProps, + "messages" | "isRunning" | "suggestions" | "suggestionLoadingIndexes" | "onSelectSuggestion" +> & { + agentId?: string; + threadId?: string; + labels?: Partial; + chatView?: SlotValue; + isModalDefaultOpen?: boolean; +}; +export function CopilotChat({ agentId, threadId, labels, chatView, isModalDefaultOpen, ...props }: CopilotChatProps) { + // Check for existing configuration provider + const existingConfig = useCopilotChatConfiguration(); + + // Apply priority: props > existing config > defaults + const resolvedAgentId = agentId ?? existingConfig?.agentId ?? DEFAULT_AGENT_ID; + const resolvedThreadId = useMemo( + () => threadId ?? existingConfig?.threadId ?? randomUUID(), + [threadId, existingConfig?.threadId], + ); + const { agent } = useAgent({ agentId: resolvedAgentId }); + const { copilotkit } = useCopilotKit(); + + const { suggestions: autoSuggestions } = useSuggestions({ agentId: resolvedAgentId }); + + const { + inputProps: providedInputProps, + messageView: providedMessageView, + suggestionView: providedSuggestionView, + ...restProps + } = props; + + useEffect(() => { + const connect = async (agent: AbstractAgent) => { + try { + await copilotkit.connectAgent({ agent }); + } catch (error) { + if (error instanceof AGUIConnectNotImplementedError) { + // connect not implemented, ignore + } else { + throw error; + } + } + }; + agent.threadId = resolvedThreadId; + connect(agent); + return () => {}; + }, [resolvedThreadId, agent, copilotkit, resolvedAgentId]); + + const onSubmitInput = useCallback( + async (value: string) => { + agent.addMessage({ + id: randomUUID(), + role: "user", + content: value, + }); + try { + await copilotkit.runAgent({ agent }); + } catch (error) { + console.error("CopilotChat: runAgent failed", error); + } + }, + [agent, copilotkit], + ); + + const handleSelectSuggestion = useCallback( + async (suggestion: Suggestion) => { + agent.addMessage({ + id: randomUUID(), + role: "user", + content: suggestion.message, + }); + + try { + await copilotkit.runAgent({ agent }); + } catch (error) { + console.error("CopilotChat: runAgent failed after selecting suggestion", error); + } + }, + [agent, copilotkit], + ); + + const stopCurrentRun = useCallback(() => { + try { + copilotkit.stopAgent({ agent }); + } catch (error) { + console.error("CopilotChat: stopAgent failed", error); + try { + agent.abortRun(); + } catch (abortError) { + console.error("CopilotChat: abortRun fallback failed", abortError); + } + } + }, [agent, copilotkit]); + + const mergedProps = merge( + { + isRunning: agent.isRunning, + suggestions: autoSuggestions, + onSelectSuggestion: handleSelectSuggestion, + suggestionView: providedSuggestionView, + }, + { + ...restProps, + ...(typeof providedMessageView === "string" + ? { messageView: { className: providedMessageView } } + : providedMessageView !== undefined + ? { messageView: providedMessageView } + : {}), + }, + ); + + const providedStopHandler = providedInputProps?.onStop; + const hasMessages = agent.messages.length > 0; + const shouldAllowStop = agent.isRunning && hasMessages; + const effectiveStopHandler = shouldAllowStop ? (providedStopHandler ?? stopCurrentRun) : providedStopHandler; + + const finalInputProps = { + ...providedInputProps, + onSubmitMessage: onSubmitInput, + onStop: effectiveStopHandler, + isRunning: agent.isRunning, + } as Partial & { onSubmitMessage: (value: string) => void }; + + finalInputProps.mode = agent.isRunning ? "processing" : (finalInputProps.mode ?? "input"); + + const finalProps = merge(mergedProps, { + messages: agent.messages, + inputProps: finalInputProps, + }) as CopilotChatViewProps; + + // Always create a provider with merged values + // This ensures priority: props > existing config > defaults + const RenderedChatView = renderSlot(chatView, CopilotChatView, finalProps); + + return ( + + {RenderedChatView} + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace CopilotChat { + export const View = CopilotChatView; +} diff --git a/src/v2.x/packages/react/src/components/chat/CopilotChatAssistantMessage.tsx b/src/v2.x/packages/react/src/components/chat/CopilotChatAssistantMessage.tsx new file mode 100644 index 0000000000..393a77da0b --- /dev/null +++ b/src/v2.x/packages/react/src/components/chat/CopilotChatAssistantMessage.tsx @@ -0,0 +1,362 @@ +import { AssistantMessage, Message } from "@ag-ui/core"; +import { useState } from "react"; +import { + Copy, + Check, + ThumbsUp, + ThumbsDown, + Volume2, + RefreshCw, +} from "lucide-react"; +import { + useCopilotChatConfiguration, + CopilotChatDefaultLabels, +} from "@/providers/CopilotChatConfigurationProvider"; +import { twMerge } from "tailwind-merge"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import "katex/dist/katex.min.css"; +import { WithSlots, renderSlot } from "@/lib/slots"; +import { Streamdown } from "streamdown"; +import CopilotChatToolCallsView from "./CopilotChatToolCallsView"; + +export type CopilotChatAssistantMessageProps = WithSlots< + { + markdownRenderer: typeof CopilotChatAssistantMessage.MarkdownRenderer; + toolbar: typeof CopilotChatAssistantMessage.Toolbar; + copyButton: typeof CopilotChatAssistantMessage.CopyButton; + thumbsUpButton: typeof CopilotChatAssistantMessage.ThumbsUpButton; + thumbsDownButton: typeof CopilotChatAssistantMessage.ThumbsDownButton; + readAloudButton: typeof CopilotChatAssistantMessage.ReadAloudButton; + regenerateButton: typeof CopilotChatAssistantMessage.RegenerateButton; + toolCallsView: typeof CopilotChatToolCallsView; + }, + { + onThumbsUp?: (message: AssistantMessage) => void; + onThumbsDown?: (message: AssistantMessage) => void; + onReadAloud?: (message: AssistantMessage) => void; + onRegenerate?: (message: AssistantMessage) => void; + message: AssistantMessage; + messages?: Message[]; + isRunning?: boolean; + additionalToolbarItems?: React.ReactNode; + toolbarVisible?: boolean; + } & React.HTMLAttributes +>; + +export function CopilotChatAssistantMessage({ + message, + messages, + isRunning, + onThumbsUp, + onThumbsDown, + onReadAloud, + onRegenerate, + additionalToolbarItems, + toolbarVisible = true, + markdownRenderer, + toolbar, + copyButton, + thumbsUpButton, + thumbsDownButton, + readAloudButton, + regenerateButton, + toolCallsView, + children, + className, + ...props +}: CopilotChatAssistantMessageProps) { + const boundMarkdownRenderer = renderSlot( + markdownRenderer, + CopilotChatAssistantMessage.MarkdownRenderer, + { + content: message.content || "", + + } + ); + + const boundCopyButton = renderSlot( + copyButton, + CopilotChatAssistantMessage.CopyButton, + { + onClick: async () => { + if (message.content) { + try { + await navigator.clipboard.writeText(message.content); + } catch (err) { + console.error("Failed to copy message:", err); + } + } + }, + } + ); + + const boundThumbsUpButton = renderSlot( + thumbsUpButton, + CopilotChatAssistantMessage.ThumbsUpButton, + { + onClick: onThumbsUp, + } + ); + + const boundThumbsDownButton = renderSlot( + thumbsDownButton, + CopilotChatAssistantMessage.ThumbsDownButton, + { + onClick: onThumbsDown, + } + ); + + const boundReadAloudButton = renderSlot( + readAloudButton, + CopilotChatAssistantMessage.ReadAloudButton, + { + onClick: onReadAloud, + } + ); + + const boundRegenerateButton = renderSlot( + regenerateButton, + CopilotChatAssistantMessage.RegenerateButton, + { + onClick: onRegenerate, + } + ); + + const boundToolbar = renderSlot( + toolbar, + CopilotChatAssistantMessage.Toolbar, + { + children: ( +
+ {boundCopyButton} + {(onThumbsUp || thumbsUpButton) && boundThumbsUpButton} + {(onThumbsDown || thumbsDownButton) && boundThumbsDownButton} + {(onReadAloud || readAloudButton) && boundReadAloudButton} + {(onRegenerate || regenerateButton) && boundRegenerateButton} + {additionalToolbarItems} +
+ ), + } + ); + + const boundToolCallsView = renderSlot( + toolCallsView, + CopilotChatToolCallsView, + { + message, + messages, + } + ); + + // Don't show toolbar if message has no content (only tool calls) + const hasContent = !!(message.content && message.content.trim().length > 0); + const isLatestAssistantMessage = + message.role === "assistant" && messages?.[messages.length - 1]?.id === message.id; + const shouldShowToolbar = toolbarVisible && hasContent && !(isRunning && isLatestAssistantMessage); + + if (children) { + return ( + <> + {children({ + markdownRenderer: boundMarkdownRenderer, + toolbar: boundToolbar, + toolCallsView: boundToolCallsView, + copyButton: boundCopyButton, + thumbsUpButton: boundThumbsUpButton, + thumbsDownButton: boundThumbsDownButton, + readAloudButton: boundReadAloudButton, + regenerateButton: boundRegenerateButton, + message, + messages, + isRunning, + onThumbsUp, + onThumbsDown, + onReadAloud, + onRegenerate, + additionalToolbarItems, + toolbarVisible: shouldShowToolbar, + })} + + ); + } + + return ( +
+ {boundMarkdownRenderer} + {boundToolCallsView} + {shouldShowToolbar && boundToolbar} +
+ ); +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace CopilotChatAssistantMessage { + export const MarkdownRenderer: React.FC< + Omit, "children"> & { + content: string; + } + > = ({ content, className, ...props }) => ( + + {content ?? ""} + + ); + + export const Toolbar: React.FC> = ({ + className, + ...props + }) => ( +
+ ); + + export const ToolbarButton: React.FC< + React.ButtonHTMLAttributes & { + title: string; + children: React.ReactNode; + } + > = ({ title, children, ...props }) => { + return ( + + + + + +

{title}

+
+
+ ); + }; + + export const CopyButton: React.FC< + React.ButtonHTMLAttributes + > = ({ className, title, onClick, ...props }) => { + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + const [copied, setCopied] = useState(false); + + const handleClick = (event: React.MouseEvent) => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + + if (onClick) { + onClick(event); + } + }; + + return ( + + {copied ? ( + + ) : ( + + )} + + ); + }; + + export const ThumbsUpButton: React.FC< + React.ButtonHTMLAttributes + > = ({ title, ...props }) => { + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + return ( + + + + ); + }; + + export const ThumbsDownButton: React.FC< + React.ButtonHTMLAttributes + > = ({ title, ...props }) => { + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + return ( + + + + ); + }; + + export const ReadAloudButton: React.FC< + React.ButtonHTMLAttributes + > = ({ title, ...props }) => { + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + return ( + + + + ); + }; + + export const RegenerateButton: React.FC< + React.ButtonHTMLAttributes + > = ({ title, ...props }) => { + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + return ( + + + + ); + }; +} + +CopilotChatAssistantMessage.MarkdownRenderer.displayName = + "CopilotChatAssistantMessage.MarkdownRenderer"; +CopilotChatAssistantMessage.Toolbar.displayName = + "CopilotChatAssistantMessage.Toolbar"; +CopilotChatAssistantMessage.CopyButton.displayName = + "CopilotChatAssistantMessage.CopyButton"; +CopilotChatAssistantMessage.ThumbsUpButton.displayName = + "CopilotChatAssistantMessage.ThumbsUpButton"; +CopilotChatAssistantMessage.ThumbsDownButton.displayName = + "CopilotChatAssistantMessage.ThumbsDownButton"; +CopilotChatAssistantMessage.ReadAloudButton.displayName = + "CopilotChatAssistantMessage.ReadAloudButton"; +CopilotChatAssistantMessage.RegenerateButton.displayName = + "CopilotChatAssistantMessage.RegenerateButton"; + +export default CopilotChatAssistantMessage; diff --git a/src/v2.x/packages/react/src/components/chat/CopilotChatAudioRecorder.tsx b/src/v2.x/packages/react/src/components/chat/CopilotChatAudioRecorder.tsx new file mode 100644 index 0000000000..ce900e885d --- /dev/null +++ b/src/v2.x/packages/react/src/components/chat/CopilotChatAudioRecorder.tsx @@ -0,0 +1,157 @@ +import { useRef, useEffect, useImperativeHandle, forwardRef } from "react"; +import { twMerge } from "tailwind-merge"; + +/** Finite-state machine for every recorder implementation */ +export type AudioRecorderState = "idle" | "recording" | "processing"; + +/** Error subclass so callers can `instanceof`-guard recorder failures */ +export class AudioRecorderError extends Error { + constructor(message: string) { + super(message); + this.name = "AudioRecorderError"; + } +} + +export const CopilotChatAudioRecorder = forwardRef< + any, + React.HTMLAttributes +>((props, ref) => { + const { className, ...divProps } = props; + const canvasRef = useRef(null); + + // Generate fake waveform that moves with time + const getLoudness = (n: number): number[] => { + const elapsed = Date.now() / 1000; // Use current timestamp directly + const samples: number[] = []; + + for (let i = 0; i < n; i++) { + // Create a position that moves from left to right over time + const position = (i / n) * 10 + elapsed * 0.5; // Scroll speed (slower) + + // Generate waveform using multiple sine waves for realism + const wave1 = Math.sin(position * 2) * 0.3; + const wave2 = Math.sin(position * 5 + elapsed) * 0.2; + const wave3 = Math.sin(position * 0.5 + elapsed * 0.3) * 0.4; + + // Add some randomness for natural variation + const noise = (Math.random() - 0.5) * 0.1; + + // Combine waves and add envelope for realistic amplitude variation + const envelope = Math.sin(elapsed * 0.7) * 0.5 + 0.5; // Slow amplitude modulation + let amplitude = (wave1 + wave2 + wave3 + noise) * envelope; + + // Clamp to 0-1 range + amplitude = Math.max(0, Math.min(1, amplitude * 0.5 + 0.3)); + + samples.push(amplitude); + } + + return samples; + }; + + // No setup needed - stub implementation + + // Canvas rendering with 60fps animation + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationId: number; + + const draw = () => { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + // Update canvas dimensions if container resized + if ( + canvas.width !== rect.width * dpr || + canvas.height !== rect.height * dpr + ) { + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + } + + // Configuration + const barWidth = 2; + const minHeight = 2; + const maxHeight = 20; + const gap = 2; + const numSamples = Math.ceil(rect.width / (barWidth + gap)); + + // Get loudness data + const loudnessData = getLoudness(numSamples); + + // Clear canvas + ctx.clearRect(0, 0, rect.width, rect.height); + + // Get current foreground color + const computedStyle = getComputedStyle(canvas); + const currentForeground = computedStyle.color; + + // Draw bars + ctx.fillStyle = currentForeground; + const centerY = rect.height / 2; + + for (let i = 0; i < loudnessData.length; i++) { + const sample = loudnessData[i] ?? 0; + const barHeight = Math.round( + sample * (maxHeight - minHeight) + minHeight + ); + const x = Math.round(i * (barWidth + gap)); + const y = Math.round(centerY - barHeight / 2); + + ctx.fillRect(x, y, barWidth, barHeight); + } + + animationId = requestAnimationFrame(draw); + }; + + draw(); + + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + }; + }, []); + + // Expose AudioRecorder API + useImperativeHandle( + ref, + () => ({ + get state() { + return "idle" as AudioRecorderState; + }, + start: async () => { + // Stub implementation - no actual recording + }, + stop: () => + new Promise((resolve) => { + // Stub implementation - return empty blob + const emptyBlob = new Blob([], { type: "audio/webm" }); + resolve(emptyBlob); + }), + dispose: () => { + // No cleanup needed + }, + }), + [] + ); + + return ( +
+ +
+ ); +}); + +CopilotChatAudioRecorder.displayName = "WebAudioRecorder"; diff --git a/src/v2.x/packages/react/src/components/chat/CopilotChatInput.tsx b/src/v2.x/packages/react/src/components/chat/CopilotChatInput.tsx new file mode 100644 index 0000000000..065316aa73 --- /dev/null +++ b/src/v2.x/packages/react/src/components/chat/CopilotChatInput.tsx @@ -0,0 +1,1097 @@ +import React, { + useState, + useRef, + KeyboardEvent, + ChangeEvent, + useEffect, + useLayoutEffect, + forwardRef, + useImperativeHandle, + useCallback, + useMemo, +} from "react"; +import { twMerge } from "tailwind-merge"; +import { Plus, Mic, ArrowUp, X, Check, Square } from "lucide-react"; + +import { + CopilotChatLabels, + useCopilotChatConfiguration, + CopilotChatDefaultLabels, +} from "@/providers/CopilotChatConfigurationProvider"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; + +import { CopilotChatAudioRecorder } from "./CopilotChatAudioRecorder"; +import { renderSlot, WithSlots } from "@/lib/slots"; + +export type CopilotChatInputMode = "input" | "transcribe" | "processing"; + +export type ToolsMenuItem = { + label: string; +} & ( + | { + action: () => void; + items?: never; + } + | { + action?: never; + items: (ToolsMenuItem | "-")[]; + } +); + +type CopilotChatInputSlots = { + textArea: typeof CopilotChatInput.TextArea; + sendButton: typeof CopilotChatInput.SendButton; + startTranscribeButton: typeof CopilotChatInput.StartTranscribeButton; + cancelTranscribeButton: typeof CopilotChatInput.CancelTranscribeButton; + finishTranscribeButton: typeof CopilotChatInput.FinishTranscribeButton; + addMenuButton: typeof CopilotChatInput.AddMenuButton; + audioRecorder: typeof CopilotChatAudioRecorder; +}; + +type CopilotChatInputRestProps = { + mode?: CopilotChatInputMode; + toolsMenu?: (ToolsMenuItem | "-")[]; + autoFocus?: boolean; + onSubmitMessage?: (value: string) => void; + onStop?: () => void; + isRunning?: boolean; + onStartTranscribe?: () => void; + onCancelTranscribe?: () => void; + onFinishTranscribe?: () => void; + onAddFile?: () => void; + value?: string; + onChange?: (value: string) => void; +} & Omit, "onChange">; + +type CopilotChatInputBaseProps = WithSlots; + +type CopilotChatInputChildrenArgs = CopilotChatInputBaseProps extends { children?: infer C } + ? C extends (props: infer P) => React.ReactNode + ? P + : never + : never; + +export type CopilotChatInputProps = Omit & { + children?: (props: CopilotChatInputChildrenArgs) => React.ReactNode; +}; + +const SLASH_MENU_MAX_VISIBLE_ITEMS = 5; +const SLASH_MENU_ITEM_HEIGHT_PX = 40; + +export function CopilotChatInput({ + mode = "input", + onSubmitMessage, + onStop, + isRunning = false, + onStartTranscribe, + onCancelTranscribe, + onFinishTranscribe, + onAddFile, + onChange, + value, + toolsMenu, + autoFocus = true, + textArea, + sendButton, + startTranscribeButton, + cancelTranscribeButton, + finishTranscribeButton, + addMenuButton, + audioRecorder, + children, + className, + ...props +}: CopilotChatInputProps) { + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(() => value ?? ""); + + useEffect(() => { + if (!isControlled && value !== undefined) { + setInternalValue(value); + } + }, [isControlled, value]); + + const resolvedValue = isControlled ? (value ?? "") : internalValue; + + const [layout, setLayout] = useState<"compact" | "expanded">("compact"); + const ignoreResizeRef = useRef(false); + const resizeEvaluationRafRef = useRef(null); + const isExpanded = mode === "input" && layout === "expanded"; + const [commandQuery, setCommandQuery] = useState(null); + const [slashHighlightIndex, setSlashHighlightIndex] = useState(0); + + const inputRef = useRef(null); + const gridRef = useRef(null); + const addButtonContainerRef = useRef(null); + const actionsContainerRef = useRef(null); + const audioRecorderRef = useRef>(null); + const slashMenuRef = useRef(null); + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + + const previousModalStateRef = useRef(undefined); + const measurementCanvasRef = useRef(null); + const measurementsRef = useRef({ + singleLineHeight: 0, + maxHeight: 0, + paddingLeft: 0, + paddingRight: 0, + }); + + const commandItems = useMemo(() => { + const entries: ToolsMenuItem[] = []; + const seen = new Set(); + + const pushItem = (item: ToolsMenuItem | "-") => { + if (item === "-") { + return; + } + + if (item.items && item.items.length > 0) { + for (const nested of item.items) { + pushItem(nested); + } + return; + } + + if (!seen.has(item.label)) { + seen.add(item.label); + entries.push(item); + } + }; + + if (onAddFile) { + pushItem({ + label: labels.chatInputToolbarAddButtonLabel, + action: onAddFile, + }); + } + + if (toolsMenu && toolsMenu.length > 0) { + for (const item of toolsMenu) { + pushItem(item); + } + } + + return entries; + }, [labels.chatInputToolbarAddButtonLabel, onAddFile, toolsMenu]); + + const filteredCommands = useMemo(() => { + if (commandQuery === null) { + return [] as ToolsMenuItem[]; + } + + if (commandItems.length === 0) { + return [] as ToolsMenuItem[]; + } + + const query = commandQuery.trim().toLowerCase(); + if (query.length === 0) { + return commandItems; + } + + const startsWith: ToolsMenuItem[] = []; + const contains: ToolsMenuItem[] = []; + for (const item of commandItems) { + const label = item.label.toLowerCase(); + if (label.startsWith(query)) { + startsWith.push(item); + } else if (label.includes(query)) { + contains.push(item); + } + } + + return [...startsWith, ...contains]; + }, [commandItems, commandQuery]); + + useEffect(() => { + if (!autoFocus) { + previousModalStateRef.current = config?.isModalOpen; + return; + } + + if (config?.isModalOpen && !previousModalStateRef.current) { + inputRef.current?.focus(); + } + + previousModalStateRef.current = config?.isModalOpen; + }, [config?.isModalOpen, autoFocus]); + + useEffect(() => { + if (commandItems.length === 0 && commandQuery !== null) { + setCommandQuery(null); + } + }, [commandItems.length, commandQuery]); + + const previousCommandQueryRef = useRef(null); + + useEffect(() => { + if ( + commandQuery !== null && + commandQuery !== previousCommandQueryRef.current && + filteredCommands.length > 0 + ) { + setSlashHighlightIndex(0); + } + + previousCommandQueryRef.current = commandQuery; + }, [commandQuery, filteredCommands.length]); + + useEffect(() => { + if (commandQuery === null) { + setSlashHighlightIndex(0); + return; + } + + if (filteredCommands.length === 0) { + setSlashHighlightIndex(-1); + } else if (slashHighlightIndex < 0 || slashHighlightIndex >= filteredCommands.length) { + setSlashHighlightIndex(0); + } + }, [commandQuery, filteredCommands, slashHighlightIndex]); + + // Handle recording based on mode changes + useEffect(() => { + const recorder = audioRecorderRef.current; + if (!recorder) { + return; + } + + if (mode === "transcribe") { + // Start recording when entering transcribe mode + recorder.start().catch(console.error); + } else { + // Stop recording when leaving transcribe mode + if (recorder.state === "recording") { + recorder.stop().catch(console.error); + } + } + }, [mode]); + + useEffect(() => { + if (mode !== "input") { + setLayout("compact"); + setCommandQuery(null); + } + }, [mode]); + + const updateSlashState = useCallback( + (value: string) => { + if (commandItems.length === 0) { + setCommandQuery((prev) => (prev === null ? prev : null)); + return; + } + + if (value.startsWith("/")) { + const firstLine = value.split(/\r?\n/, 1)[0] ?? ""; + const query = firstLine.slice(1); + setCommandQuery((prev) => (prev === query ? prev : query)); + } else { + setCommandQuery((prev) => (prev === null ? prev : null)); + } + }, + [commandItems.length], + ); + + useEffect(() => { + updateSlashState(resolvedValue); + }, [resolvedValue, updateSlashState]); + + // Handlers + const handleChange = (e: ChangeEvent) => { + const nextValue = e.target.value; + if (!isControlled) { + setInternalValue(nextValue); + } + onChange?.(nextValue); + updateSlashState(nextValue); + }; + + const clearInputValue = useCallback(() => { + if (!isControlled) { + setInternalValue(""); + } + + if (onChange) { + onChange(""); + } + }, [isControlled, onChange]); + + const runCommand = useCallback( + (item: ToolsMenuItem) => { + clearInputValue(); + + item.action?.(); + + setCommandQuery(null); + setSlashHighlightIndex(0); + + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }, + [clearInputValue], + ); + + const handleKeyDown = (e: KeyboardEvent) => { + if (commandQuery !== null && mode === "input") { + if (e.key === "ArrowDown") { + if (filteredCommands.length > 0) { + e.preventDefault(); + setSlashHighlightIndex((prev) => { + if (filteredCommands.length === 0) { + return prev; + } + const next = prev === -1 ? 0 : (prev + 1) % filteredCommands.length; + return next; + }); + } + return; + } + + if (e.key === "ArrowUp") { + if (filteredCommands.length > 0) { + e.preventDefault(); + setSlashHighlightIndex((prev) => { + if (filteredCommands.length === 0) { + return prev; + } + if (prev === -1) { + return filteredCommands.length - 1; + } + return prev <= 0 ? filteredCommands.length - 1 : prev - 1; + }); + } + return; + } + + if (e.key === "Enter") { + const selected = slashHighlightIndex >= 0 ? filteredCommands[slashHighlightIndex] : undefined; + if (selected) { + e.preventDefault(); + runCommand(selected); + return; + } + } + + if (e.key === "Escape") { + e.preventDefault(); + setCommandQuery(null); + return; + } + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (isProcessing) { + onStop?.(); + } else { + send(); + } + } + }; + + const send = () => { + if (!onSubmitMessage) { + return; + } + const trimmed = resolvedValue.trim(); + if (!trimmed) { + return; + } + + onSubmitMessage(trimmed); + + if (!isControlled) { + setInternalValue(""); + onChange?.(""); + } + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const BoundTextArea = renderSlot(textArea, CopilotChatInput.TextArea, { + ref: inputRef, + value: resolvedValue, + onChange: handleChange, + onKeyDown: handleKeyDown, + autoFocus: autoFocus, + className: twMerge( + "w-full py-3", + isExpanded ? "px-5" : "pr-5", + ), + }); + + const isProcessing = mode !== "transcribe" && isRunning; + const canSend = resolvedValue.trim().length > 0 && !!onSubmitMessage; + const canStop = !!onStop; + + const handleSendButtonClick = () => { + if (isProcessing) { + onStop?.(); + return; + } + send(); + }; + + const BoundAudioRecorder = renderSlot(audioRecorder, CopilotChatAudioRecorder, { + ref: audioRecorderRef, + }); + + const BoundSendButton = renderSlot(sendButton, CopilotChatInput.SendButton, { + onClick: handleSendButtonClick, + disabled: isProcessing ? !canStop : !canSend, + children: isProcessing && canStop ? : undefined, + }); + + const BoundStartTranscribeButton = renderSlot(startTranscribeButton, CopilotChatInput.StartTranscribeButton, { + onClick: onStartTranscribe, + }); + + const BoundCancelTranscribeButton = renderSlot(cancelTranscribeButton, CopilotChatInput.CancelTranscribeButton, { + onClick: onCancelTranscribe, + }); + + const BoundFinishTranscribeButton = renderSlot(finishTranscribeButton, CopilotChatInput.FinishTranscribeButton, { + onClick: onFinishTranscribe, + }); + + const BoundAddMenuButton = renderSlot(addMenuButton, CopilotChatInput.AddMenuButton, { + disabled: mode === "transcribe", + onAddFile, + toolsMenu, + }); + + if (children) { + const childProps = { + textArea: BoundTextArea, + audioRecorder: BoundAudioRecorder, + sendButton: BoundSendButton, + startTranscribeButton: BoundStartTranscribeButton, + cancelTranscribeButton: BoundCancelTranscribeButton, + finishTranscribeButton: BoundFinishTranscribeButton, + addMenuButton: BoundAddMenuButton, + onSubmitMessage, + onStop, + isRunning, + onStartTranscribe, + onCancelTranscribe, + onFinishTranscribe, + onAddFile, + mode, + toolsMenu, + autoFocus, + } as CopilotChatInputChildrenArgs; + + return <>{children(childProps)}; + } + + const handleContainerClick = (e: React.MouseEvent) => { + // Don't focus if clicking on buttons or other interactive elements + const target = e.target as HTMLElement; + if (target.tagName !== "BUTTON" && !target.closest("button") && inputRef.current && mode === "input") { + inputRef.current.focus(); + } + }; + + const ensureMeasurements = useCallback(() => { + const textarea = inputRef.current; + if (!textarea) { + return; + } + + const previousValue = textarea.value; + const previousHeight = textarea.style.height; + + textarea.style.height = "auto"; + + const computedStyle = window.getComputedStyle(textarea); + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = parseFloat(computedStyle.paddingRight) || 0; + const paddingTop = parseFloat(computedStyle.paddingTop) || 0; + const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0; + + textarea.value = ""; + const singleLineHeight = textarea.scrollHeight; + textarea.value = previousValue; + + const contentHeight = singleLineHeight - paddingTop - paddingBottom; + const maxHeight = contentHeight * 5 + paddingTop + paddingBottom; + + measurementsRef.current = { + singleLineHeight, + maxHeight, + paddingLeft, + paddingRight, + }; + + textarea.style.height = previousHeight; + textarea.style.maxHeight = `${maxHeight}px`; + }, []); + + const adjustTextareaHeight = useCallback(() => { + const textarea = inputRef.current; + if (!textarea) { + return 0; + } + + if (measurementsRef.current.singleLineHeight === 0) { + ensureMeasurements(); + } + + const { maxHeight } = measurementsRef.current; + if (maxHeight) { + textarea.style.maxHeight = `${maxHeight}px`; + } + + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + if (maxHeight) { + textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; + } else { + textarea.style.height = `${scrollHeight}px`; + } + + return scrollHeight; + }, [ensureMeasurements]); + + const updateLayout = useCallback((nextLayout: "compact" | "expanded") => { + setLayout((prev) => { + if (prev === nextLayout) { + return prev; + } + ignoreResizeRef.current = true; + return nextLayout; + }); + }, []); + + const evaluateLayout = useCallback(() => { + if (mode !== "input") { + updateLayout("compact"); + return; + } + + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + const isMobileViewport = window.matchMedia("(max-width: 767px)").matches; + if (isMobileViewport) { + ensureMeasurements(); + adjustTextareaHeight(); + updateLayout("expanded"); + return; + } + } + + const textarea = inputRef.current; + const grid = gridRef.current; + const addContainer = addButtonContainerRef.current; + const actionsContainer = actionsContainerRef.current; + + if (!textarea || !grid || !addContainer || !actionsContainer) { + return; + } + + if (measurementsRef.current.singleLineHeight === 0) { + ensureMeasurements(); + } + + const scrollHeight = adjustTextareaHeight(); + const baseline = measurementsRef.current.singleLineHeight; + const hasExplicitBreak = resolvedValue.includes("\n"); + const renderedMultiline = baseline > 0 ? scrollHeight > baseline + 1 : false; + let shouldExpand = hasExplicitBreak || renderedMultiline; + + if (!shouldExpand) { + const gridStyles = window.getComputedStyle(grid); + const paddingLeft = parseFloat(gridStyles.paddingLeft) || 0; + const paddingRight = parseFloat(gridStyles.paddingRight) || 0; + const columnGap = parseFloat(gridStyles.columnGap) || 0; + const gridAvailableWidth = grid.clientWidth - paddingLeft - paddingRight; + + if (gridAvailableWidth > 0) { + const addWidth = addContainer.getBoundingClientRect().width; + const actionsWidth = actionsContainer.getBoundingClientRect().width; + const compactWidth = Math.max(gridAvailableWidth - addWidth - actionsWidth - columnGap * 2, 0); + + const canvas = measurementCanvasRef.current ?? document.createElement("canvas"); + if (!measurementCanvasRef.current) { + measurementCanvasRef.current = canvas; + } + + const context = canvas.getContext("2d"); + if (context) { + const textareaStyles = window.getComputedStyle(textarea); + const font = + textareaStyles.font || + `${textareaStyles.fontStyle} ${textareaStyles.fontVariant} ${textareaStyles.fontWeight} ${textareaStyles.fontSize}/${textareaStyles.lineHeight} ${textareaStyles.fontFamily}`; + context.font = font; + + const compactInnerWidth = Math.max( + compactWidth - (measurementsRef.current.paddingLeft || 0) - (measurementsRef.current.paddingRight || 0), + 0, + ); + + if (compactInnerWidth > 0) { + const lines = resolvedValue.length > 0 ? resolvedValue.split("\n") : [""]; + let longestWidth = 0; + for (const line of lines) { + const metrics = context.measureText(line || " "); + if (metrics.width > longestWidth) { + longestWidth = metrics.width; + } + } + + if (longestWidth > compactInnerWidth) { + shouldExpand = true; + } + } + } + } + } + + const nextLayout = shouldExpand ? "expanded" : "compact"; + updateLayout(nextLayout); + }, [adjustTextareaHeight, ensureMeasurements, mode, resolvedValue, updateLayout]); + + useLayoutEffect(() => { + evaluateLayout(); + }, [evaluateLayout]); + + useEffect(() => { + if (typeof ResizeObserver === "undefined") { + return; + } + + const textarea = inputRef.current; + const grid = gridRef.current; + const addContainer = addButtonContainerRef.current; + const actionsContainer = actionsContainerRef.current; + + if (!textarea || !grid || !addContainer || !actionsContainer) { + return; + } + + const scheduleEvaluation = () => { + if (ignoreResizeRef.current) { + ignoreResizeRef.current = false; + return; + } + + if (typeof window === "undefined") { + evaluateLayout(); + return; + } + + if (resizeEvaluationRafRef.current !== null) { + cancelAnimationFrame(resizeEvaluationRafRef.current); + } + + resizeEvaluationRafRef.current = window.requestAnimationFrame(() => { + resizeEvaluationRafRef.current = null; + evaluateLayout(); + }); + }; + + const observer = new ResizeObserver(() => { + scheduleEvaluation(); + }); + + observer.observe(grid); + observer.observe(addContainer); + observer.observe(actionsContainer); + observer.observe(textarea); + + return () => { + observer.disconnect(); + if (typeof window !== "undefined" && resizeEvaluationRafRef.current !== null) { + cancelAnimationFrame(resizeEvaluationRafRef.current); + resizeEvaluationRafRef.current = null; + } + }; + }, [evaluateLayout]); + + const slashMenuVisible = commandQuery !== null && commandItems.length > 0; + + useEffect(() => { + if (!slashMenuVisible || slashHighlightIndex < 0) { + return; + } + + const active = slashMenuRef.current?.querySelector( + `[data-slash-index="${slashHighlightIndex}"]`, + ); + active?.scrollIntoView({ block: "nearest" }); + }, [slashMenuVisible, slashHighlightIndex]); + + const slashMenu = slashMenuVisible ? ( +
+ {filteredCommands.length === 0 ? ( +
No commands found
+ ) : ( + filteredCommands.map((item, index) => { + const isActive = index === slashHighlightIndex; + return ( + + ); + }) + )} +
+ ) : null; + + return ( +
+
+
+ {BoundAddMenuButton} +
+
+ {mode === "transcribe" ? ( + BoundAudioRecorder + ) : ( + <> + {BoundTextArea} + {slashMenu} + + )} +
+
+ {mode === "transcribe" ? ( + <> + {onCancelTranscribe && BoundCancelTranscribeButton} + {onFinishTranscribe && BoundFinishTranscribeButton} + + ) : ( + <> + {onStartTranscribe && BoundStartTranscribeButton} + {BoundSendButton} + + )} +
+
+
+ ); +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace CopilotChatInput { + export const SendButton: React.FC> = ({ className, children, ...props }) => ( +
+ +
+ ); + + export const ToolbarButton: React.FC< + React.ButtonHTMLAttributes & { + icon: React.ReactNode; + labelKey: keyof CopilotChatLabels; + defaultClassName?: string; + } + > = ({ icon, labelKey, defaultClassName, className, ...props }) => { + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + return ( + + + + + +

{labels[labelKey]}

+
+
+ ); + }; + + export const StartTranscribeButton: React.FC> = (props) => ( + } + labelKey="chatInputToolbarStartTranscribeButtonLabel" + defaultClassName="mr-2" + {...props} + /> + ); + + export const CancelTranscribeButton: React.FC> = (props) => ( + } + labelKey="chatInputToolbarCancelTranscribeButtonLabel" + defaultClassName="mr-2" + {...props} + /> + ); + + export const FinishTranscribeButton: React.FC> = (props) => ( + } + labelKey="chatInputToolbarFinishTranscribeButtonLabel" + defaultClassName="mr-[10px]" + {...props} + /> + ); + + export const AddMenuButton: React.FC< + React.ButtonHTMLAttributes & { + toolsMenu?: (ToolsMenuItem | "-")[]; + onAddFile?: () => void; + } + > = ({ className, toolsMenu, onAddFile, disabled, ...props }) => { + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + + const menuItems = useMemo<(ToolsMenuItem | "-")[]>(() => { + const items: (ToolsMenuItem | "-")[] = []; + + if (onAddFile) { + items.push({ + label: labels.chatInputToolbarAddButtonLabel, + action: onAddFile, + }); + } + + if (toolsMenu && toolsMenu.length > 0) { + if (items.length > 0) { + items.push("-"); + } + + for (const item of toolsMenu) { + if (item === "-") { + if (items.length === 0 || items[items.length - 1] === "-") { + continue; + } + items.push(item); + } else { + items.push(item); + } + } + + while (items.length > 0 && items[items.length - 1] === "-") { + items.pop(); + } + } + + return items; + }, [onAddFile, toolsMenu, labels.chatInputToolbarAddButtonLabel]); + + const renderMenuItems = useCallback( + (items: (ToolsMenuItem | "-")[]): React.ReactNode => + items.map((item, index) => { + if (item === "-") { + return ; + } + + if (item.items && item.items.length > 0) { + return ( + + {item.label} + {renderMenuItems(item.items)} + + ); + } + + return ( + + {item.label} + + ); + }), + [], + ); + + const hasMenuItems = menuItems.length > 0; + const isDisabled = disabled || !hasMenuItems; + + return ( + + + + + + + + +

+ Add files and more + / +

+
+
+ {hasMenuItems && ( + + {renderMenuItems(menuItems)} + + )} +
+ ); + }; + + export type TextAreaProps = React.TextareaHTMLAttributes; + + export const TextArea = forwardRef(function TextArea( + { style, className, autoFocus, ...props }, + ref, + ) { + const internalTextareaRef = useRef(null); + const config = useCopilotChatConfiguration(); + const labels = config?.labels ?? CopilotChatDefaultLabels; + + useImperativeHandle(ref, () => internalTextareaRef.current as HTMLTextAreaElement); + + // Auto-scroll input into view on mobile when focused + useEffect(() => { + const textarea = internalTextareaRef.current; + if (!textarea) return; + + const handleFocus = () => { + // Small delay to let the keyboard start appearing + setTimeout(() => { + textarea.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, 300); + }; + + textarea.addEventListener("focus", handleFocus); + return () => textarea.removeEventListener("focus", handleFocus); + }, []); + + useEffect(() => { + if (autoFocus) { + internalTextareaRef.current?.focus(); + } + }, [autoFocus]); + + return ( +