diff --git a/packages/a2ui-renderer/package.json b/packages/a2ui-renderer/package.json index 3be6260b860..93e94ba144d 100644 --- a/packages/a2ui-renderer/package.json +++ b/packages/a2ui-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/a2ui-renderer", - "version": "1.58.0", + "version": "1.59.0", "private": false, "description": "A2UI Renderer for CopilotKit - render A2UI surfaces in React applications", "keywords": [ diff --git a/packages/agentcore-runner/package.json b/packages/agentcore-runner/package.json index 937bf8d4356..5b9ee9ad1fb 100644 --- a/packages/agentcore-runner/package.json +++ b/packages/agentcore-runner/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/agentcore-runner", - "version": "1.58.0", + "version": "1.59.0", "description": "AWS Bedrock AgentCore-compatible agent runner for CopilotKit2", "repository": { "type": "git", diff --git a/packages/core/package.json b/packages/core/package.json index aa4c8ee9e2a..fae69c0a246 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/core", - "version": "1.58.0", + "version": "1.59.0", "description": "Core web utilities for CopilotKit2", "repository": { "type": "git", diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 957b863a6a1..c17345c0778 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/react-core", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/react-native/package.json b/packages/react-native/package.json index bfaf3b5f306..d9ae6757f21 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/react-native", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/react-textarea/package.json b/packages/react-textarea/package.json index b7ca9b72e6a..7b8d925dd01 100644 --- a/packages/react-textarea/package.json +++ b/packages/react-textarea/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/react-textarea", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index f70c6e5904e..80180979ed2 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/react-ui", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/runtime-client-gql/package.json b/packages/runtime-client-gql/package.json index cf7351e0b67..ffbd654b4ef 100644 --- a/packages/runtime-client-gql/package.json +++ b/packages/runtime-client-gql/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/runtime-client-gql", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 28ab9f36571..5bed1e2af3a 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/runtime", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/sdk-js/package.json b/packages/sdk-js/package.json index 577d15f5650..5d1ef06f516 100644 --- a/packages/sdk-js/package.json +++ b/packages/sdk-js/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/sdk-js", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/shared/package.json b/packages/shared/package.json index 0cf6012023d..35f8b2d9af7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/shared", - "version": "1.58.0", + "version": "1.59.0", "private": false, "keywords": [ "ai", diff --git a/packages/sqlite-runner/package.json b/packages/sqlite-runner/package.json index 3a04c3f7c0e..79741c4a6dc 100644 --- a/packages/sqlite-runner/package.json +++ b/packages/sqlite-runner/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/sqlite-runner", - "version": "1.58.0", + "version": "1.59.0", "description": "SQLite-backed agent runner for CopilotKit2", "repository": { "type": "git", diff --git a/packages/voice/package.json b/packages/voice/package.json index 1d1173ffe60..6ed15b2c03a 100644 --- a/packages/voice/package.json +++ b/packages/voice/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/voice", - "version": "1.58.0", + "version": "1.59.0", "description": "Voice services for CopilotKit (transcription, text-to-speech, etc.)", "repository": { "type": "git", diff --git a/packages/vue/package.json b/packages/vue/package.json index fbfd429170c..b9061aaae1c 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/vue", - "version": "1.58.0", + "version": "1.59.0", "private": false, "description": "Vue 3 components and composables for CopilotKit", "keywords": [ diff --git a/packages/web-inspector/package.json b/packages/web-inspector/package.json index 0a62e85fab8..365170fdeaa 100644 --- a/packages/web-inspector/package.json +++ b/packages/web-inspector/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/web-inspector", - "version": "1.58.0", + "version": "1.59.0", "description": "Lit-based web component for the CopilotKit web inspector", "repository": { "type": "git", diff --git a/sdk-python/copilotkit/copilotkit_lg_middleware.py b/sdk-python/copilotkit/copilotkit_lg_middleware.py index 8f0113008c4..03c42cd76b6 100644 --- a/sdk-python/copilotkit/copilotkit_lg_middleware.py +++ b/sdk-python/copilotkit/copilotkit_lg_middleware.py @@ -154,6 +154,10 @@ class StateSchema(AgentState): { "messages", "copilotkit", + # Transport-layer plumbing: forwarded request headers conveyed via a + # separate ContextVar to the httpx hook. MUST never be rendered into + # the LLM prompt — neither via App Context nor via expose_state. + "copilotkit_forwarded_headers", "ag-ui", "tools", "structured_response", @@ -218,7 +222,17 @@ def _build_state_note(self, state: dict) -> str | None: if self._expose_state is False: return None if isinstance(self._expose_state, frozenset): - keys: list[str] = [k for k in self._expose_state if k in state] + # Allowlist branch: honor user intent for other reserved keys + # (e.g. ``thread_id``) so the override test in this suite still + # passes, but hard-exclude ``copilotkit_forwarded_headers`` — + # rendering it would leak the raw forwarded request headers into + # the LLM prompt, which is what the reserved-keys comment above + # promises will never happen "via App Context nor via expose_state". + keys: list[str] = [ + k + for k in self._expose_state + if k in state and k != "copilotkit_forwarded_headers" + ] else: keys = [ k @@ -487,6 +501,19 @@ def before_agent( runtime, "context", None ) + # Strip the reserved transport-layer key ``copilotkit_forwarded_headers`` + # so it is never rendered into the LLM prompt. langgraph-api auto-copies + # ``config.configurable`` into ``runtime.context``, which means the + # forwarded-headers wrapper dict shows up here even though it is only + # meant for the httpx hook (which reads it from a separate ContextVar + # via ``_extract_forwarded_headers_from_config``). + if isinstance(app_context, dict): + app_context = { + k: v + for k, v in app_context.items() + if k != "copilotkit_forwarded_headers" + } + # Check if app_context is missing or empty if not app_context: return None diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index 62dc7cdd22c..f950a655a64 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "copilotkit" -version = "0.1.92" +version = "0.1.93" description = "CopilotKit python SDK" authors = ["Markus Ecker "] license = "MIT" diff --git a/sdk-python/tests/test_copilotkit_lg_middleware.py b/sdk-python/tests/test_copilotkit_lg_middleware.py index 9577d0b085c..3e31f68c85b 100644 --- a/sdk-python/tests/test_copilotkit_lg_middleware.py +++ b/sdk-python/tests/test_copilotkit_lg_middleware.py @@ -304,6 +304,74 @@ def test_expose_state_emits_valid_json_payload(): assert parsed == {"liked": ["a", "b"], "count": 3, "nested": {"k": "v"}} +def test_expose_state_true_never_surfaces_forwarded_headers(): + """``copilotkit_forwarded_headers`` is a transport-layer plumbing key — it + must NEVER reach the LLM prompt via the ``expose_state`` path either, even + when ``expose_state=True`` would otherwise serialize every non-reserved + top-level state key. + """ + middleware = CopilotKitMiddleware(expose_state=True) + request = _make_request( + state={ + "messages": [], + "liked": ["a"], + "copilotkit_forwarded_headers": { + "x-aimock-context": "showcase/d6", + "x-aimock-strict": "true", + }, + } + ) + + seen, _ = _run_wrap(middleware, request) + + body = seen.system_message.content if seen.system_message else "" + # The genuine user key is still surfaced. + assert '"liked"' in body + # The transport-layer wrapper and its header values are not. + assert "copilotkit_forwarded_headers" not in body, ( + "copilotkit_forwarded_headers must never appear in expose_state output" + ) + assert "x-aimock-context" not in body, ( + "forwarded header values must never appear in expose_state output" + ) + assert "x-aimock-strict" not in body + assert "showcase/d6" not in body + + +def test_expose_state_allowlist_never_surfaces_forwarded_headers(): + """``copilotkit_forwarded_headers`` must NEVER reach the LLM prompt via the + ``expose_state`` path — including the explicit allowlist form. The sibling + ``test_expose_state_allowlist_can_override_reserved_keys`` pins that users + CAN allowlist other reserved keys (e.g. ``thread_id``) on purpose; this key + is the one exception, because it is a transport-layer wrapper for forwarded + request headers and rendering it would leak the raw headers into the prompt. + """ + middleware = CopilotKitMiddleware( + expose_state=frozenset({"copilotkit_forwarded_headers"}) + ) + request = _make_request( + state={ + "messages": [], + "copilotkit_forwarded_headers": { + "x-aimock-context": "showcase/d6", + "x-aimock-strict": "true", + }, + } + ) + + seen, _ = _run_wrap(middleware, request) + + body = seen.system_message.content if seen.system_message else "" + assert "copilotkit_forwarded_headers" not in body, ( + "copilotkit_forwarded_headers must never appear in allowlist output" + ) + assert "x-aimock-context" not in body, ( + "forwarded header values must never appear in allowlist output" + ) + assert "x-aimock-strict" not in body + assert "showcase/d6" not in body + + # --------------------------------------------------------------------------- # Async wrapper parity # --------------------------------------------------------------------------- @@ -410,6 +478,80 @@ def test_before_agent_uses_runtime_context_when_state_context_empty(): assert any("/dashboard" in s for s in sys_contents) +def test_before_agent_strips_copilotkit_forwarded_headers_from_runtime_context(): + """``copilotkit_forwarded_headers`` is a transport-layer plumbing key that + langgraph-api auto-copies from ``configurable`` into ``context``. It must + never be rendered into the LLM prompt as App Context — the forwarded-headers + httpx conveyance path reads it from a separate ContextVar. + + When that key is the ONLY thing in ``runtime.context``, ``before_agent`` + must treat it as empty App Context and not inject an "App Context:" system + message at all. + """ + middleware = CopilotKitMiddleware() + state = {"messages": [HumanMessage("hi")], "copilotkit": {}} + runtime = MagicMock( + name="runtime", + context={ + "copilotkit_forwarded_headers": { + "x-aimock-context": "showcase/d6", + "x-aimock-strict": "true", + } + }, + ) + + result = middleware.before_agent(state, runtime) + + # Contract: when ``runtime.context`` contains ONLY + # ``copilotkit_forwarded_headers``, the strip leaves an empty App Context, + # so ``before_agent`` MUST short-circuit and return None (no App Context + # system message). A non-None result here means the strip regressed and + # the transport-layer wrapper is being injected into the prompt. + # + # NOTE: an earlier version of this test guarded the leak assertions with + # ``if result is not None``, which silently passed if the strip stopped + # short-circuiting — exactly the regression we need to catch. Assert + # explicitly instead. + assert result is None, ( + "expected short-circuit (no App Context message) when only " + "copilotkit_forwarded_headers is present in runtime.context" + ) + + +def test_before_agent_strips_forwarded_headers_but_keeps_real_app_context(): + """When ``runtime.context`` contains both a genuine app key AND the + transport-only ``copilotkit_forwarded_headers`` wrapper, the App Context + system message must still be injected with the real key, but the forwarded + headers must be filtered out. + """ + middleware = CopilotKitMiddleware() + state = {"messages": [HumanMessage("hi")], "copilotkit": {}} + runtime = MagicMock( + name="runtime", + context={ + "user_tier": "pro", + "copilotkit_forwarded_headers": { + "x-aimock-context": "showcase/d6", + }, + }, + ) + + result = middleware.before_agent(state, runtime) + + assert result is not None + sys_contents = _system_contents(result["messages"]) + # The genuine app context is still surfaced. + assert any("App Context:" in s for s in sys_contents) + assert any("user_tier" in s and "pro" in s for s in sys_contents) + # The transport-layer wrapper is stripped from the rendered prompt. + assert not any("copilotkit_forwarded_headers" in s for s in sys_contents), ( + "copilotkit_forwarded_headers must be filtered out of the App Context message" + ) + assert not any("x-aimock-context" in s for s in sys_contents), ( + "forwarded header values must never appear in a system prompt" + ) + + # --------------------------------------------------------------------------- # after_model — frontend tool-call interception # --------------------------------------------------------------------------- diff --git a/showcase/shell-docs/next.config.ts b/showcase/shell-docs/next.config.ts index 5f95f5fdcb6..b01b83af332 100644 --- a/showcase/shell-docs/next.config.ts +++ b/showcase/shell-docs/next.config.ts @@ -433,7 +433,7 @@ const nextConfig: NextConfig = { }, { source: "/learn/tutorials/multi-conversation-chat", - destination: "/tutorials/multi-conversation-chat", + destination: "/", permanent: true, }, { @@ -507,11 +507,11 @@ const nextConfig: NextConfig = { destination: "/coding-agents", permanent: false, }, - // Orphaned broken stub. + // Old guide now belongs in the v2 hook reference. { source: "/copilot-suggestions", - destination: "/", - permanent: false, + destination: "/reference/v2/hooks/useSuggestions", + permanent: true, }, // AI-slop placeholder pulled from nav until properly authored; // file stays on disk for rewrite. @@ -520,14 +520,6 @@ const nextConfig: NextConfig = { destination: "/generative-ui", permanent: false, }, - // ~1-year-old migration target, no longer a meaningful jump-off - // point. - { - source: "/migrate/1.10.X", - destination: "/migrate/v2", - permanent: false, - }, - // ag-ui-middleware moved into the agentic-protocols group so it // appears in the sidebar under AG-UI rather than as an orphan // root page. 302 (not 301) since the new home is recent and we diff --git a/showcase/shell-docs/src/components/docs-landing-next.tsx b/showcase/shell-docs/src/components/docs-landing-next.tsx index bd0e718e0cb..63c4213bdb6 100644 --- a/showcase/shell-docs/src/components/docs-landing-next.tsx +++ b/showcase/shell-docs/src/components/docs-landing-next.tsx @@ -18,6 +18,7 @@ const backendDescriptions: Record = { "Python LangGraph agents with the broadest feature coverage.", "langgraph-typescript": "TypeScript LangGraph agents over the AG-UI adapter.", "langgraph-fastapi": "Python LangGraph agents exposed through FastAPI.", + deepagents: "LangChain Deep Agents connected to CopilotKit product UI.", "google-adk": "Gemini-powered Google ADK agents connected through AG-UI.", mastra: "TypeScript-native agents, tools, memory, and workflows.", "crewai-crews": "CrewAI crews wired into CopilotKit product interfaces.", diff --git a/showcase/shell-docs/src/content/docs/integrations/built-in-agent/meta.json b/showcase/shell-docs/src/content/docs/integrations/built-in-agent/meta.json index dafe11091af..ee033b3b04d 100644 --- a/showcase/shell-docs/src/content/docs/integrations/built-in-agent/meta.json +++ b/showcase/shell-docs/src/content/docs/integrations/built-in-agent/meta.json @@ -23,6 +23,7 @@ "mcp-servers", "model-selection", "advanced-configuration", + "telemetry", "---Backend---", "copilot-runtime", "custom-agent", "ag-ui", "---Premium Features---", diff --git a/showcase/shell-docs/src/content/docs/integrations/built-in-agent/telemetry.mdx b/showcase/shell-docs/src/content/docs/integrations/built-in-agent/telemetry.mdx new file mode 100644 index 00000000000..1bdcb258e29 --- /dev/null +++ b/showcase/shell-docs/src/content/docs/integrations/built-in-agent/telemetry.mdx @@ -0,0 +1,24 @@ +--- +title: Anonymous Telemetry +--- + +We use anonymous telemetry (metadata-only) to learn how to improve CopilotKit. + +- Open-source telemetry is **completely anonymous** +- We **do not collect any data** about end-users (the users interacting with your copilot) +- We **do not collect any application data** flowing through your system, only CopilotKit metadata +- We do not sell or share any data with third parties +- We do not use cookies or trackers in open-source telemetry +- To minimize the frequency of data sent, we apply batching and sampling to telemetry + +## How to opt out of anonymous telemetry + +Set `COPILOTKIT_TELEMETRY_DISABLED=true` in your runtime environment. This disables telemetry for both the CopilotRuntime and the Inspector dev-console. We also respect [Do Not Track (DNT)](https://consoledonottrack.com/). + +## How to adjust telemetry sample rate + +The default sample rate is `0.05` (5%). You can adjust it by setting the `COPILOTKIT_TELEMETRY_SAMPLE_RATE` to any value between 0 and 1. + +## Get in touch + +If you have any questions or concerns, please reach out at [hello@copilotkit.ai](mailto:hello@copilotkit.ai). diff --git a/showcase/shell-docs/src/content/docs/meta.json b/showcase/shell-docs/src/content/docs/meta.json index 53ff5b0b2be..b12a9380630 100644 --- a/showcase/shell-docs/src/content/docs/meta.json +++ b/showcase/shell-docs/src/content/docs/meta.json @@ -66,7 +66,7 @@ }, { "title": "Migrate", - "pages": ["migrate/v2", "migrate/1.8.2"] + "pages": ["migrate/v2", "migrate/1.10.X", "migrate/1.8.2"] } ] } diff --git a/showcase/shell-docs/src/content/docs/migrate/1.10.X.mdx b/showcase/shell-docs/src/content/docs/migrate/1.10.X.mdx new file mode 100644 index 00000000000..a959d805755 --- /dev/null +++ b/showcase/shell-docs/src/content/docs/migrate/1.10.X.mdx @@ -0,0 +1,9 @@ +--- +title: Migrate to 1.10.X +description: Migration guide for CopilotKit 1.10.X +icon: "lucide/RefreshCw" +--- + +import MigrateTo1100 from "@/snippets/shared/troubleshooting/migrate-to-1.10.X.mdx"; + + diff --git a/showcase/shell-docs/src/content/docs/migrate/meta.json b/showcase/shell-docs/src/content/docs/migrate/meta.json index 94a627ffadf..af84f0d0920 100644 --- a/showcase/shell-docs/src/content/docs/migrate/meta.json +++ b/showcase/shell-docs/src/content/docs/migrate/meta.json @@ -1,5 +1,5 @@ { "title": "Migrate", "icon": "lucide/RefreshCw", - "pages": ["v2", "1.8.2"] + "pages": ["v2", "1.10.X", "1.8.2"] } diff --git a/showcase/shell-docs/src/content/reference/hooks/useCapabilities.mdx b/showcase/shell-docs/src/content/reference/hooks/useCapabilities.mdx new file mode 100644 index 00000000000..33122dda676 --- /dev/null +++ b/showcase/shell-docs/src/content/reference/hooks/useCapabilities.mdx @@ -0,0 +1,133 @@ +--- +title: "useCapabilities" +description: "React hook for reading an agent's declared capabilities" +--- +## Overview + +`useCapabilities` returns the [AG-UI `AgentCapabilities`](https://docs.ag-ui.com/concepts/capabilities) declared by an agent. Capabilities describe what an agent supports -- tool calling, streaming, multi-agent coordination, human-in-the-loop, and more. + +Capabilities are populated from the runtime `/info` response at connection time. The value is `undefined` until the runtime handshake completes or if the agent doesn't declare capabilities. + +## Signature + +```tsx +import { useCapabilities } from "@copilotkit/react-core/v2"; + +function useCapabilities(agentId?: string): AgentCapabilities | undefined +``` + +## Parameters + + + ID of the agent whose capabilities to read. If omitted, uses the default agent. + + +## Return Value + + + The agent's capability declaration, or `undefined` if the agent hasn't reported capabilities yet (e.g., the runtime handshake is still in progress) or doesn't implement `getCapabilities()`. + + When defined, the object contains these optional categories: + + + Agent metadata: `name`, `type` (framework), `description`, `version`, `provider`, `documentationUrl`, `metadata`. + + + + Supported transports: `streaming`, `websocket`, `httpBinary`, `pushNotifications`, `resumable`. + + + + Tool support: `supported`, `items`, `parallelCalls`, `clientProvided`. + + + + Output formats: `structuredOutput`, `supportedMimeTypes`. + + + + State management: `snapshots`, `deltas`, `memory`, `persistentState`. + + + + Multi-agent coordination: `supported`, `delegation`, `handoffs`, `subAgents`. + + + + Reasoning/thinking visibility: `supported`, `streaming`, `encrypted`. + + + + Multimodal input/output: `input` and `output` sub-objects for images, audio, video, PDF, and file support. + + + + Code execution: `codeExecution`, `sandboxed`, `maxIterations`, `maxExecutionTime`. + + + + Human-in-the-loop: `supported`, `approvals`, `interventions`, `feedback`. + + + + Arbitrary key-value pairs for integration-specific capabilities. + + + +## Usage + +### Conditionally render UI based on capabilities + +```tsx title="ToolPanel.tsx" +import { useCapabilities } from "@copilotkit/react-core/v2"; + +function ToolPanel() { + const capabilities = useCapabilities(); + + if (!capabilities?.tools?.supported) { + return null; // Agent doesn't support tools — hide the panel + } + + return ( +
+

Tools

+ {capabilities.tools.clientProvided && ( +

This agent accepts client-provided tools.

+ )} +
+ ); +} +``` + +### Read capabilities for a specific agent + +```tsx title="AgentInfo.tsx" +import { useCapabilities } from "@copilotkit/react-core/v2"; + +function AgentInfo({ agentId }: { agentId: string }) { + const capabilities = useCapabilities(agentId); + + if (!capabilities) { + return

Loading capabilities…

; + } + + return ( +
    +
  • Streaming: {capabilities.transport?.streaming ? "Yes" : "No"}
  • +
  • Tools: {capabilities.tools?.supported ? "Yes" : "No"}
  • +
  • Human-in-the-loop: {capabilities.humanInTheLoop?.supported ? "Yes" : "No"}
  • +
+ ); +} +``` + +## Behavior + +- **Synchronous read** -- The hook reads `capabilities` directly from the agent instance. There is no separate loading state or async fetch -- the value is `undefined` until the runtime `/info` handshake populates it, then becomes defined. +- **Re-renders** -- The hook calls `useAgent()` internally, so the component re-renders on agent state, message, and run-status changes (the default `updates` set). If you only need capabilities and want fewer re-renders, read `agent.capabilities` directly via `useAgent({ updates: [] })`. +- **Stable reference** -- The capabilities object reference only changes when the agent reconnects or the runtime response changes. It does not change on every render. + +## Related + +- [`useAgent`](/reference/v2/hooks/useAgent) -- access the full agent instance (capabilities are a property of the agent) +- [AG-UI Protocol](/agentic-protocols/ag-ui) -- the protocol that defines the capabilities schema diff --git a/showcase/shell-docs/src/lib/__tests__/docs-render.test.ts b/showcase/shell-docs/src/lib/__tests__/docs-render.test.ts index c45fadbeb2a..ff47fed99b0 100644 --- a/showcase/shell-docs/src/lib/__tests__/docs-render.test.ts +++ b/showcase/shell-docs/src/lib/__tests__/docs-render.test.ts @@ -6,7 +6,12 @@ vi.mock("../registry", () => ({ getDocsMode: () => "generated", })); -import { inlineSnippets, SNIPPET_MAP, SNIPPETS_DIR } from "../docs-render"; +import { + inlineSnippets, + loadDoc, + SNIPPET_MAP, + SNIPPETS_DIR, +} from "../docs-render"; let tempDir = ""; @@ -101,3 +106,13 @@ describe("inlineSnippets", () => { ); }); }); + +describe("loadDoc", () => { + it("resolves clean URLs to files stored under route-group folders", () => { + const doc = loadDoc("integrations/aws-strands/telemetry"); + + expect(doc?.filePath).toContain( + "integrations/aws-strands/(other)/telemetry/index.mdx", + ); + }); +}); diff --git a/showcase/shell-docs/src/lib/__tests__/route-groups.test.ts b/showcase/shell-docs/src/lib/__tests__/route-groups.test.ts new file mode 100644 index 00000000000..22fcdc1bef6 --- /dev/null +++ b/showcase/shell-docs/src/lib/__tests__/route-groups.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { stripRouteGroupSegmentsFromPathname } from "../route-groups"; + +describe("stripRouteGroupSegmentsFromPathname", () => { + it("removes route groups from public paths", () => { + expect( + stripRouteGroupSegmentsFromPathname("/strands/(other)/telemetry"), + ).toBe("/strands/telemetry"); + }); + + it("keeps non-route-group paths unchanged", () => { + expect(stripRouteGroupSegmentsFromPathname("/strands/telemetry")).toBe( + "/strands/telemetry", + ); + }); +}); diff --git a/showcase/shell-docs/src/lib/__tests__/seo-redirects.test.ts b/showcase/shell-docs/src/lib/__tests__/seo-redirects.test.ts new file mode 100644 index 00000000000..fca8e3783bc --- /dev/null +++ b/showcase/shell-docs/src/lib/__tests__/seo-redirects.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { seoRedirects } from "../seo-redirects"; + +describe("seoRedirects", () => { + it("redirects old DeepAgents integration URLs to the framework root", () => { + expect(seoRedirects).toContainEqual({ + id: "INT-wild×deepagents", + source: "/integrations/deepagents/:path*", + destination: "/deepagents/:path*", + }); + }); + + it("redirects old migration guide slugs to their new pages", () => { + expect(seoRedirects).toEqual( + expect.arrayContaining([ + { + id: "MG2a", + source: "/migration-guides/migrate-to-v2", + destination: "/migrate/v2", + }, + { + id: "MG3a", + source: "/migration-guides/migrate-to-1.10.X", + destination: "/migrate/1.10.X", + }, + { + id: "MG4a", + source: "/migration-guides/migrate-to-1.8.2", + destination: "/migrate/1.8.2", + }, + ]), + ); + }); + + it("redirects the last old docs URLs to their intended shell locations", () => { + expect(seoRedirects).toEqual( + expect.arrayContaining([ + { + id: "R13", + source: "/copilot-suggestions", + destination: "/reference/v2/hooks/useSuggestions", + }, + { + id: "R15", + source: "/integrations/built-in-agent", + destination: "/", + }, + { + id: "R16A", + source: "/integrations", + destination: "/", + }, + { + id: "MV-telemetry", + source: "/telemetry", + destination: "/built-in-agent/telemetry", + }, + ]), + ); + }); +}); diff --git a/showcase/shell-docs/src/lib/docs-render.tsx b/showcase/shell-docs/src/lib/docs-render.tsx index 341f68f3c2c..2683fdc2b6d 100644 --- a/showcase/shell-docs/src/lib/docs-render.tsx +++ b/showcase/shell-docs/src/lib/docs-render.tsx @@ -12,6 +12,7 @@ import path from "path"; import matter from "gray-matter"; import { resolveWithinDir } from "./safe-fs"; import { getDocsMode } from "./registry"; +import { isRouteGroupSegment } from "./route-groups"; export const CONTENT_DIR = path.join(process.cwd(), "src/content/docs"); export const SNIPPETS_DIR = path.join(CONTENT_DIR, "..", "snippets"); @@ -1433,6 +1434,74 @@ export interface DocFrontmatter { hideTOC?: boolean; } +function slugSegments(slugPath: string): string[] | null { + const segments = slugPath.split(/[\\/]+/).filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) { + return null; + } + return segments; +} + +function routeGroupSubdirs(dir: string): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory() && isRouteGroupSegment(entry.name)) + .map((entry) => entry.name); +} + +function resolveDocThroughRouteGroups( + dir: string, + segments: string[], +): string | null { + if (segments.length === 0) { + const indexPath = path.join(dir, "index.mdx"); + return fs.existsSync(indexPath) ? indexPath : null; + } + + const [segment, ...rest] = segments; + if (rest.length === 0) { + const mdxPath = path.join(dir, `${segment}.mdx`); + if (fs.existsSync(mdxPath)) return mdxPath; + const indexPath = path.join(dir, segment, "index.mdx"); + if (fs.existsSync(indexPath)) return indexPath; + } + + const directDir = path.join(dir, segment); + if (fs.existsSync(directDir) && fs.statSync(directDir).isDirectory()) { + const direct = resolveDocThroughRouteGroups(directDir, rest); + if (direct) return direct; + } + + for (const routeGroup of routeGroupSubdirs(dir)) { + const grouped = resolveDocThroughRouteGroups( + path.join(dir, routeGroup), + segments, + ); + if (grouped) return grouped; + } + + return null; +} + +function resolveRouteGroupedDocPath(slugPath: string): string | null { + const segments = slugSegments(slugPath); + if (!segments) return null; + + const filePath = resolveDocThroughRouteGroups(CONTENT_DIR, segments); + if (!filePath) return null; + + const resolved = resolveWithinDir( + CONTENT_DIR, + path.relative(CONTENT_DIR, filePath), + ); + return resolved && fs.existsSync(resolved) ? resolved : null; +} + /** * Load an MDX file by slug and return its raw source + parsed frontmatter * metadata for rendering. Returns null when the file doesn't exist. @@ -1456,7 +1525,12 @@ export function loadDoc( } else if (indexResolved && fs.existsSync(indexResolved)) { filePath = indexResolved; } else { - return null; + // Route groups such as `(other)` organize the sidebar filesystem but + // are not public URL segments. Resolve `/strands/telemetry` to + // `integrations/aws-strands/(other)/telemetry/index.mdx`. + const routeGroupedPath = resolveRouteGroupedDocPath(slugPath); + if (!routeGroupedPath) return null; + filePath = routeGroupedPath; } let source: string; diff --git a/showcase/shell-docs/src/lib/framework-order.ts b/showcase/shell-docs/src/lib/framework-order.ts index c7331d40102..0daba42df47 100644 --- a/showcase/shell-docs/src/lib/framework-order.ts +++ b/showcase/shell-docs/src/lib/framework-order.ts @@ -11,6 +11,7 @@ export const FRAMEWORK_DISPLAY_ORDER: readonly string[] = [ "langgraph-python", "langgraph-typescript", + "deepagents", "google-adk", "strands", "mastra", diff --git a/showcase/shell-docs/src/lib/registry.ts b/showcase/shell-docs/src/lib/registry.ts index 7a29a5d6114..7b39c9f47fe 100644 --- a/showcase/shell-docs/src/lib/registry.ts +++ b/showcase/shell-docs/src/lib/registry.ts @@ -102,16 +102,44 @@ export interface Registry { const registry = registryData as Registry; +const DOCS_ONLY_INTEGRATIONS: Integration[] = [ + { + name: "Deep Agents", + slug: "deepagents", + category: "popular", + language: "python", + description: + "LangChain Deep Agents connected to CopilotKit chat, state, tools, and generative UI.", + partner_docs: null, + repo: "", + copilotkit_version: "", + backend_url: "", + deployed: true, + docs_mode: "generated", + sort_order: 13, + features: [], + demos: [], + }, +]; + +function allIntegrations(): Integration[] { + const registeredSlugs = new Set(registry.integrations.map((i) => i.slug)); + return [ + ...registry.integrations, + ...DOCS_ONLY_INTEGRATIONS.filter((i) => !registeredSlugs.has(i.slug)), + ]; +} + export function getRegistry(): Registry { return registry; } export function getIntegrations(): Integration[] { - return registry.integrations; + return allIntegrations(); } export function getIntegration(slug: string): Integration | undefined { - return registry.integrations.find((i) => i.slug === slug); + return allIntegrations().find((i) => i.slug === slug); } /** @@ -230,7 +258,7 @@ export function getFeatureCategories(): FeatureCategory[] { export function getIntegrationsByCategory(): Record { const grouped: Record = {}; - for (const integration of registry.integrations) { + for (const integration of getIntegrations()) { if (!grouped[integration.category]) { grouped[integration.category] = []; } diff --git a/showcase/shell-docs/src/lib/route-groups.ts b/showcase/shell-docs/src/lib/route-groups.ts new file mode 100644 index 00000000000..5af7d49eea8 --- /dev/null +++ b/showcase/shell-docs/src/lib/route-groups.ts @@ -0,0 +1,10 @@ +export function isRouteGroupSegment(segment: string): boolean { + return /^\(.+\)$/.test(segment); +} + +export function stripRouteGroupSegmentsFromPathname(pathname: string): string { + const clean = pathname + .split("/") + .filter((segment) => segment.length > 0 && !isRouteGroupSegment(segment)); + return `/${clean.join("/")}`; +} diff --git a/showcase/shell-docs/src/lib/seo-redirects.ts b/showcase/shell-docs/src/lib/seo-redirects.ts index 5c1b85400cb..a1edba8ec0c 100644 --- a/showcase/shell-docs/src/lib/seo-redirects.ts +++ b/showcase/shell-docs/src/lib/seo-redirects.ts @@ -42,6 +42,7 @@ const FRAMEWORKS = [ "pydantic-ai", "llamaindex", "mastra", + "deepagents", "agent-spec", "ag2", "microsoft-agent-framework", @@ -469,15 +470,17 @@ const ROOT_RENAMES: RedirectEntry[] = [ { id: "R13", source: "/copilot-suggestions", - destination: "/prebuilt-components", + destination: "/reference/v2/hooks/useSuggestions", }, - // /direct-to-llm and /integrations/built-in-agent → built-in-agent (BIA canonical) + // /direct-to-llm keeps its BIA canonical path, but the retired + // /integrations/built-in-agent landing route should go home. { id: "R14", source: "/direct-to-llm", destination: "/built-in-agent" }, { id: "R15", source: "/integrations/built-in-agent", - destination: "/built-in-agent", + destination: "/", }, + { id: "R16A", source: "/integrations", destination: "/" }, { id: "R18", source: "/mcp", destination: "/build-with-agents" }, { id: "R19", source: "/vibe-coding-mcp", destination: "/build-with-agents" }, { @@ -668,12 +671,12 @@ const MOVED_ROOT_REDIRECTS: RedirectEntry[] = [ source: "/getting-started/quickstart-chatbot", destination: "/quickstart", }, - // /telemetry was a one-off legacy page. Send to the home page rather - // than 404 since the topic has no current canonical home. + // /telemetry was a one-off legacy page. It now lives under the + // built-in-agent docs where runtime configuration pages are grouped. { id: "MV-telemetry", source: "/telemetry", - destination: "/", + destination: "/built-in-agent/telemetry", }, // /reference/hooks/useCoAgent — useCoAgent (v1) was renamed to // useAgent (v2). External links still point at the old name. @@ -857,16 +860,31 @@ const DOCS_PREFIX: RedirectEntry[] = [ const MIGRATION_GUIDES: RedirectEntry[] = [ { id: "MG1", source: "/migration-guides", destination: "/migrate/v2" }, { id: "MG2", source: "/migration-guides/v2", destination: "/migrate/v2" }, + { + id: "MG2a", + source: "/migration-guides/migrate-to-v2", + destination: "/migrate/v2", + }, { id: "MG3", source: "/migration-guides/1.10.X", - destination: "/migrate/v2", + destination: "/migrate/1.10.X", + }, + { + id: "MG3a", + source: "/migration-guides/migrate-to-1.10.X", + destination: "/migrate/1.10.X", }, { id: "MG4", source: "/migration-guides/1.8.2", destination: "/migrate/1.8.2", }, + { + id: "MG4a", + source: "/migration-guides/migrate-to-1.8.2", + destination: "/migrate/1.8.2", + }, ]; // --------------------------------------------------------------------------- diff --git a/showcase/shell-docs/src/middleware.ts b/showcase/shell-docs/src/middleware.ts index 290a247ca05..cbfc609409f 100644 --- a/showcase/shell-docs/src/middleware.ts +++ b/showcase/shell-docs/src/middleware.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import type { NextFetchEvent, NextRequest } from "next/server"; import { seoRedirects } from "@/lib/seo-redirects"; +import { stripRouteGroupSegmentsFromPathname } from "@/lib/route-groups"; import registry from "@/data/registry.json"; // --------------------------------------------------------------------------- @@ -173,6 +174,14 @@ export function middleware( ): NextResponse { const { pathname } = request.nextUrl; + const routeGroupFreePath = stripRouteGroupSegmentsFromPathname(pathname); + if (routeGroupFreePath !== pathname) { + trackRedirect("route-group-strip", pathname, routeGroupFreePath); + const url = request.nextUrl.clone(); + url.pathname = routeGroupFreePath; + return NextResponse.redirect(url, 301); + } + // 1. Redirect lookup. // // 1a. Exact match (O(1) Map lookup) is consulted FIRST for every