From 78d3c2e2b398978db6f4d57142c0d450581e5cdc Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Mon, 21 Sep 2026 14:29:07 +0530 Subject: [PATCH 1/6] chore: migrate assistant-ui runtime to TrueForge workspace --- .changeset/migrate-assistant-runtime.md | 7 + .cursor/rules/server-types-runtime-only.mdc | 5 +- .prettierignore | 1 + AGENTS.md | 4 +- docs/docs.json | 4 + docs/ui-sdk/concepts/architecture.mdx | 8 +- docs/ui-sdk/guides/troubleshooting.mdx | 3 +- docs/ui-sdk/reference/containers.mdx | 8 +- docs/ui-sdk/reference/events.mdx | 4 +- docs/ui-sdk/reference/hooks.mdx | 26 +- docs/ui-sdk/reference/server.mdx | 7 +- docs/ui-sdk/reference/theme.mdx | 2 +- docs/ui-sdk/reference/trueforge-ui.mdx | 19 +- .../connect-with-truefoundry.mdx | 100 - .../setup-custom-servers/custom-server.mdx | 10 +- .../setup-custom-servers/server-contract.mdx | 18 +- .../ui-sdk/setup-custom-ui/slot-overrides.mdx | 2 +- eslint.config.mjs | 5 + package.json | 11 +- packages/assistant-ui-runtime/AGENTS.md | 7 + packages/assistant-ui-runtime/CHANGELOG.md | 201 ++ packages/assistant-ui-runtime/CLAUDE.md | 1 + packages/assistant-ui-runtime/README.md | 149 + packages/assistant-ui-runtime/package.json | 87 + .../src/askUserQuestion.ts | 38 + .../src/attachmentAdapter.ts | 63 + .../src/collectPending.ts | 167 + .../assistant-ui-runtime/src/constants.ts | 2 + .../src/convertTurnMessages.ts | 1679 +++++++++ .../src/createSubAgent.ts | 11 + .../src/draft/agentSpec.ts | 34 + .../src/draft/draftSessionBridge.ts | 28 + .../draft/trueforgeDraftThreadListAdapter.ts | 73 + .../src/draft/useDraftAgentSpec.ts | 289 ++ .../src/extractTurnUserText.ts | 23 + .../src/foldPeerThreads.ts | 553 +++ packages/assistant-ui-runtime/src/hooks.ts | 176 + packages/assistant-ui-runtime/src/index.ts | 218 ++ .../src/lastUserMessageText.ts | 19 + .../assistant-ui-runtime/src/listPages.ts | 19 + .../src/loadSessionSnapshot.ts | 34 + packages/assistant-ui-runtime/src/mcpAuth.ts | 35 + .../src/messageCustomMetadata.ts | 50 + .../src/modelMessageContent.ts | 149 + .../src/modelMessageImageContent.ts | 154 + .../src/requiredActionInputs.ts | 38 + .../src/sandboxDownload.ts | 33 + .../src/server/eventUtils.ts | 125 + .../assistant-ui-runtime/src/server/events.ts | 232 ++ .../assistant-ui-runtime/src/server/index.ts | 169 + .../assistant-ui-runtime/src/server/types.ts | 1136 ++++++ .../src/sessionListStartTimestamp.ts | 6 + .../src/sessionSnapshot.ts | 146 + .../src/sessionThreadMetadata.ts | 36 + packages/assistant-ui-runtime/src/sessions.ts | 17 + .../assistant-ui-runtime/src/streamTurn.ts | 118 + .../assistant-ui-runtime/src/toolApproval.ts | 413 +++ .../assistant-ui-runtime/src/toolResponse.ts | 346 ++ .../src/trueforgeExtras.ts | 223 ++ ...trueforgeOwnedSessionsThreadListAdapter.ts | 71 + .../src/trueforgeThreadListAdapter.ts | 69 + .../src/turnEventHelpers.ts | 71 + .../src/turnStreamUpdate.ts | 11 + packages/assistant-ui-runtime/src/types.ts | 84 + .../src/useTrueForgeAgentMessages.ts | 1138 ++++++ .../src/useTrueForgeAgentRuntime.ts | 308 ++ .../test/attachmentAdapter.test.ts | 56 + .../buildEditedUserMessageContent.test.ts | 51 + .../test/collectPending.test.ts | 59 + .../test/convertTurnMessages.test.ts | 3141 +++++++++++++++++ .../trueforgeDraftThreadListAdapter.test.ts | 225 ++ .../test/draft/useDraftAgentSpec.test.tsx | 246 ++ .../test/draftAgentConfig.test.ts | 112 + .../test/foldPeerThreads.test.ts | 425 +++ .../test/loadSessionSnapshot.test.ts | 63 + .../test/modelMessageImageContent.test.ts | 107 + .../test/requiredActionInputs.test.ts | 385 ++ .../requiredActionsFromActiveUpdate.test.ts | 92 + .../test/sandboxDownload.test.ts | 46 + .../test/server/types.test.ts | 29 + .../test/streamTurn.test.ts | 389 ++ .../test/toolApproval.test.ts | 137 + .../test/toolResponse.test.ts | 136 + .../test/trueforgeExtras.test.ts | 156 + ...orgeOwnedSessionsThreadListAdapter.test.ts | 140 + .../test/trueforgeThreadListAdapter.test.ts | 151 + .../assistant-ui-runtime/test/tsconfig.json | 7 + .../test/useTrueForgeAgentMessages.test.tsx | 1504 ++++++++ .../test/useTrueForgeAgentRuntime.test.tsx | 83 + packages/assistant-ui-runtime/tsconfig.json | 18 + packages/assistant-ui-runtime/tsup.config.ts | 11 + .../assistant-ui-runtime/vitest.config.ts | 8 + packages/frontend/README.md | 2 +- packages/frontend/package.json | 2 +- packages/frontend/src/gatewaySdkStubs.ts | 14 - packages/frontend/vite.config.ts | 9 - packages/trueforge-ui/README.md | 115 +- packages/trueforge-ui/docs/api.md | 37 +- packages/trueforge-ui/docs/compatibility.md | 19 +- .../docs/createTrueFoundryServer.temp.ts | 293 -- .../trueforge-ui/docs/server-types.temp.md | 538 --- .../trueforge-ui/docs/server-types.temp.ts | 561 --- packages/trueforge-ui/docs/server.md | 850 +---- packages/trueforge-ui/package.json | 11 +- .../src/atoms/SaveAgentButton.tsx | 12 +- .../draft/AgentConfigInstructionsContext.tsx | 6 +- .../atoms/draft/DraftCompositeSelector.tsx | 6 +- .../src/atoms/draft/DraftModelSelector.tsx | 6 +- .../draft/DraftReasoningEffortSelector.tsx | 6 +- .../atoms/draft/DraftSpecPreferenceBridge.tsx | 6 +- .../containers/AgentConfigDrawerContainer.tsx | 12 +- .../src/containers/AskUserContainer.tsx | 4 +- .../containers/AssistantMessageContainer.tsx | 4 +- .../src/containers/AssistantTextContainer.tsx | 4 +- .../src/containers/ComposerContainer.tsx | 6 +- .../src/containers/CustomActionContainer.tsx | 4 +- .../src/containers/HistoryLoaderContainer.tsx | 4 +- .../src/containers/McpAuthContainer.tsx | 6 +- .../containers/ResumeUnavailableContainer.tsx | 4 +- .../src/containers/ToolCallContainer.tsx | 4 +- ...Provider.tsx => TrueForgeChatProvider.tsx} | 24 +- .../src/containers/TrueForgeUIShell.tsx | 16 +- .../src/containers/nestedApprovalBridge.ts | 2 +- .../src/containers/useResolvedServer.ts | 60 +- .../trueforge-ui/src/hooks/useApprovalNav.ts | 4 +- .../src/hooks/useChatChromeActionsVisible.ts | 4 +- .../src/hooks/useComposerPauseView.ts | 6 +- packages/trueforge-ui/src/index.ts | 36 +- .../builderServer.ts | 2 +- .../catalogs/connectorCatalog.ts | 2 +- .../catalogs/modelProviderCatalog.ts | 2 +- .../catalogs/sandboxProviderCatalog.ts | 2 +- .../catalogs/skillCatalog.ts | 2 +- .../trueforge-agent-server-adapter/index.ts | 4 +- .../trueforge-ui/src/server/ServerContext.tsx | 4 +- .../src/server/TrueForgeServerConfig.ts | 31 +- ...ndryServer.ts => createTrueForgeServer.ts} | 14 +- .../test/atoms/ClearChatButton.test.tsx | 2 +- .../test/atoms/SaveAgentButton.test.tsx | 8 +- .../draft/DraftComposerSections.test.tsx | 4 +- .../draft/DraftCompositeSelector.test.tsx | 4 +- .../atoms/draft/DraftModelSelector.test.tsx | 4 +- .../AgentConfigDrawerContainer.test.tsx | 8 +- .../test/containers/AskUserContainer.test.tsx | 2 +- .../AssistantTextContainer.test.tsx | 4 +- .../containers/ComposerContainer.test.tsx | 8 +- .../containers/CustomActionContainer.test.tsx | 2 +- .../HistoryLoaderContainer.test.tsx | 4 +- .../test/containers/McpAuthContainer.test.tsx | 8 +- .../ResumeUnavailableContainer.test.tsx | 4 +- .../test/containers/Thread.test.tsx | 8 +- .../containers/ToolCallContainer.test.tsx | 2 +- ...est.tsx => TrueForgeChatProvider.test.tsx} | 44 +- .../test/containers/TrueForgeUI.test.tsx | 50 +- .../containers/useResolvedServer.test.tsx | 85 - .../test/hooks/useApprovalNav.test.tsx | 2 +- .../useChatChromeActionsVisible.test.tsx | 2 +- .../test/hooks/useComposerPauseView.test.tsx | 4 +- .../test/packageExports.smoke.mjs | 2 +- .../trueforge-ui/test/publicUiExports.test.ts | 28 +- .../routing/withRouter.integration.test.tsx | 24 +- ....test.ts => createTrueForgeServer.test.ts} | 8 +- packages/trueforge-ui/test/testSetup.ts | 4 + packages/trueforge-ui/test/tsconfig.json | 2 +- pnpm-lock.yaml | 659 ++-- pnpm-workspace.yaml | 5 - 166 files changed, 17553 insertions(+), 3268 deletions(-) create mode 100644 .changeset/migrate-assistant-runtime.md delete mode 100644 docs/ui-sdk/setup-custom-servers/connect-with-truefoundry.mdx create mode 100644 packages/assistant-ui-runtime/AGENTS.md create mode 100644 packages/assistant-ui-runtime/CHANGELOG.md create mode 100644 packages/assistant-ui-runtime/CLAUDE.md create mode 100644 packages/assistant-ui-runtime/README.md create mode 100644 packages/assistant-ui-runtime/package.json create mode 100644 packages/assistant-ui-runtime/src/askUserQuestion.ts create mode 100644 packages/assistant-ui-runtime/src/attachmentAdapter.ts create mode 100644 packages/assistant-ui-runtime/src/collectPending.ts create mode 100644 packages/assistant-ui-runtime/src/constants.ts create mode 100644 packages/assistant-ui-runtime/src/convertTurnMessages.ts create mode 100644 packages/assistant-ui-runtime/src/createSubAgent.ts create mode 100644 packages/assistant-ui-runtime/src/draft/agentSpec.ts create mode 100644 packages/assistant-ui-runtime/src/draft/draftSessionBridge.ts create mode 100644 packages/assistant-ui-runtime/src/draft/trueforgeDraftThreadListAdapter.ts create mode 100644 packages/assistant-ui-runtime/src/draft/useDraftAgentSpec.ts create mode 100644 packages/assistant-ui-runtime/src/extractTurnUserText.ts create mode 100644 packages/assistant-ui-runtime/src/foldPeerThreads.ts create mode 100644 packages/assistant-ui-runtime/src/hooks.ts create mode 100644 packages/assistant-ui-runtime/src/index.ts create mode 100644 packages/assistant-ui-runtime/src/lastUserMessageText.ts create mode 100644 packages/assistant-ui-runtime/src/listPages.ts create mode 100644 packages/assistant-ui-runtime/src/loadSessionSnapshot.ts create mode 100644 packages/assistant-ui-runtime/src/mcpAuth.ts create mode 100644 packages/assistant-ui-runtime/src/messageCustomMetadata.ts create mode 100644 packages/assistant-ui-runtime/src/modelMessageContent.ts create mode 100644 packages/assistant-ui-runtime/src/modelMessageImageContent.ts create mode 100644 packages/assistant-ui-runtime/src/requiredActionInputs.ts create mode 100644 packages/assistant-ui-runtime/src/sandboxDownload.ts create mode 100644 packages/assistant-ui-runtime/src/server/eventUtils.ts create mode 100644 packages/assistant-ui-runtime/src/server/events.ts create mode 100644 packages/assistant-ui-runtime/src/server/index.ts create mode 100644 packages/assistant-ui-runtime/src/server/types.ts create mode 100644 packages/assistant-ui-runtime/src/sessionListStartTimestamp.ts create mode 100644 packages/assistant-ui-runtime/src/sessionSnapshot.ts create mode 100644 packages/assistant-ui-runtime/src/sessionThreadMetadata.ts create mode 100644 packages/assistant-ui-runtime/src/sessions.ts create mode 100644 packages/assistant-ui-runtime/src/streamTurn.ts create mode 100644 packages/assistant-ui-runtime/src/toolApproval.ts create mode 100644 packages/assistant-ui-runtime/src/toolResponse.ts create mode 100644 packages/assistant-ui-runtime/src/trueforgeExtras.ts create mode 100644 packages/assistant-ui-runtime/src/trueforgeOwnedSessionsThreadListAdapter.ts create mode 100644 packages/assistant-ui-runtime/src/trueforgeThreadListAdapter.ts create mode 100644 packages/assistant-ui-runtime/src/turnEventHelpers.ts create mode 100644 packages/assistant-ui-runtime/src/turnStreamUpdate.ts create mode 100644 packages/assistant-ui-runtime/src/types.ts create mode 100644 packages/assistant-ui-runtime/src/useTrueForgeAgentMessages.ts create mode 100644 packages/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts create mode 100644 packages/assistant-ui-runtime/test/attachmentAdapter.test.ts create mode 100644 packages/assistant-ui-runtime/test/buildEditedUserMessageContent.test.ts create mode 100644 packages/assistant-ui-runtime/test/collectPending.test.ts create mode 100644 packages/assistant-ui-runtime/test/convertTurnMessages.test.ts create mode 100644 packages/assistant-ui-runtime/test/draft/trueforgeDraftThreadListAdapter.test.ts create mode 100644 packages/assistant-ui-runtime/test/draft/useDraftAgentSpec.test.tsx create mode 100644 packages/assistant-ui-runtime/test/draftAgentConfig.test.ts create mode 100644 packages/assistant-ui-runtime/test/foldPeerThreads.test.ts create mode 100644 packages/assistant-ui-runtime/test/loadSessionSnapshot.test.ts create mode 100644 packages/assistant-ui-runtime/test/modelMessageImageContent.test.ts create mode 100644 packages/assistant-ui-runtime/test/requiredActionInputs.test.ts create mode 100644 packages/assistant-ui-runtime/test/requiredActionsFromActiveUpdate.test.ts create mode 100644 packages/assistant-ui-runtime/test/sandboxDownload.test.ts create mode 100644 packages/assistant-ui-runtime/test/server/types.test.ts create mode 100644 packages/assistant-ui-runtime/test/streamTurn.test.ts create mode 100644 packages/assistant-ui-runtime/test/toolApproval.test.ts create mode 100644 packages/assistant-ui-runtime/test/toolResponse.test.ts create mode 100644 packages/assistant-ui-runtime/test/trueforgeExtras.test.ts create mode 100644 packages/assistant-ui-runtime/test/trueforgeOwnedSessionsThreadListAdapter.test.ts create mode 100644 packages/assistant-ui-runtime/test/trueforgeThreadListAdapter.test.ts create mode 100644 packages/assistant-ui-runtime/test/tsconfig.json create mode 100644 packages/assistant-ui-runtime/test/useTrueForgeAgentMessages.test.tsx create mode 100644 packages/assistant-ui-runtime/test/useTrueForgeAgentRuntime.test.tsx create mode 100644 packages/assistant-ui-runtime/tsconfig.json create mode 100644 packages/assistant-ui-runtime/tsup.config.ts create mode 100644 packages/assistant-ui-runtime/vitest.config.ts delete mode 100644 packages/frontend/src/gatewaySdkStubs.ts delete mode 100644 packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts delete mode 100644 packages/trueforge-ui/docs/server-types.temp.md delete mode 100644 packages/trueforge-ui/docs/server-types.temp.ts rename packages/trueforge-ui/src/containers/{TrueFoundryChatProvider.tsx => TrueForgeChatProvider.tsx} (87%) rename packages/trueforge-ui/src/server/{createTrueFoundryServer.ts => createTrueForgeServer.ts} (95%) rename packages/trueforge-ui/test/containers/{TrueFoundryChatProvider.test.tsx => TrueForgeChatProvider.test.tsx} (78%) rename packages/trueforge-ui/test/server/{createTrueFoundryServer.test.ts => createTrueForgeServer.test.ts} (96%) diff --git a/.changeset/migrate-assistant-runtime.md b/.changeset/migrate-assistant-runtime.md new file mode 100644 index 000000000..30c0eeb1f --- /dev/null +++ b/.changeset/migrate-assistant-runtime.md @@ -0,0 +1,7 @@ +--- +'@truefoundry/assistant-ui-runtime': minor +'@truefoundry/trueforge-ui': minor +'@truefoundry/trueforge': patch +--- + +Move the assistant-ui runtime into the TrueForge workspace, rename its public runtime APIs to TrueForge, and remove the legacy TrueFoundry server adapter and server configuration. diff --git a/.cursor/rules/server-types-runtime-only.mdc b/.cursor/rules/server-types-runtime-only.mdc index d551e556d..621199101 100644 --- a/.cursor/rules/server-types-runtime-only.mdc +++ b/.cursor/rules/server-types-runtime-only.mdc @@ -7,7 +7,8 @@ alwaysApply: false # Server types — runtime is the sole owner Canonical `AgentUIServer` / chat / builder / catalog port types live only in -`@truefoundry/assistant-ui-runtime` (`src/server/types.ts` + `events.ts`). +[`packages/assistant-ui-runtime`](packages/assistant-ui-runtime) +(`src/server/types.ts` + `events.ts`). ## Rules @@ -18,7 +19,7 @@ Canonical `AgentUIServer` / chat / builder / catalog port types live only in - Hosts import types from `@truefoundry/trueforge-ui` only — they must not need the runtime package for types. - Need a new field/method on the contract? Change runtime `server/types.ts` first, - publish/bump, then re-export — never fork the shape in the UI SDK. + then re-export and changeset both affected packages — never fork the shape in the UI SDK. - React wiring may stay here (`ServerContext`, `ShellModeContext`, `TrueForgeServerConfig`). Those modules **import** runtime types; they do not redefine ports. diff --git a/.prettierignore b/.prettierignore index 9ac097918..f92b3aefb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,6 +10,7 @@ docs/openapi.json # Helm chart sources and downloaded Bitnami subcharts; not ours to reformat. charts/ coverage/ +data/ # Prettier's markdown parser escapes `*` inside MDX `{/* */}` comments, breaking MDX parsing. *.mdx .turbo/ diff --git a/AGENTS.md b/AGENTS.md index 3b5b1ddf3..e18e079a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,13 @@ - Workspace tasks MUST use `package.json` scripts; add a script when a missing workflow is repeatable like the existing commands, not ad hoc commands. - CI package path filters, matrix package ids, and root scripts `test:*` in `.github/workflows/ci.yml` and root `package.json` MUST stay synchronized when a workspace package is added, renamed, or moved; the `store` filter sync rule lives in `packages/trueforge-core/src/agent-session/store/AGENTS.md`. - Release wiring MUST keep dist-free host development, `pnpm smoke`, and packed CJS/ESM consumers of `@truefoundry/trueforge-core` and `@truefoundry/trueforge-sdk` working without changes. Workspace loaders opt into SDK/core `src/` via the `trueforge-dev` export condition (never `"development"`); the SDK's `trueforge-dev` branch MUST keep `types` on its generated `.d.ts` (emitted by root `sdk:types`) because generated source is not `exactOptionalPropertyTypes`-clean. -- PRs that change published-package code (`packages/trueforge-core`, `packages/trueforge`, `packages/trueforge-ui`, `packages/trueforge-sdk`) or `packages/frontend` (ships inside `@truefoundry/trueforge`) MUST include a new `.changeset/*.md` file (`pnpm changeset`). Docs, CI/workflows, charts, and docker-compose changes do not. SDK regeneration already adds `@truefoundry/trueforge-sdk` via `pnpm changeset:sdk-regen`. +- PRs that change published-package code (`packages/assistant-ui-runtime`, `packages/trueforge-core`, `packages/trueforge`, `packages/trueforge-ui`, `packages/trueforge-sdk`) or `packages/frontend` (ships inside `@truefoundry/trueforge`) MUST include a new `.changeset/*.md` file (`pnpm changeset`). Docs, CI/workflows, charts, and docker-compose changes do not. SDK regeneration already adds `@truefoundry/trueforge-sdk` via `pnpm changeset:sdk-regen`. - Shared Postgres/Redis settings in `docker-compose.yml` and `docker-compose.dev.yml` (image versions, health checks, `env_file`) MUST stay synchronized; intentional differences (app services, data paths, project `name`, host ports, in-network `POSTGRES_HOST` / `REDIS_URL`) MUST stay explicit. `packages/trueforge/.env` is the host-dev + secrets source; `docker-compose.yml` may read it but MUST override container connectivity so host-dev localhost values are not used inside the smoke-test stack. - Changes to types or schemas MUST keep `packages/trueforge-core`, `packages/frontend`, `packages/trueforge`, and `patches` synchronized; they MUST NOT update only one affected layer. - TypeScript code MUST NOT use assertion escapes such as `as T`, `as unknown as T`, non-null `!`, or `as never` to silence type errors; implementations MUST use sound contracts, guards, or corrected types. - When catching an error and throwing another, the new error MUST set `{ cause: caught }` so the original failure is preserved for logs and debugging. - Every type, schema, helper, and contract MUST have one canonical owner; code MUST NOT introduce duplicate definitions or forwarding shims that hide ownership. -- AgentUIServer / server-port types (`AgentChatServer`, `AgentBuilderServer`, catalog ports, session/turn DTOs, stream events) MUST be defined only in `@truefoundry/assistant-ui-runtime` (`src/server/types.ts` / `events.ts`). `@truefoundry/trueforge-ui` MUST re-export them from `src/server/types.ts` (pass-through aliases only) and MUST NOT add hand-written parallel definitions of those ports/DTOs. +- AgentUIServer / server-port types (`AgentChatServer`, `AgentBuilderServer`, catalog ports, session/turn DTOs, stream events) MUST be defined only in `packages/assistant-ui-runtime/src/server/types.ts` / `events.ts`. `@truefoundry/trueforge-ui` MUST re-export them from `src/server/types.ts` (pass-through aliases only) and MUST NOT add hand-written parallel definitions of those ports/DTOs. - Runtime types backed by Zod schemas MUST be derived with named `z.infer` aliases; code MUST NOT duplicate those schemas as hand-written interfaces or indirect utility-type chains. - Modules MUST use static `import` and `import type`; they MUST NOT use `require()`, `require.resolve()`, or lint suppressions to bypass import checks. - A change that makes code unused MUST remove that dead code in the same change; it MUST NOT leave stale exports, files, documentation, duplicates, or “just in case” shims. diff --git a/docs/docs.json b/docs/docs.json index d7305f7ee..baa97e1db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -76,6 +76,10 @@ "pages": [ "chat-ui", "ui-sdk/get-started/quickstart", + { + "group": "Server setup", + "pages": ["ui-sdk/setup-custom-servers/server-contract", "ui-sdk/setup-custom-servers/custom-server"] + }, { "group": "Frontend Customisation", "pages": [ diff --git a/docs/ui-sdk/concepts/architecture.mdx b/docs/ui-sdk/concepts/architecture.mdx index 224b2d17a..1e70b963d 100644 --- a/docs/ui-sdk/concepts/architecture.mdx +++ b/docs/ui-sdk/concepts/architecture.mdx @@ -35,18 +35,18 @@ That replacement is what `overrides` does: name a component, supply your own, an - + - + ``` -Order matters: `SlotsProvider` must be outside `TrueFoundryChatProvider`. +Order matters: `SlotsProvider` must be outside `TrueForgeChatProvider`. - Composing manually means you own runtime resets. `TrueForgeUI` remounts the chat runtime when the selected agent changes, a new draft starts, or the chat is cleared; a hand-rolled stack does not. Change the `key` on `TrueFoundryChatProvider` yourself at those moments, or stale messages carry over between agents. + Composing manually means you own runtime resets. `TrueForgeUI` remounts the chat runtime when the selected agent changes, a new draft starts, or the chat is cleared; a hand-rolled stack does not. Change the `key` on `TrueForgeChatProvider` yourself at those moments, or stale messages carry over between agents. ## Styling hooks diff --git a/docs/ui-sdk/guides/troubleshooting.mdx b/docs/ui-sdk/guides/troubleshooting.mdx index 45078c5d0..b63d36932 100644 --- a/docs/ui-sdk/guides/troubleshooting.mdx +++ b/docs/ui-sdk/guides/troubleshooting.mdx @@ -62,8 +62,7 @@ description: "Common failure modes and their causes." The built-in server failed to resolve. For `type: "trueforge"`, confirm `@truefoundry/trueforge-sdk` is installed, `baseUrl` is reachable, and you passed either `token` or a working `fetch` — see - [Quickstart](/ui-sdk/get-started/quickstart). For `type: "truefoundry"`, check that `apiKey` and - `controlPlaneURL` are set and reachable. Attach an `onError` handler to see the underlying error. + [Quickstart](/ui-sdk/get-started/quickstart). Attach an `onError` handler to see the underlying error. diff --git a/docs/ui-sdk/reference/containers.mdx b/docs/ui-sdk/reference/containers.mdx index ae9d2078a..00c0d572d 100644 --- a/docs/ui-sdk/reference/containers.mdx +++ b/docs/ui-sdk/reference/containers.mdx @@ -14,16 +14,16 @@ need few props. Use them to build a [custom layout](/ui-sdk/guides/layouts). | `ThreadContainer` | `{ composer?: ReactNode }` | The message transcript with an optional composer slot beneath it. | | `ComposerContainer` | `{ placeholder?: string }` | Input, attachments, model label, send/cancel. | | `ThreadListContainer` | `{ onThreadOpen?: () => void }` | Session list with new-chat affordance. | -| `TrueFoundryChatProvider` | see below | Mounts the chat runtime. | +| `TrueForgeChatProvider` | see below | Mounts the chat runtime. | ```ts -type TrueFoundryChatProviderProps = { +type TrueForgeChatProviderProps = { server: AgentUIServer; initialSessionId?: string; - adapters?: UseTrueFoundryAgentRuntimeOptions["adapters"]; + adapters?: UseTrueForgeAgentRuntimeOptions["adapters"]; onError?: (error: unknown) => void; children: ReactNode; - agent?: TrueFoundryAgentConfig; + agent?: TrueForgeAgentConfig; agentName?: string; listSessionsAgentId?: string; }; diff --git a/docs/ui-sdk/reference/events.mdx b/docs/ui-sdk/reference/events.mdx index d494c9bf7..2178cd0a8 100644 --- a/docs/ui-sdk/reference/events.mdx +++ b/docs/ui-sdk/reference/events.mdx @@ -156,9 +156,9 @@ interface ToolCall { } type ToolInfo = - | { type: "truefoundry-system"; name: string } + | { type: "trueforge-system"; name: string } | { type: "mcp"; serverId: string; serverName: string; name: string } - | { type: string; name?: string; [key: string]: unknown }; + | { type: string; name?: string }; ``` `function.arguments` is a JSON **string**, not an object. `toolInfo` drives presentation: the diff --git a/docs/ui-sdk/reference/hooks.mdx b/docs/ui-sdk/reference/hooks.mdx index 9e8aff3bd..15195aa8b 100644 --- a/docs/ui-sdk/reference/hooks.mdx +++ b/docs/ui-sdk/reference/hooks.mdx @@ -52,21 +52,21 @@ Import these from `@truefoundry/trueforge-ui` like everything else: | Export | Purpose | | --- | --- | -| `useTrueFoundryAgentRuntime` | The underlying runtime hook. | -| `useTrueFoundryAgentSpec` | Read the active agent spec. | -| `useTrueFoundryUpdateAgentSpec` | Mutate the draft spec. | -| `useTrueFoundryCancel` | Cancel the in-flight turn. | -| `useTrueFoundryTurnId` | The id of the current turn. | -| `useTrueFoundryHistoryPagination` | Page backwards through history. | -| `useTrueFoundryMcpAuth` | Low-level MCP auth state. | -| `useTrueFoundryRespondToToolApproval` | Approve or reject a tool call. | -| `useTrueFoundryToolResponses` | Read tool responses. | -| `useTrueFoundryDownloadSandboxFile` | Download a sandbox artifact. | +| `useTrueForgeAgentRuntime` | The underlying runtime hook. | +| `useTrueForgeAgentSpec` | Read the active agent spec. | +| `useTrueForgeUpdateAgentSpec` | Mutate the draft spec. | +| `useTrueForgeCancel` | Cancel the in-flight turn. | +| `useTrueForgeTurnId` | The id of the current turn. | +| `useTrueForgeHistoryPagination` | Page backwards through history. | +| `useTrueForgeMcpAuth` | Low-level MCP auth state. | +| `useTrueForgeRespondToToolApproval` | Approve or reject a tool call. | +| `useTrueForgeToolResponses` | Read tool responses. | +| `useTrueForgeDownloadSandboxFile` | Download a sandbox artifact. | | `mergeAgentSpec` | Merge partial specs. | -| `trueFoundryAttachmentAdapter` | Default attachment adapter. | +| `trueForgeAttachmentAdapter` | Default attachment adapter. | -Types: `DraftAgentConfig`, `NamedAgentConfig`, `TrueFoundryAgentConfig`, -`UseTrueFoundryAgentRuntimeOptions`. +Types: `DraftAgentConfig`, `NamedAgentConfig`, `TrueForgeAgentConfig`, +`UseTrueForgeAgentRuntimeOptions`. ## Utilities diff --git a/docs/ui-sdk/reference/server.mdx b/docs/ui-sdk/reference/server.mdx index 77e561986..972d838cb 100644 --- a/docs/ui-sdk/reference/server.mdx +++ b/docs/ui-sdk/reference/server.mdx @@ -6,7 +6,7 @@ description: "Server factory, context hooks, shell mode, and contract types." ## Factory ```ts -createTrueFoundryServer(options: CreateTrueFoundryServerOptions): TrueFoundryServer; +createTrueForgeServer(options: CreateTrueForgeServerOptions): TrueForgeServer; ``` Options: `{ chatServer, getCapabilities, getModels, getSkills, getMcp, searchAgents, saveAgent, deleteAgent?, catalog? }`. @@ -49,7 +49,7 @@ to reference: `AgentUIServer`, `AgentChatServer`, `AgentBuilderServer`, `AgentBuilderCapabilitiesResponse`, `CatalogServer`, `TrueForgeServerConfig`, - `CreateTrueFoundryServerOptions`, `TrueFoundryServer`. + `CreateTrueForgeServerOptions`, `TrueForgeServer`. `Session`, `CreateSessionRequest`, `UpdateSessionRequest`, `Turn`, `TurnState`, @@ -60,8 +60,7 @@ to reference: `UserToolResponseEvent`, `ApprovalDecision`. - `TurnStreamData`, `TurnStreamingEvent`, `SessionEvent`, `SessionEventItem`. These are typed - openly as `{ type: string; [key: string]: unknown }`; the concrete shapes are in + `TurnStreamData`, `TurnStreamingEvent`, `SessionEventItem`. The concrete event shapes are in [Streaming events](/ui-sdk/reference/events). diff --git a/docs/ui-sdk/reference/theme.mdx b/docs/ui-sdk/reference/theme.mdx index e7225ebf4..a0c059a1a 100644 --- a/docs/ui-sdk/reference/theme.mdx +++ b/docs/ui-sdk/reference/theme.mdx @@ -8,7 +8,7 @@ description: "Theme provider, hooks, presets, brand, and slots." | Export | Purpose | | --- | --- | | `ThemeProvider` | Owns light/dark/system state and injects token CSS variables on a wrapper `div.aui-theme-root`. | -| `SlotsProvider` | Slot override registry. Must sit outside `TrueFoundryChatProvider`. | +| `SlotsProvider` | Slot override registry. Must sit outside `TrueForgeChatProvider`. | | `BrandLogo` | Product mark. `variant="icon"` renders the compact square asset; `variant="logo"` renders the wider asset and falls back to the icon. With no brand override, the logo variant uses the built-in TrueForge wordmark. Overridable slot. Props: `{ className?, variant?: "icon" \| "logo" }`. | | `Icon` | Renders a built-in icon by name, or your replacement when `theme.icons` maps that name. Props: `{ name: string \| readonly string[] } & IconProps`. An array resolves to its **last** element, accommodating Font Awesome-style `["far", "clone"]` tuples; it is not a fallback chain. An unresolved name renders nothing. | diff --git a/docs/ui-sdk/reference/trueforge-ui.mdx b/docs/ui-sdk/reference/trueforge-ui.mdx index fe450e770..16f4ce555 100644 --- a/docs/ui-sdk/reference/trueforge-ui.mdx +++ b/docs/ui-sdk/reference/trueforge-ui.mdx @@ -13,21 +13,11 @@ import { TrueForgeUI } from "@truefoundry/trueforge-ui"; Where the chat gets its data. Either a built-in config object or a ready `AgentUIServer`. - + Omit entirely to pass an `AgentUIServer` object directly — there is no `"custom"` wrapper. `"trueforge"` loads the Harness adapter (`plugins/trueforge-agent-server-adapter`). See [Quickstart](/ui-sdk/get-started/quickstart). - - TrueFoundry API key. Required when `type` is `"truefoundry"`. Client-side keys are for local - development only. - - - TrueFoundry control plane URL. Required when `type` is `"truefoundry"`. - - - Target a specific gateway. Resolved from the control plane when omitted. - TrueForge / Harness API root. Used when `type` is `"trueforge"`. Defaults to `'/'`. @@ -41,6 +31,9 @@ import { TrueForgeUI } from "@truefoundry/trueforge-ui"; Attach or override settings catalogs. See [Settings catalog](/ui-sdk/reference/catalog). + + Override the built-in permissions port. + See [Server contract](/ui-sdk/setup-custom-servers/server-contract). @@ -223,7 +216,7 @@ import { TrueForgeUI } from "@truefoundry/trueforge-ui"; - Runtime adapter overrides. Attachments default to `trueFoundryAttachmentAdapter`. + Runtime adapter overrides. Attachments default to `trueForgeAttachmentAdapter`. @@ -247,7 +240,7 @@ Persisted sessions remain available through the thread list. ```tsx - - - ); -} -``` - -Add `gatewayPlaneURL` alongside `controlPlaneURL` to target a specific gateway. - -Because this config is resolved asynchronously, the SDK shows a brief loading indicator before the chat appears. The other two paths mount immediately. - -## Build the server yourself - -If you need to hold the resolved `AgentUIServer` — to wrap it, log through it, or pass it around before mounting — build it with `createTrueFoundryAgentUIServer`. It returns a ready server (gateway chat plus the Control Plane builder lists) that you hand to `server` directly, so there is no async resolution and no loading indicator on mount. - -```tsx App.tsx -import { useEffect, useState } from "react"; -import { TrueForgeUI } from "@truefoundry/trueforge-ui"; -import { - createTrueFoundryAgentUIServer, - type TrueFoundryAgentUIServer, -} from "@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter"; - -export default function App() { - const [server, setServer] = useState(null); - - useEffect(() => { - createTrueFoundryAgentUIServer({ - apiKey: import.meta.env.VITE_TFY_API_KEY, - cpURL: import.meta.env.VITE_TFY_CONTROL_PLANE_URL, - }).then(setServer); - }, []); - - if (server === null) { - return null; - } - - return ( -
- -
- ); -} -``` - -`gatewayURL` is optional — when omitted it is resolved from `cpURL`. - -## TrueFoundry chat, your own builder - -To keep TrueFoundry's hosted chat streaming but serve the builder surfaces — models, skills, connectors, and the agent library — from your own backend, compose the gateway chat port with your callbacks through `createTrueFoundryServer`. The stubs below return empty lists, which is enough to mount; fill them in as you add agent management. - -```jsx App.tsx -import { TrueForgeUI, createTrueFoundryServer } from "@truefoundry/trueforge-ui"; -import { createTrueFoundryChatServer } from "@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter"; - -const chatServer = createTrueFoundryChatServer({ - apiKey: import.meta.env.VITE_TFY_API_KEY, - baseUrl: import.meta.env.VITE_TFY_GATEWAY_URL, -}); - -const server = createTrueFoundryServer({ - chatServer, - getCapabilities: async () => ({ - data: { sandbox: { enabled: false }, skill: { enabled: false } }, - }), - getModels: async () => [], - getSkills: async () => [], - getMcp: async () => [], - searchAgents: async () => [], - saveAgent: async () => ({}), -}); - -export default function App() { - return ( -
- -
- ); -} -``` - -To own chat streaming as well — every call, including the `createTurn` event stream, served by your backend — implement the full interface instead: [Bring your own server](/ui-sdk/setup-custom-servers/custom-server). \ No newline at end of file diff --git a/docs/ui-sdk/setup-custom-servers/custom-server.mdx b/docs/ui-sdk/setup-custom-servers/custom-server.mdx index 5b08e1f6b..e3bf61d56 100644 --- a/docs/ui-sdk/setup-custom-servers/custom-server.mdx +++ b/docs/ui-sdk/setup-custom-servers/custom-server.mdx @@ -3,14 +3,10 @@ title: "Bring your own server" description: "Implement AgentUIServer and pass the object straight to the component." --- -Use this when your backend owns the whole contract — sessions, chat streaming, and the agent builder. You implement `AgentUIServer` and pass the object straight to the component, with no `type` wrapper: every call the UI makes, including the `createTurn` event stream, hits your endpoints. Nothing here depends on TrueFoundry. +Use this when your backend owns the whole contract — sessions, chat streaming, and the agent builder. You implement `AgentUIServer` and pass the object straight to the component, with no `type` wrapper: every call the UI makes, including the `createTurn` event stream, hits your endpoints. Because `server` accepts an `AgentUIServer` directly, you can front any backend — including your own proxy that keeps API keys server-side. The object is used immediately, so there is no loading indicator on mount. - - To keep TrueFoundry's hosted chat and supply only your own builder callbacks, use the composition helpers on [Connect with TrueFoundry](/ui-sdk/setup-custom-servers/connect-with-truefoundry#truefoundry-chat-your-own-builder) instead. This page assumes your backend serves everything. - - ## Minimal implementation The shape below is the smallest thing that mounts. Optional methods are omitted; required ones must exist even if they only throw in paths your UI never reaches. @@ -167,7 +163,7 @@ Spreading `nextPageToken` in only when present keeps the result valid under `exa - In a hand-written `AgentUIServer` like the one above, omitting these leaves the corresponding affordances unavailable rather than broken. (This differs from the [`createTrueFoundryServer` composition helper](/ui-sdk/setup-custom-servers/connect-with-truefoundry#truefoundry-chat-your-own-builder), where an omitted `deleteAgent` throws when called.) + In a hand-written `AgentUIServer` like the one above, omitting these leaves the corresponding affordances unavailable rather than broken. With [`createTrueForgeServer`](/ui-sdk/reference/server#factory), an omitted `deleteAgent` throws when called. @@ -204,7 +200,7 @@ See [Widening the types](/ui-sdk/setup-custom-servers/server-contract#widening-t ## Attachments -Attachment handling defaults to `trueFoundryAttachmentAdapter`. Override it through the `adapters` prop on `TrueForgeUI` if your backend stores uploads differently. +Attachment handling defaults to `trueForgeAttachmentAdapter`. Override it through the `adapters` prop on `TrueForgeUI` if your backend stores uploads differently. ```tsx diff --git a/docs/ui-sdk/setup-custom-servers/server-contract.mdx b/docs/ui-sdk/setup-custom-servers/server-contract.mdx index 252943266..fb737cc36 100644 --- a/docs/ui-sdk/setup-custom-servers/server-contract.mdx +++ b/docs/ui-sdk/setup-custom-servers/server-contract.mdx @@ -7,32 +7,24 @@ The `server` prop accepts either a built-in config object or an object implement ```ts type TrueForgeServerConfig = - | { - type: "truefoundry"; - apiKey: string; - controlPlaneURL: string; - gatewayPlaneURL?: string; - catalog?: CatalogServer; - } | { type: "trueforge"; baseUrl?: string; token?: string; fetch?: typeof fetch; catalog?: CatalogServer; + permissions?: PermissionsServer; } | AgentUIServer; ``` -Each form resolves differently. An `AgentUIServer` object is used immediately. Built-in configs -(`truefoundry` / `trueforge`) resolve asynchronously, so the SDK renders a loading indicator -(`role="status"`) for a moment first. `type: "trueforge"` loads +An `AgentUIServer` object is used immediately. The built-in `trueforge` config resolves +asynchronously, so the SDK renders a loading indicator (`role="status"`) for a moment first. It loads `plugins/trueforge-agent-server-adapter` and composes a full Harness `AgentUIServer` — see [Quickstart](/ui-sdk/get-started/quickstart). -`catalog` is optional on both built-in forms. For `truefoundry` it is attached after resolution; for -`trueforge` it overrides the adapter's default settings catalogs. Either way it enables the -[settings UI](/ui-sdk/reference/catalog) without hand-building a server. +`catalog` and `permissions` optionally override the adapter's built-in ports. A custom +`AgentUIServer` can expose the same optional ports directly. ## The composed contract diff --git a/docs/ui-sdk/setup-custom-ui/slot-overrides.mdx b/docs/ui-sdk/setup-custom-ui/slot-overrides.mdx index 888e1fd3d..01a5566db 100644 --- a/docs/ui-sdk/setup-custom-ui/slot-overrides.mdx +++ b/docs/ui-sdk/setup-custom-ui/slot-overrides.mdx @@ -104,7 +104,7 @@ ones most applications touch. ## Overriding outside TrueForgeUI If you compose providers manually, pass overrides to `SlotsProvider` and keep it outside -`TrueFoundryChatProvider`: +`TrueForgeChatProvider`: ```tsx {/* ... */} diff --git a/eslint.config.mjs b/eslint.config.mjs index cfa2be8fe..e657c53f6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,6 +46,7 @@ export default defineConfig( '**/node_modules/**', '**/dist/**', '**/coverage/**', + 'data/**', '**/.pnpm-store/**', '**/.eslintcache', // Excluded from package tsconfigs; run via tsx/jest, not the type-aware ESLint project. @@ -110,6 +111,10 @@ export default defineConfig( }, }, }, + { + files: ['packages/assistant-ui-runtime/*.config.ts'], + extends: [tseslint.configs.disableTypeChecked], + }, { files: ['**/*.{js,mjs,cjs}'], extends: [tseslint.configs.disableTypeChecked], diff --git a/package.json b/package.json index 75d5cac3d..5d25e81e1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "node": ">=22.14.0" }, "scripts": { - "build": "pnpm --filter @truefoundry/trueforge-core build && pnpm --filter @truefoundry/trueforge-sdk build && pnpm --filter @truefoundry/trueforge-ui build && pnpm --filter frontend build && pnpm --filter @truefoundry/trueforge build", + "build": "pnpm --filter @truefoundry/trueforge-core build && pnpm --filter @truefoundry/trueforge-sdk build && pnpm --filter @truefoundry/assistant-ui-runtime build && pnpm --filter @truefoundry/trueforge-ui build && pnpm --filter frontend build && pnpm --filter @truefoundry/trueforge build", "chart:deps": "helm dependency build charts/trueforge", "chart:lint": "helm lint charts/trueforge --values charts/trueforge/ci/lint-values.yaml", "chart:package": "helm package charts/trueforge --destination dist", @@ -19,8 +19,8 @@ "dev:no-watch": "cross-env STANDALONE=false NODE_ENV=development pnpm --parallel --filter @truefoundry/trueforge --filter frontend dev:no-watch", "dev:server:ui": "pnpm --filter frontend build && pnpm --filter @truefoundry/trueforge build:frontend-assets && cross-env STANDALONE=false NODE_ENV=development pnpm --parallel --filter @truefoundry/trueforge-core --filter @truefoundry/trueforge dev", "dev": "cross-env STANDALONE=false NODE_ENV=development pnpm --parallel --filter @truefoundry/trueforge-core --filter @truefoundry/trueforge --filter frontend dev", - "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,md}\" --cache --cache-location .prettiercache --cache-strategy content --error-on-unmatched-pattern --config .prettierrc", - "format:fix": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\" --cache --cache-location .prettiercache --cache-strategy content --config .prettierrc", + "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,md}\" \"!data/**\" --cache --cache-location .prettiercache --cache-strategy content --error-on-unmatched-pattern --config .prettierrc", + "format:fix": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\" \"!data/**\" --cache --cache-location .prettiercache --cache-strategy content --config .prettierrc", "format": "prettier --write . --cache --cache-location .prettiercache --cache-strategy content --config .prettierrc", "lint:ci": "eslint . --cache --cache-location .eslintcache", "lint": "eslint . --fix --cache --cache-location .eslintcache --cache-strategy content", @@ -40,6 +40,7 @@ "start:controller": "cross-env STANDALONE=false NODE_ENV=production pnpm --filter @truefoundry/trueforge start:controller", "push-sandbox-image-to-daytona": "pnpm --filter @truefoundry/trueforge push-sandbox-image-to-daytona", "test:frontend": "pnpm --filter frontend test", + "test:assistant-ui-runtime": "pnpm --filter @truefoundry/assistant-ui-runtime test", "test:chart-version": "bash tests/scripts/resolve-chart-version.test.sh", "test:trueforge-core": "pnpm --filter @truefoundry/trueforge-core test", "test:trueforge": "pnpm --filter @truefoundry/trueforge test", @@ -50,8 +51,8 @@ "test:store:postgres": "pnpm --filter @truefoundry/trueforge test:store:postgres", "test:store:sqlite": "pnpm --filter @truefoundry/trueforge test:store:sqlite", "test:trueforge-ui": "pnpm --filter @truefoundry/trueforge-ui test", - "test": "pnpm test:trueforge-core && pnpm test:trueforge-ui && pnpm test:trueforge && pnpm test:frontend", - "typecheck": "pnpm --filter @truefoundry/trueforge-core typecheck && pnpm --filter @truefoundry/trueforge-ui typecheck && pnpm --filter @truefoundry/trueforge typecheck && pnpm --filter frontend typecheck", + "test": "pnpm test:trueforge-core && pnpm test:assistant-ui-runtime && pnpm test:trueforge-ui && pnpm test:trueforge && pnpm test:frontend", + "typecheck": "pnpm --filter @truefoundry/trueforge-core typecheck && pnpm --filter @truefoundry/assistant-ui-runtime typecheck && pnpm --filter @truefoundry/trueforge-ui typecheck && pnpm --filter @truefoundry/trueforge typecheck && pnpm --filter frontend typecheck", "version": "node scripts/version.mjs" }, "devDependencies": { diff --git a/packages/assistant-ui-runtime/AGENTS.md b/packages/assistant-ui-runtime/AGENTS.md new file mode 100644 index 000000000..0ec195656 --- /dev/null +++ b/packages/assistant-ui-runtime/AGENTS.md @@ -0,0 +1,7 @@ +- One server session maps to one assistant-ui thread (`session.id` is the thread `remoteId`). +- The root thread id is always `main`; sub-agent threads nest beneath their creating tool call. +- The runtime accepts a ready `AgentUIServer` and MUST NOT own credentials or construct backend clients. +- Resuming a paused turn MUST include every pending tool approval and tool response across root and sub-agent threads in one turn request. +- Named and draft agent modes share the same runtime; draft spec updates remain synchronized through the server port. +- Canonical server ports, DTOs, and events live in `src/server/types.ts` and `src/server/events.ts`. +- Public runtime names use `TrueForge`, never the removed `TrueFoundry` backend name. diff --git a/packages/assistant-ui-runtime/CHANGELOG.md b/packages/assistant-ui-runtime/CHANGELOG.md new file mode 100644 index 000000000..348deec1c --- /dev/null +++ b/packages/assistant-ui-runtime/CHANGELOG.md @@ -0,0 +1,201 @@ +# Changelog + +## 0.1.43 + +### Added + +- `AgentRuntimeConfig.webSearch` and `AgentBuilderCapabilitiesResponse.data.webSearch` for host web-search capability gating. + +## 0.1.42 + +### Added + +- Optional `AgentChatServer.renameSession`. When present, thread-list adapters persist rename through it. + +## 0.1.41 + +### Breaking + +- `AgentChatServer.downloadSandboxFile` now receives `sandboxId?: string` (optional) instead of `sandboxId: string`. Host implementations must handle a missing `sandboxId`; turn-scoped hosts resolve the sandbox from `(sessionId, turnId)`. + +### Fixed + +- **Continuation turns no longer leak a local turn id** — approval / ask-user / MCP-auth resume turns now rename their placeholder id to the gateway-assigned id (`turn.created`), and fall back to the last committed turn's id instead of minting a fresh local one. Previously the fabricated id (e.g. `Vu3Hyio`) reached hosts via `custom.turnId`, breaking turn-scoped sandbox downloads ("Turn not found") and edit/retry. +- **Sandbox artifact download after resume** — `downloadSandboxFile` no longer hard-requires a client-known `sandboxId`. Turn-scoped hosts resolve the sandbox from `(sessionId, turnId)`; `sandboxId` is optional on the request and passed best-effort. Hydration forward-propagates the session-scoped `sandboxId` across turns in the loaded window (and backfills tip turns when older pages reveal `sandbox.created`), and downloads resolve the sandbox that was current as of the artifact's turn, paging in older history when its reference predates the loaded window. +- **Session switch no longer corrupts sandbox history page-in** — stale `loadOlderHistory` / `resolveSandboxIdForTurn` closures abort when the live session id changes, so a post-switch iteration cannot merge another session's pages onto the current snapshot. + +## 0.1.39 + +### Breaking + +- `ListPermissionsResponse.data` is now `{ type, permissions }` instead of a flat id→grants map. +- `PermissionResourceType` includes `tenant`; `ResourcePermission` includes `CREATE` (tenant entity-kind grants). + +### Fixed + +- Assistant messages use the turn completion time instead of the turn start time. +- Detaching from a turn stream (session switch, reload, remount) no longer cancels the backend run; only an explicit cancel does. + +## 0.1.37 + +### Added + +- Optional `description` on `AgentSelectorEntry` / `AgentLibraryEntry`, `AgentDetail`, and `SaveAgentRequest`. + +### Changed + +- The bundled Control Plane adapter maps description as a top-level library/save field and still writes it onto the CP `manifest.description` wire field. + +## 0.1.36 + +### Added + +- Optional `CreatedBySubject` on `AgentSelectorEntry` / `AgentLibraryEntry` and `Schedule` so hosts can surface a Created-by column. + +## 0.1.33 + +### Breaking + +- Removed `AgentBuilderServer.listMcp`; `getMcp` returns the complete MCP catalog. + +## 0.1.30 + +### Changed + +- `SessionListMetrics.totalCostInUsd` is optional so hosts can omit unavailable session costs. + +## 0.1.29 + +### Added + +- **`AgentBuilderServer.listMcp?(req?: PageParams): Promise>`** — optional token-paginated MCP catalog for picker infinite scroll. Hosts that omit it keep using `getMcp()`. + +## 0.1.28 + +### Added + +- Optional `PermissionsServer` port for batch MANAGE/DELETE grants on agents, schedules, and sessions. + +## 0.1.25 + +### Breaking + +- **`ScheduleServer.listSchedules` returns `ListResult`** instead of `TSchedule[]` (token pagination via `limit` / `pageToken` / `nextPageToken`). +- **`ListSchedulesParams`** adds optional `agentIds`, `limit`, and `pageToken` (still accepts single `agentId`). + +### Added + +- **`ScheduleRun` / `ScheduleRunStatus` / `CreateScheduleRunRequest`** — flat run DTOs for last-run chips and manual “run now”. +- **`ScheduleServer.listScheduleRuns` / `createScheduleRun`** — optional schedule-run surface for hosts that support manual triggers. +- Advanced agent configuration contracts for model limits, model parameters, runtime settings, and lazy MCP tool selection (`McpToolSelection`, optional `AgentBuilderServer.getMcpTools`). +- TrueForge builder adapter enrichment for model metadata and MCP tool discovery. +- `AgentInputTokensCompactionTrigger` and `AgentCompactionConfig` types. + +## 0.1.18 + +### Added + +- **`getModels` reasoning efforts** — `listEnabledModels` also fetches `/api/svc/v1/provider-accounts/providers` and sets `properties.reasoningEfforts` for thinking-capable models (same visibility rules as ai.tf: skip exact `openai` slug, require `thinking: true`, honor `removeParams`). + +### Fixed + +- **Extras ancestor walk vs RootAssistantClient** — `tryGetTrueForgeExtras` / `useTrueForgeRuntimeExtras` try/catch Proxy gets when walking `Object.create(parent)` AUI clients, so hitting the store’s RootAssistantClient prototype no longer throws `The current scope does not have a "subscribe" property`. `trueForgeExtras.get` / `.use` use the same walk (no nested-UI footgun). +- **`getModels` latency** — enabled-models and provider-metadata CP fetches run in parallel; providers soft-fails without blocking the model list. +- **Model selector `name` is model_fqn** — `normalizeEnabledModels` sets `ModelSelectorEntry.name` (and `id`) to `model_fqn`, so trueforge-ui’s DraftModelSelector writes `AgentSpec.model.name` as `account/model-id` instead of the short CP display label. +- **Gateway mount sanitization** — `normalizeMcpMount` / `normalizeSkillMount` rebuild allowlisted registry/git/inline shapes instead of blind-passthrough, so FE `id` / display `name` / registry `url` never leak onto draft create/update. Registry MCP mounts default `enableTools` to `["@all"]` when missing (narrow lists still preserved). + +## 0.1.12 + +### Breaking + +- **`SandboxCatalogServer` method renames (sandbox → sandbox provider)** + `getSandboxCatalog` → `getSandboxProviderCatalog`, `listSandboxes` → `listSandboxProviders`, `createSandbox` → `createSandboxProvider`, `updateSandbox` → `updateSandboxProvider`, `deleteSandbox` → `deleteSandboxProvider?` (now optional). Host aliases (`SandboxProviderConfig`, `SandboxProviderCatalogEntry`, `SandboxProviderBase`, `CreateSandboxProviderRequest`, `UpdateSandboxProviderRequest`) map to the existing DTO names. + +- **`SandboxBase` now extends `SandboxConfig`** + Connected rows always carry last-saved provider settings so update forms can show previous values. + +- **`UpdateSandboxRequest.apiKey` is optional** + Omit to keep the existing key; send a value to rotate. + +- **`ConnectorBase.tools` removed** + Tools are no longer embedded on the connector row. Fetch with new required `ConnectorCatalogServer.getToolsByConnectorId({ id })`. `ConnectorBase` is no longer generic in `TTool`. + +- **`ConnectorCatalogServer` adds required `getConnector` / `getToolsByConnectorId`** + Implement both on every catalog adapter. + +- **`authenticateConnector` request/return changed** + Request is `AuthenticateConnectorRequest` (`id` + optional `returnTo`). Return is `TConnector | ConnectorAuthenticationResult` — either an authenticated connector (or one carrying `auth.authUrl`) or a result with `authorization_endpoint` for the popup flow. + +- **`ConnectorAuthPublicOAuth.authUrl` is optional** + Public dcr rows may omit `authUrl` when auth is started via `authorization_endpoint` instead. (Supersedes the 0.1.8 note that public dcr required `authUrl`.) + +### Added + +- **Sole ownership of server-port types** — this package is the canonical home for `AgentChatServer` / `AgentBuilderServer` / catalog ports and DTOs. Hosts (e.g. trueforge-ui) should re-export, not fork. +- **`AgentUIServer`** — host-facing alias of `AgentUIServerPort`. +- **Selector / compose aliases** — `ModelSelection`, `AgentSkill`, `ConnectorState`, `AgentLibraryEntry`, `SearchAgentsParams` (aliases of the `*SelectorEntry` / `SearchAgentSelectorParams` names). +- **Root exports** for builder/catalog types previously internal to `server/` (`SaveAgentRequest`, `AgentBuilderCapabilitiesResponse`, `AuthenticateConnectorRequest`, `ConnectorAuthenticationResult`, mounts, selector entries, sandbox-provider aliases, etc.). +- **`ModelSelectorEntry.providerLogo?`**, **`ConnectorSelectorEntry.requiresAuth?` / `authenticated?`**, **`ModelProviderCatalogEntry.supportedReasoningEfforts?` / `logo?`**, **`ConnectorCatalogEntry.logo?`**. +- **`SaveAgentRequest`** — named request body for `AgentBuilderServer.saveAgent`. +- **`createTrueForgeAgentUIServer.getCapabilities`** — returns sandbox / skill / settings enabled (stub; always `true` for this pack). +- **`PreviousTurnIdInput` documents `"none"`** alongside `"auto"` and turn ids. + +### Fixed + +- **`sessionToThreadMetadata`** — always stamps `custom.isMutable` from the session (plus `agentName` when present). History UIs can trust mutability without inferring it from `agentName`, so ref sessions whose agent was deleted stay immutable. + +## 0.1.10 + +### Breaking + +- **`ListSessionsParams.agentName` → `agentId`** + Thread-list adapters and the TFY chat server forward the optional filter as `agentId`. Hosts that previously keyed list filters by name should pass that same value as `agentId` (or via `listSessionsAgentId` on the runtime / adapters). + +- **Draft thread list no longer filters `isMutable` client-side** + `createTrueForgeDraftThreadListAdapter.list` returns every session from `listSessions`. Title named (immutable) rows with `title → agentName → id`, not the draft model name. + +### Added + +- **`listSessionsAgentId?: string`** on `useTrueForgeAgentRuntime` and all three thread-list adapters (`createTrueForgeThreadListAdapter`, `createTrueForgeDraftThreadListAdapter`, `createTrueForgeOwnedSessionsThreadListAdapter`). Omit to list all chats; set to filter `listSessions({ agentId })`. +- **`AgentSelectorEntry.agentId?` / `agentSpec?`** — CP `normalizeAgents` fills these from agent id + `latestVersionDetails.manifest` so hosts can offer Edit when a published spec is present. +- **`agentSpecFromCpManifest` / `toCamelCaseDeep`** — map CP AgentManifest (snake_case) → FE `TfyAgentSpec` for Edit seeding, preserving agent `config` and mount `enableTools` / `preload` / `config`. +- **`sessionDisplayTitle` / `sessionToThreadMetadata`** — shared session → thread-list metadata helpers; named sessions expose `custom.agentName` when present. + +### Fixed + +- **Edit → Save round-trip** — catalog mounts from CP keep runtime fields; `normalizeMcpMount` / `normalizeSkillMount` forward `enableTools`, `preload`, and `config` so save no longer defaults MCP tools to `@all`, forces skill `preload: false`, or drops config. +- **Untitled named sessions in draft history** — mixed lists use `sessionDisplayTitle` so immutable rows fall back to `agentName` / `id` instead of `defaultAgentSpec.model.name`. + +### Changed + +- Bump `@assistant-ui/core` to `^0.2.22` and `@assistant-ui/store` to `^0.2.21`. + +## 0.1.9 + +### Added + +- **Sandboxes catalog** — new `SandboxCatalogServer` port and DTOs (`SandboxConfig`, `SandboxCatalogEntry`, `SandboxBase`, `CreateSandboxRequest`, `UpdateSandboxRequest`). Exposed as the optional `CatalogServer.sandboxCatalog` sub-port. `SandboxConfig` holds the mutable settings (`snapshotName`, `execTimeoutMs`, `autoStopIntervalInMinutes`, `autoArchiveIntervalInMinutes`, `autoDeleteIntervalInMinutes`) shared by catalog rows, create, and update; create adds `apiKey`. +- **Skills catalog** — `SkillCatalogServer.getSkillCatalog()` plus registry/github skill DTOs (`RegistrySkill`, `GithubSkill`, `DefinedSkill`, `SkillConfigBase`, `SkillCatalogEntry`, `CreateSkillRequestBase`, `SelectRegistrySkillRequest`, `ImportGithubSkillRequest`). Registry vs github is distinguished by `catalogId` presence, not a `type` field. +- **`ModelSelectorEntry.reasoningEfforts?: string[]`** and **`ToolBase.description: string`**. + +## 0.1.8 + +### Breaking + +- **`AgentChatServer.prepareAndExecuteTurn` → `createTurn`** + Rename the method on every `AgentChatServer` implementation and mock. Signature and return type are unchanged. + +- **`ConnectorAuthType` is now `"dcr" | "header" | "none"`** + Previously an open `string` with documented display labels (`"None"`, `"OAuth"`, `"API Key"`). Map host/wire values to these literals. + +- **`ConnectorAuth` / `ConnectorAuthPublic` are discriminated unions** + - Write: `{ type: "dcr"; authUrl?: string } | { type: "header"; apiKey?: string; headerName?: string } | { type: "none" }` + - Public: dcr branch requires `authUrl: string` (no secrets). + Branch types (`ConnectorAuthOAuth`, `ConnectorAuthPublicOAuth`, …) are exported so hosts can intersect extras and re-union, e.g. `ConnectorAuthPublicOAuth & { custom: string }`. + +- **`ConnectorBase.requiresAuth: boolean`** + Required alongside existing `authenticated`. When `requiresAuth` is true, UI should not show Disconnect. + +### Notes + +- After `authenticateConnector`, the authorize URL is on `connector.auth.authUrl` when `auth.type === "dcr"` (not a separate widened return field). diff --git a/packages/assistant-ui-runtime/CLAUDE.md b/packages/assistant-ui-runtime/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/packages/assistant-ui-runtime/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/packages/assistant-ui-runtime/README.md b/packages/assistant-ui-runtime/README.md new file mode 100644 index 000000000..830539025 --- /dev/null +++ b/packages/assistant-ui-runtime/README.md @@ -0,0 +1,149 @@ +# @truefoundry/assistant-ui-runtime + +Headless React runtime that maps TrueForge sessions, turns, streaming events, tool approvals, and sub-agent threads onto assistant-ui's external-store runtime. + +The package accepts a ready `AgentChatServer`. It does not construct backend clients, read credentials, or depend on a backend SDK. + +## Installation + +```bash +pnpm add @truefoundry/assistant-ui-runtime @assistant-ui/react +``` + +React 18 and 19 are supported. `@assistant-ui/core` and `@assistant-ui/store` are installed with the runtime. + +## Quick start + +```tsx +'use client'; + +import { AssistantRuntimeProvider } from '@assistant-ui/react'; +import { useTrueForgeAgentRuntime, type AgentChatServer } from '@truefoundry/assistant-ui-runtime'; + +function Chat({ server }: { server: AgentChatServer }) { + const runtime = useTrueForgeAgentRuntime({ + server, + agent: { mode: 'named', agentName: 'support-agent' }, + }); + + return ( + + {/* Render assistant-ui Thread, Composer, and ThreadList primitives here. */} + + ); +} +``` + +For the complete TrueForge UI, use `@truefoundry/trueforge-ui`. It supplies the built-in TrueForge HTTP adapter and re-exports the host-facing server contracts. + +## Agent modes + +Named agents use an existing server-side agent: + +```ts +import type { TrueForgeAgentConfig } from '@truefoundry/assistant-ui-runtime'; + +const agent = { + mode: 'named', + agentName: 'support-agent', +} satisfies TrueForgeAgentConfig; +``` + +Draft agents run from an inline spec and synchronize edits through the server: + +```ts +import type { TrueForgeAgentConfig } from '@truefoundry/assistant-ui-runtime'; + +const agent = { + mode: 'draft', + defaultAgentSpec: { + model: { name: 'openai-main/gpt-4.1' }, + instructions: 'Answer concisely.', + }, +} satisfies TrueForgeAgentConfig; +``` + +`agentName` remains available as shorthand for named mode. + +## Runtime options + +`useTrueForgeAgentRuntime` accepts assistant-ui's external-store options plus: + +| Option | Purpose | +| ------------------------------- | ----------------------------------------------------------- | +| `server` | Ready `AgentChatServer` implementation | +| `agent` | Named or draft agent configuration | +| `agentName` | Legacy named-agent shorthand | +| `initialSessionId` | Initial session to load | +| `threadId` / `onThreadIdChange` | Controlled active session | +| `listSessionsAgentId` | Optional history filter | +| `listSessionsCreatedByMe` | Restrict history to the authenticated subject | +| `onError` | Load, turn, and stream error callback | +| `adapters` | Attachment, speech, dictation, voice, and feedback adapters | + +## Runtime extras + +The package exposes typed hooks for TrueForge-specific state and actions: + +- `useTrueForgeApprovals` +- `useTrueForgeToolResponses` +- `useTrueForgeMcpAuth` +- `useTrueForgeRespondToToolApproval` +- `useTrueForgeRespondToToolResponse` +- `useTrueForgeResumeMcpAuth` +- `useTrueForgeDownloadSandboxFile` +- `useTrueForgeCancel` +- `useTrueForgeHistoryPagination` +- `useTrueForgeResumeUnavailable` +- `useTrueForgeResetFromTurn` +- `useTrueForgeAgentSpec` +- `useTrueForgeUpdateAgentSpec` +- `useTrueForgeFlushAgentSpec` +- `useTrueForgeAdoptAgentSpec` + +Use `trueForgeExtras`, `getTrueForgeExtras`, and `tryGetTrueForgeExtras` when integrating directly with assistant-ui state. + +## Server contracts + +Canonical server ports, DTOs, and stream events are exported from both the package root and the dedicated server entry: + +```ts +import type { + AgentChatServer, + AgentUIServer, + Session, + Turn, + TurnStreamingEvent, +} from '@truefoundry/assistant-ui-runtime/server'; +``` + +Important invariants: + +- One server session maps to one assistant-ui thread. +- The root thread id is always `main`. +- Sub-agent threads nest below their creating tool call. +- Resuming a paused turn submits all pending approvals and tool responses across every thread in one request. +- Credentials remain host-owned. + +## Attachments + +`trueForgeAttachmentAdapter` converts assistant-ui attachment input into TrueForge user-message content. Hosts can override it through the runtime `adapters` option. + +## Package exports + +- `@truefoundry/assistant-ui-runtime` +- `@truefoundry/assistant-ui-runtime/server` + +## Development + +Run from the trueforge repository root: + +```bash +pnpm --filter @truefoundry/assistant-ui-runtime build +pnpm test:assistant-ui-runtime +pnpm --filter @truefoundry/assistant-ui-runtime typecheck +``` + +## License + +MIT diff --git a/packages/assistant-ui-runtime/package.json b/packages/assistant-ui-runtime/package.json new file mode 100644 index 000000000..c3dc65467 --- /dev/null +++ b/packages/assistant-ui-runtime/package.json @@ -0,0 +1,87 @@ +{ + "name": "@truefoundry/assistant-ui-runtime", + "version": "0.1.43", + "description": "TrueForge agent runtime adapter for assistant-ui", + "author": "TrueFoundry", + "repository": { + "type": "git", + "url": "git+https://github.com/truefoundry/trueforge.git", + "directory": "packages/assistant-ui-runtime" + }, + "homepage": "https://github.com/truefoundry/trueforge/tree/main/packages/assistant-ui-runtime#readme", + "bugs": { + "url": "https://github.com/truefoundry/trueforge/issues" + }, + "keywords": [ + "assistant-ui", + "truefoundry", + "ai", + "agent", + "react", + "llm", + "chat" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "engines": { + "node": ">=22" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js", + "default": "./dist/server/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "sideEffects": false, + "scripts": { + "build": "tsup", + "lint": "eslint src --fix --cache --cache-location ../../.eslintcache --cache-strategy content", + "lint:ci": "eslint src --cache --cache-location ../../.eslintcache", + "pack:dry": "pnpm build && pnpm pack --dry-run", + "test": "vitest run", + "test:file": "vitest run", + "typecheck": "tsc --noEmit && pnpm run typecheck:test", + "typecheck:test": "tsc --noEmit --project test/tsconfig.json" + }, + "dependencies": { + "@assistant-ui/core": "^0.2.22", + "@assistant-ui/store": "^0.2.21" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/node": "^20", + "@types/react": "^19.2.17", + "jsdom": "^29.1.1", + "react": "^19.2.4", + "tsup": "^8.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/assistant-ui-runtime/src/askUserQuestion.ts b/packages/assistant-ui-runtime/src/askUserQuestion.ts new file mode 100644 index 000000000..e883c8f03 --- /dev/null +++ b/packages/assistant-ui-runtime/src/askUserQuestion.ts @@ -0,0 +1,38 @@ +import type { ToolCall } from './server/index.js'; + +export function isAskUserQuestionToolCall(toolCall: Pick): boolean { + return toolCall.toolInfo?.type === 'trueforge-system' && toolCall.toolInfo.name === 'ask_user_question'; +} + +export interface AskUserQuestionArgs { + question?: string; + options?: string[]; +} + +export function parseAskUserQuestionArgs(argsText: string | undefined): AskUserQuestionArgs { + if (!argsText) { + return {}; + } + try { + const parsed: unknown = JSON.parse(argsText); + if (!isUnknownRecord(parsed)) { + return {}; + } + const rawQuestion = parsed['question']; + const rawOptions = parsed['options']; + const question = typeof rawQuestion === 'string' ? rawQuestion : undefined; + const options = Array.isArray(rawOptions) + ? rawOptions.filter((item): item is string => typeof item === 'string') + : undefined; + return { + ...(question == null ? {} : { question }), + ...(options == null ? {} : { options }), + }; + } catch { + return {}; + } +} + +function isUnknownRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/assistant-ui-runtime/src/attachmentAdapter.ts b/packages/assistant-ui-runtime/src/attachmentAdapter.ts new file mode 100644 index 000000000..0e3f38228 --- /dev/null +++ b/packages/assistant-ui-runtime/src/attachmentAdapter.ts @@ -0,0 +1,63 @@ +import type { AttachmentAdapter, PendingAttachment } from '@assistant-ui/core'; + +const bytesToBase64 = (bytes: Uint8Array): string => globalThis.Buffer.from(bytes).toString('base64'); + +const getFileDataURL = async (file: File): Promise => { + if (typeof FileReader === 'undefined') { + const buffer = await file.arrayBuffer(); + return `data:${file.type};base64,${bytesToBase64(new Uint8Array(buffer))}`; + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Attachment reader returned non-text data.')); + } + }; + reader.onerror = () => { + reject(reader.error ?? new Error('Failed to read attachment.')); + }; + reader.readAsDataURL(file); + }); +}; + +/** + * Reads composer files into `CompleteAttachment` `file` parts for send. + * Wire via `adapters: { attachments: trueForgeAttachmentAdapter }`. + */ +export const trueForgeAttachmentAdapter: AttachmentAdapter = { + accept: '*', + add({ file }) { + return Promise.resolve({ + id: globalThis.crypto.randomUUID(), + type: 'file', + name: file.name, + file, + contentType: file.type, + content: [], + status: { + type: 'requires-action', + reason: 'composer-send', + }, + }); + }, + async send(attachment: PendingAttachment) { + return { + ...attachment, + status: { type: 'complete' }, + content: [ + { + type: 'file', + mimeType: attachment.contentType ?? '', + filename: attachment.name, + data: await getFileDataURL(attachment.file), + }, + ], + }; + }, + remove() { + return Promise.resolve(); + }, +}; diff --git a/packages/assistant-ui-runtime/src/collectPending.ts b/packages/assistant-ui-runtime/src/collectPending.ts new file mode 100644 index 000000000..eef9475b1 --- /dev/null +++ b/packages/assistant-ui-runtime/src/collectPending.ts @@ -0,0 +1,167 @@ +import type { ThreadAssistantMessagePart, ThreadMessage } from '@assistant-ui/core'; +import type { McpAuthRequiredEvent } from './server/index.js'; + +import { ROOT_THREAD_ID } from './constants.js'; +import { isMcpServerAuthInfoList, isUnknownRecord } from './messageCustomMetadata.js'; +import { getToolApprovalThreadId, hasPendingToolApproval } from './toolApproval.js'; +import { + getToolResponseThreadId, + hasPendingToolResponse, + type AskUserQuestionInterruptPayload, +} from './toolResponse.js'; + +export interface PendingApproval { + approvalId: string; + threadId: string; + toolName: string; + args: Record; + argsText: string; +} + +export interface PendingToolResponse { + toolCallId: string; + threadId: string; + toolName: string; + args: Record; + argsText: string; + question?: string; + options?: string[]; +} + +type ToolCallPart = Extract; + +function walkToolCallParts( + content: readonly ThreadAssistantMessagePart[], + visit: (part: ToolCallPart, threadId: string) => void, + threadId: string, +): void { + for (const part of content) { + if (part.type !== 'tool-call') { + continue; + } + visit(part, threadId); + if (part.messages == null) { + continue; + } + for (const message of part.messages) { + if (message.role !== 'assistant') { + continue; + } + const nestedThreadId = getToolApprovalThreadId(message) ?? getToolResponseThreadId(message) ?? threadId; + walkToolCallParts(message.content, visit, nestedThreadId); + } + } +} + +export function collectPendingApprovals(messages: readonly ThreadMessage[]): PendingApproval[] { + const pending: PendingApproval[] = []; + + for (const message of messages) { + if (message.role !== 'assistant') { + continue; + } + const rootThreadId = getToolApprovalThreadId(message) ?? ROOT_THREAD_ID; + walkToolCallParts( + message.content, + (part, threadId) => { + const approval = part.approval; + if (approval == null || !hasPendingToolApproval(approval)) { + return; + } + pending.push({ + approvalId: approval.id, + threadId, + toolName: part.toolName, + args: { ...part.args }, + argsText: part.argsText, + }); + }, + rootThreadId, + ); + } + + return pending; +} + +export function collectPendingToolResponses(messages: readonly ThreadMessage[]): PendingToolResponse[] { + const pending: PendingToolResponse[] = []; + + for (const message of messages) { + if (message.role !== 'assistant') { + continue; + } + const rootThreadId = getToolResponseThreadId(message) ?? ROOT_THREAD_ID; + walkToolCallParts( + message.content, + (part, threadId) => { + if (!hasPendingToolResponse(part)) { + return; + } + const payload: AskUserQuestionInterruptPayload | undefined = isUnknownRecord(part.interrupt?.payload) + ? { + ...(typeof part.interrupt.payload['question'] === 'string' + ? { question: part.interrupt.payload['question'] } + : {}), + ...(Array.isArray(part.interrupt.payload['options']) && + part.interrupt.payload['options'].every(option => typeof option === 'string') + ? { options: part.interrupt.payload['options'] } + : {}), + } + : undefined; + pending.push({ + toolCallId: part.toolCallId, + threadId, + toolName: part.toolName, + args: { ...part.args }, + argsText: part.argsText, + ...(payload?.question != null ? { question: payload.question } : {}), + ...(payload?.options != null ? { options: payload.options } : {}), + }); + }, + rootThreadId, + ); + } + + return pending; +} + +export function derivePendingMcpAuth( + messages: readonly ThreadMessage[], +): { mcpServers: McpAuthRequiredEvent['mcpServers'] } | null { + for (const message of messages.toReversed()) { + if (message.role !== 'assistant') { + continue; + } + if (message.status.type !== 'requires-action') { + continue; + } + const custom = message.metadata.custom; + if (custom['pendingMcpAuth'] !== true) { + continue; + } + const servers = custom['mcpServers']; + if (!isMcpServerAuthInfoList(servers)) { + return { mcpServers: [] }; + } + return { mcpServers: servers }; + } + return null; +} + +/** + * Most recent `sandboxId` observed anywhere in the conversation, scanning backward. + * Unlike `derivePendingMcpAuth`, this isn't gated on message status: once a sandbox + * is created it stays valid for the rest of the session, not just the paused message. + */ +export function deriveSandboxId(messages: readonly ThreadMessage[]): string | undefined { + for (const message of messages.toReversed()) { + if (message.role !== 'assistant') { + continue; + } + const sandboxId = message.metadata.custom['sandboxId']; + if (typeof sandboxId === 'string') { + return sandboxId; + } + } + return undefined; +} diff --git a/packages/assistant-ui-runtime/src/constants.ts b/packages/assistant-ui-runtime/src/constants.ts new file mode 100644 index 000000000..befb749c5 --- /dev/null +++ b/packages/assistant-ui-runtime/src/constants.ts @@ -0,0 +1,2 @@ +/** Gateway root thread id — always the literal `"main"`. */ +export const ROOT_THREAD_ID = 'main'; diff --git a/packages/assistant-ui-runtime/src/convertTurnMessages.ts b/packages/assistant-ui-runtime/src/convertTurnMessages.ts new file mode 100644 index 000000000..58d5fdec4 --- /dev/null +++ b/packages/assistant-ui-runtime/src/convertTurnMessages.ts @@ -0,0 +1,1679 @@ +import type { + AppendMessage, + CompleteAttachment, + ExportedMessageRepositoryItem, + MessageStatus, + ThreadMessage, + ThreadUserMessagePart, +} from '@assistant-ui/core'; +import type { + McpAuthRequiredEvent, + Turn, + TurnCreatedEvent, + TurnEvent, + TurnInputItem, + TurnStreamData, +} from './server/index.js'; +import type { AgentChatServer } from './server/types.js'; + +import { ROOT_THREAD_ID } from './constants.js'; +import { extractTurnUserText } from './extractTurnUserText.js'; +import { + buildRootAssistantContent, + buildRootAssistantContentForIds, + findFirstPendingApprovalThreadId, + findFirstPendingResponseThreadId, + ingestStreamEvent, + ingestTurnEvent, + PeerThreadFoldState, + type ThreadBucket, +} from './foldPeerThreads.js'; +import { drainListPages } from './listPages.js'; +import { buildMcpAuthTextParts, mcpAuthAssistantStatus, mcpAuthMessageCustom } from './mcpAuth.js'; +import type { AssistantContentPart } from './modelMessageContent.js'; +import { extractImageUrlFromUserContentItem, imageUrlToAttachment } from './modelMessageImageContent.js'; +import { + createEmptySessionSnapshot, + replaceSessionSnapshot, + sessionEventsToSessionRecord, + turnToSessionRecord, + type GatewaySessionEventItem, + type ProjectSessionMessagesOptions, + type RequiredActionsOverlay, + type SessionHistoryPagination, + type SessionSnapshot, + type SessionTurnRecord, +} from './sessionSnapshot.js'; +import { + applyApprovalDecisionsToContent, + collectApprovalDecisionsFromTurnInput, + collectSubsequentApprovalDecisions, + messageHasPendingApprovals, + TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY, + toolApprovalMessageCustom, + toolApprovalStatus, +} from './toolApproval.js'; +import { + applyStagedResponsesToContent, + applyUserToolResponsesToFold, + collectSubsequentToolResponses, + collectToolResponsesFromTurnInput, + messageHasPendingResponses, + TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY, + toolResponseMessageCustom, + toolResponseStatus, +} from './toolResponse.js'; +import { appendMcpAuthToTurnContent, appendToolApprovalToTurnContent } from './turnEventHelpers.js'; +import type { TurnStreamUpdate } from './turnStreamUpdate.js'; + +/** + * Turn / event → assistant-ui normalization + * + * This module is the projection layer: it turns gateway session state into + * `ThreadMessage[]` for `@assistant-ui/core`. The hook in + * `useTrueForgeAgentMessages.ts` owns the mutable `SessionSnapshot`; this + * file defines how that snapshot is built and rendered. + * + * ## Data model + * + * Gateway side: + * - A **session** has ordered **turns** (each with `input`, `state`, `listEvents`). + * - A turn's **input** is either a user message (`user.message`) or a continuation + * (`user.tool_response`, `user.tool_approval`, …). + * - **Events** (`model.message`, `tool.*`, `thread.created`, …) arrive on a + * **thread id**; the root conversation uses `ROOT_THREAD_ID` (`"main"`). + * + * Local side (`SessionSnapshot` in `sessionSnapshot.ts`): + * - `fold` — accumulated events across all threads (see `foldPeerThreads.ts`). + * - `turns` — committed turn records, each optionally storing + * `rootModelMessageIds` (root-thread `model.message` ids ingested with that turn). + * - `pendingUser` / `activeStream` — optimistic UI while a turn is in flight. + * - `groupRootBaseline` — root `model.message` ids that existed before the active + * turn group started; used to scope live streaming to the current group only. + * - `requiredActions` — locally staged approval/response decisions before resume. + * + * Output: + * - Alternating **user** / **assistant** `ThreadMessage` pairs. + * - One assistant message per **turn group** (user turn + its continuation turns). + * + * ## Turn groups + * + * A **turn group** starts at a turn whose input contains `user.message`. Later + * turns with only continuation inputs (tool response, tool approval, MCP resume) + * belong to the same group and are folded into the same assistant message. + * + * Root content for a group is the union of `rootModelMessageIds` on every turn + * in that group. Prior groups must not leak in: scope with + * `rootModelMessageIdsSinceBaseline(fold, baseline)` where + * `baseline = groupRootBaseline ?? computeGroupRootBaseline(turns)`. + * + * ## Pipeline + * + * 1. **Ingest** — `ingestStreamEvent` / `ingestTurnEvent` append events into + * per-thread buckets in `PeerThreadFoldState`. Deltas merge in place. + * + * 2. **Fold to content** — `buildRootAssistantContentForIds` walks scoped + * `model.message` ids and emits assistant-ui parts (reasoning, text, tool-call). + * Sub-agent child threads are attached under `create_sub_agent` tool-calls as + * nested `messages` (see `attachSubAgentMessages` in `foldPeerThreads.ts`). + * + * 3. **History projection** — `projectHistoryTurns` walks committed `turns`: + * - User turn → push user message; push assistant if the group has content. + * - Continuation turn → merge assistant content into the last assistant message + * in the group (same `*-assistant` id as the user turn that opened the group). + * - Apply answers from later continuation turns onto earlier tool-calls via + * `collectSubsequentApprovalDecisions` / `collectSubsequentToolResponses`. + * - A paused group's assistant message keeps `requires-action` until a later + * continuation turn resolves all its approvals/responses, then downgrades + * to the turn-state status (`complete`, `error`, `cancelled`). + * + * 4. **Live projection** — `projectSessionMessages` = history + `pendingUser` + + * `activeStream`. While streaming, `streamTurnEvents` yields content scoped to + * `groupRootBaseline`. When `streamComplete`, `projectActiveStreamUpdate` + * rebuilds from the fold (same baseline scoping as streaming). + * + * 5. **Staged overlay** — Before the SDK resume turn is sent, user decisions sit + * in `requiredActions` and are merged onto messages by + * `applyRequiredActionsOverlayToMessages` so the UI shows interrupt + result + * together. Resume is batched: all pending approvals and ask-user answers in + * a paused message must be resolved before `sendTurn({ inputs })`. + * + * ## Required actions (assistant-ui mapping) + * + * | Gateway event / input | assistant-ui representation | + * |-----------------------------|------------------------------------------------------| + * | `tool.approval_required` | `tool-call` with `approval: { id }`, status | + * | | `requires-action` / `tool-calls`, custom thread id | + * | `user.tool_approval` | closes `tool.approval_required`: sets `approval.approved` | + * | | (+ optional reason) on tool-call | + * | `tool.response_required` | `tool-call` with `interrupt: { type: "human", … }` | + * | (ask_user_question) | payload parsed from tool args (`askUserQuestion.ts`) | + * | `user.tool_response` | closes `tool.response_required`: sets `result` on | + * | | tool-call and clears the pending `interrupt` | + * | `mcp.auth_required` | Appended auth link text parts; status `interrupt` | + * + * Sub-agent threads can have their own pending approval/response; metadata + * `toolApprovalThreadId` / `toolResponseThreadId` on nested assistant messages + * scopes resume inputs to the correct thread. + * + * A sub-agent stays attached under the `create_sub_agent` tool-call that spawned + * it, which lives in the root `model.message` of the turn that opened the group. + * The child thread keeps producing events across several turns (spawn turn, then + * continuation turns that answer its required actions), and all of them render + * under that single tool-call in the group's one assistant message. + * + * INVARIANT — while a sub-agent is active the parent agent is paused awaiting a + * required action, so every following turn until it finishes is a continuation + * (`user.tool_response` / `user.tool_approval` / MCP resume), NOT a `user.message`. + * If a sub-agent is spawned/active in turn 1, turn 2 cannot carry `user.message` + * input. A `user.message` there would open a new turn group (`userText` boundary) + * and split the still-running sub-agent's events away from its tool-call. The + * gateway enforces this; the projection relies on it for correct nesting. + * + * ## Assumptions + * + * - Root-thread model messages use `threadId === ROOT_THREAD_ID`. + * - Turn order in `snapshot.turns` matches gateway chronological order. + * - `rootModelMessageIds` on committed turns is accurate (set in + * `commitActiveStream` when a stream completes). + * - Continuation turns never carry `user.message`; that boundary defines groups. + * In particular, while a sub-agent (or any tool) is mid-flight awaiting a + * required action, the next turn is always a continuation, never a new + * `user.message`. + * - Tool-call identity is stable via `toolCallId` across events, fold state, and + * assistant-ui parts. + * - Reload (`buildSnapshotFromSessionEvents`) and live paths must produce the same + * per-group scoping; history uses cumulative `groupRootIds` per turn index, + * live uses `groupRootBaseline`. + */ + +const TURN_EVENTS_PAGE_SIZE = 25; +const SESSION_EVENTS_PAGE_SIZE = 100; +/** Cap how many event pages initial load / load-older may chain for a group boundary. */ +const MAX_HISTORY_BOUNDARY_PAGES = 10; + +interface FetchSessionEventsOptions { + /** + * Newest turn in the listing window (initial load only). Lists that turn and + * its ancestors. Omit to use the session last turn. Running-turn events are + * excluded by the API — subscribe to that turn for live events. + */ + lastTurnId?: string; +} + +interface SessionEventsPageResult { + /** Newest-first items from this request. */ + itemsNewestFirst: GatewaySessionEventItem[]; + olderPageToken?: string; + hasOlder: boolean; +} + +/** + * Drops a leading incomplete turn (events before the first `turn.created`) that + * appears when a page boundary splits a turn. + */ +function trimIncompleteLeadingEvents(itemsAsc: GatewaySessionEventItem[]): GatewaySessionEventItem[] { + const start = itemsAsc.findIndex(item => item.event.type === 'turn.created'); + if (start === -1) { + return []; + } + return itemsAsc.slice(start); +} + +/** + * Whether the oldest complete turn in a chronological window opens a user group. + * `incomplete` means we still lack a finished oldest turn (need an older page). + */ +function oldestCompleteTurnGroupState( + itemsAsc: GatewaySessionEventItem[], +): 'user-group' | 'continuation' | 'incomplete' { + const trimmed = trimIncompleteLeadingEvents(itemsAsc); + if (trimmed.length === 0) { + return 'incomplete'; + } + const created = trimmed[0]; + if (created?.event.type !== 'turn.created') { + return 'incomplete'; + } + const hasDone = trimmed.some(item => item.turnId === created.turnId && item.event.type === 'turn.done'); + if (!hasDone) { + return 'incomplete'; + } + return extractTurnUserText(created.event.input) != null ? 'user-group' : 'continuation'; +} + +async function fetchSessionEventsPage( + server: AgentChatServer, + sessionId: string, + options?: FetchSessionEventsOptions & { pageToken?: string }, +): Promise { + const page = await server.listEvents({ + sessionId, + limit: SESSION_EVENTS_PAGE_SIZE, + ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}), + ...(options?.pageToken != null ? { pageToken: options.pageToken } : {}), + }); + const olderPageToken = page.nextPageToken; + const hasOlder = olderPageToken != null && olderPageToken !== ''; + return { + itemsNewestFirst: page.data, + ...(olderPageToken != null && olderPageToken !== '' ? { olderPageToken } : {}), + hasOlder, + }; +} + +/** + * Fetches pages until the chronological window starts on a complete user-message + * turn group (or history is exhausted). + */ +async function fetchSessionEventsWindow( + server: AgentChatServer, + sessionId: string, + options?: FetchSessionEventsOptions & { pageToken?: string }, +): Promise<{ + itemsAsc: GatewaySessionEventItem[]; + olderPageToken?: string; + hasOlder: boolean; +}> { + let itemsNewestFirst: GatewaySessionEventItem[] = []; + let pageToken = options?.pageToken; + let olderPageToken: string | undefined; + let hasOlder = false; + + for (let pageCount = 0; pageCount < MAX_HISTORY_BOUNDARY_PAGES; pageCount++) { + const page = await fetchSessionEventsPage(server, sessionId, { + ...options, + ...(pageToken != null ? { pageToken } : {}), + }); + itemsNewestFirst = [...itemsNewestFirst, ...page.itemsNewestFirst]; + olderPageToken = page.olderPageToken; + hasOlder = page.hasOlder; + + const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse()); + const groupState = oldestCompleteTurnGroupState(itemsAsc); + if (groupState === 'user-group' || !hasOlder) { + return { + itemsAsc, + ...(olderPageToken != null ? { olderPageToken } : {}), + hasOlder, + }; + } + + if (olderPageToken == null) { + return { itemsAsc, hasOlder: false }; + } + pageToken = olderPageToken; + } + + const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse()); + return { + itemsAsc, + ...(olderPageToken != null ? { olderPageToken } : {}), + hasOlder, + }; +} + +/** + * Fetches session-level events via `server.listEvents()`. The API returns pages + * in desc order (newest first); the collected array is reversed before returning + * so callers receive events in chronological (asc) order. + * + * Used by rewind/edit paths that need the full ancestor window. + */ +async function fetchAllSessionEvents( + server: AgentChatServer, + sessionId: string, + options?: FetchSessionEventsOptions, +): Promise { + const items = await drainListPages(pageToken => + server.listEvents({ + sessionId, + limit: SESSION_EVENTS_PAGE_SIZE, + ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}), + ...(pageToken != null ? { pageToken } : {}), + }), + ); + items.reverse(); + return items; +} + +function cloneThreadBucket(bucket: ThreadBucket): ThreadBucket { + return { + events: new Map(bucket.events), + modelMessageIds: [...bucket.modelMessageIds], + toolResults: new Map(bucket.toolResults), + pendingApprovals: new Map(bucket.pendingApprovals), + approvalDecisions: new Map(bucket.approvalDecisions), + pendingResponses: new Map(bucket.pendingResponses), + done: bucket.done, + ...(bucket.title != null ? { title: bucket.title } : {}), + ...(bucket.agentInfo != null ? { agentInfo: bucket.agentInfo } : {}), + }; +} + +/** Prepends older fold state ahead of the currently loaded fold (scroll-up). */ +export function prependFoldState(older: PeerThreadFoldState, newer: PeerThreadFoldState): PeerThreadFoldState { + const result = new PeerThreadFoldState(); + const threadIds = new Set([...older.threads.keys(), ...newer.threads.keys()]); + for (const threadId of threadIds) { + const olderBucket = older.threads.get(threadId); + const newerBucket = newer.threads.get(threadId); + if (olderBucket == null && newerBucket != null) { + result.threads.set(threadId, cloneThreadBucket(newerBucket)); + continue; + } + if (newerBucket == null && olderBucket != null) { + result.threads.set(threadId, cloneThreadBucket(olderBucket)); + continue; + } + if (olderBucket == null || newerBucket == null) { + continue; + } + result.threads.set(threadId, { + events: new Map([...olderBucket.events, ...newerBucket.events]), + modelMessageIds: [...olderBucket.modelMessageIds, ...newerBucket.modelMessageIds], + toolResults: new Map([...olderBucket.toolResults, ...newerBucket.toolResults]), + pendingApprovals: new Map([...olderBucket.pendingApprovals, ...newerBucket.pendingApprovals]), + approvalDecisions: new Map([...olderBucket.approvalDecisions, ...newerBucket.approvalDecisions]), + pendingResponses: new Map([...olderBucket.pendingResponses, ...newerBucket.pendingResponses]), + done: newerBucket.done || olderBucket.done, + ...(newerBucket.title != null || olderBucket.title != null + ? { title: newerBucket.title ?? olderBucket.title } + : {}), + ...(newerBucket.agentInfo != null || olderBucket.agentInfo != null + ? { agentInfo: newerBucket.agentInfo ?? olderBucket.agentInfo } + : {}), + }); + } + for (const [threadId, link] of older.threadParents) { + result.threadParents.set(threadId, link); + } + for (const [threadId, link] of newer.threadParents) { + result.threadParents.set(threadId, link); + } + return result; +} + +/** + * Ingests a chronologically ordered list of session-level event items into the + * snapshot. `turn.created` marks the start of a turn (provides user input), + * content events are folded as usual, and `turn.done` finalises the turn record + * and pushes it to `snapshot.turns`. + * + * `onTurnComplete` is called after each `turn.done` with the partially-built + * snapshot so callers can update UI progressively. The fold state is correct at + * that point for every turn up to and including the just-completed one. + */ +function ingestSessionEventsIntoSnapshot( + snapshot: SessionSnapshot, + items: GatewaySessionEventItem[], + onTurnComplete?: (snap: SessionSnapshot) => void, +): void { + let currentTurnId: string | null = null; + let currentCreatedEvent: TurnCreatedEvent | null = null; + let currentContentEvents: TurnEvent[] = []; + let beforeCount = 0; + // Session-scoped: sandbox.created fires when a sandbox is (re)created and the + // sandbox is reused by later turns, so carry the latest one forward. + let sessionSandboxId: string | undefined; + + for (const item of items) { + const { turnId, event } = item; + + if (event.type === 'turn.created') { + currentTurnId = turnId; + currentCreatedEvent = event; + currentContentEvents = []; + beforeCount = snapshot.fold.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0; + } else if (event.type === 'turn.done') { + if (currentTurnId == null || currentCreatedEvent == null) { + continue; + } + + const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID); + const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(beforeCount); + + const sandboxEvent = currentContentEvents.find( + (ev): ev is Extract => ev.type === 'sandbox.created', + ); + sessionSandboxId = sandboxEvent?.sandboxId ?? sessionSandboxId; + + applyUserToolResponsesToFold(snapshot.fold, currentCreatedEvent.input ?? []); + snapshot.turns.push( + sessionEventsToSessionRecord(currentTurnId, currentCreatedEvent, event, rootModelMessageIds, sessionSandboxId), + ); + + // Pass a new object reference so React's Object.is check in + // useState sees a changed value and schedules a re-render. + // The snapshot is mutated in place throughout this loop, so + // passing `snapshot` directly would make every tick look identical + // to React after the first setSnapshot call. + onTurnComplete?.(replaceSessionSnapshot(snapshot, {})); + + currentTurnId = null; + currentCreatedEvent = null; + currentContentEvents = []; + } else { + if (currentTurnId != null) { + ingestTurnEvent(snapshot.fold, event); + currentContentEvents.push(event); + } + } + } +} + +function attachRunningTurn(snapshot: SessionSnapshot, runningTurn: Turn | undefined): SessionSnapshot { + if (runningTurn == null) { + return snapshot; + } + const pendingUserText = extractTurnUserText(runningTurn.input); + return replaceSessionSnapshot(snapshot, { + runningTurn, + unstable_resume: true, + groupRootBaseline: computeGroupRootBaseline(snapshot.turns), + ...(pendingUserText !== undefined + ? { + pendingUser: { + turnId: runningTurn.id, + content: extractTurnUserMessageContent(runningTurn.input), + createdAt: new Date(runningTurn.createdAt), + }, + } + : {}), + }); +} + +/** + * Last `turn.created` in ASC event order with no following `turn.done` — the + * open tip of the active branch in this window. + */ +function findOpenTurnCreated( + itemsAsc: readonly GatewaySessionEventItem[], +): { turnId: string; event: TurnCreatedEvent } | undefined { + let open: { turnId: string; event: TurnCreatedEvent } | undefined; + for (const item of itemsAsc) { + if (item.event.type === 'turn.created') { + open = { turnId: item.turnId, event: item.event }; + } else if (item.event.type === 'turn.done') { + open = undefined; + } + } + return open; +} + +function turnFromCreatedEvent(options: { sessionId: string; turnId: string; event: TurnCreatedEvent }): Turn { + const { sessionId, turnId, event } = options; + return { + id: turnId, + sessionId, + state: { status: 'running' }, + createdAt: event.createdAt, + ...(event.input != null ? { input: event.input } : {}), + ...(event.previousTurnId === undefined ? {} : { previousTurnId: event.previousTurnId }), + }; +} + +interface SessionTip { + /** Turn to resume and subscribe to; absent once the tip has finished. */ + runningTurn?: Turn; + /** + * Tip input that event ingestion could not apply, because it only folds a + * turn's input once that turn's `turn.done` arrives. Answered ask-user + * prompts and approvals live here, so this must be folded even when the tip + * is no longer running. + */ + continuationInput: readonly TurnInputItem[]; +} + +/** + * Resolves the tip turn of the active branch for resume/subscribe. + * + * Prefer an open tip from the events window (works when listTurns is + * oldest-first and when listEvents includes the running turn). Fall back to + * `listTurns({ limit: 1 })` for hosts that omit the running turn from + * listEvents and put the tip first. + */ +async function resolveSessionTip(options: { + server: AgentChatServer; + sessionId: string; + itemsAsc: readonly GatewaySessionEventItem[]; +}): Promise { + const { server, sessionId, itemsAsc } = options; + const open = findOpenTurnCreated(itemsAsc); + if (open != null) { + const continuationInput = open.event.input ?? []; + if (typeof server.getTurn === 'function') { + try { + const turn = await server.getTurn({ + sessionId, + turnId: open.turnId, + }); + if (turn.state.status === 'running') { + return { + runningTurn: turn, + continuationInput: turn.input ?? continuationInput, + }; + } + // Tip finished between listEvents and getTurn: nothing to + // resume, but its answers still belong in the fold. + return { continuationInput: turn.input ?? continuationInput }; + } catch { + // getTurn failed; synthesize from the open turn.created below. + } + } + return { + runningTurn: turnFromCreatedEvent({ + sessionId, + turnId: open.turnId, + event: open.event, + }), + continuationInput, + }; + } + + // Hosts that exclude the running turn from listEvents still surface it as + // the first row of listTurns when that API is tip-first. + const turnsPage = await server.listTurns({ sessionId, limit: 1 }); + const tip = turnsPage.data[0]; + return tip?.state.status === 'running' + ? { runningTurn: tip, continuationInput: tip.input ?? [] } + : { continuationInput: [] }; +} + +/** + * Builds a session snapshot using the session-level `listEvents` API. + * + * Loads the newest event page (extending only when a page boundary splits a + * turn group), then leaves older pages for `prependOlderSessionHistory`. + * Detects a currently-running tip from an open `turn.created` in that window + * when present; otherwise falls back to `listTurns({ limit: 1 })` for hosts + * that omit the running turn from listEvents. + * + * `onProgress` is called after each complete turn is ingested so callers can + * update the UI progressively while the processing loop runs. + */ +export async function buildSnapshotFromSessionEvents( + server: AgentChatServer, + sessionId: string, + onProgress?: (snap: SessionSnapshot) => void, +): Promise { + const window = await fetchSessionEventsWindow(server, sessionId); + const historyPagination: SessionHistoryPagination = { + hasOlder: window.hasOlder, + ...(window.olderPageToken != null ? { olderPageToken: window.olderPageToken } : {}), + }; + + const snapshot = createEmptySessionSnapshot(); + ingestSessionEventsIntoSnapshot(snapshot, window.itemsAsc, onProgress); + + const withHistory = replaceSessionSnapshot(snapshot, { + historyEvents: window.itemsAsc, + historyPagination, + }); + + const tip = await resolveSessionTip({ + server, + sessionId, + itemsAsc: window.itemsAsc, + }); + // The tip has no turn.done in this window, so ingestion never folded its + // input. Apply it here so answered approvals / ask-user prompts are not + // restored as pending after a refresh. + applyUserToolResponsesToFold(withHistory.fold, tip.continuationInput); + return attachRunningTurn(withHistory, tip.runningTurn); +} + +/** + * Fetches the next older `listEvents` window and prepends it onto `snapshot` + * without tearing down live stream / pending UI state. + */ +export async function prependOlderSessionHistory( + server: AgentChatServer, + sessionId: string, + snapshot: SessionSnapshot, +): Promise { + const pagination = snapshot.historyPagination; + if (pagination?.hasOlder !== true || pagination.olderPageToken == null) { + return snapshot; + } + + const window = await fetchSessionEventsWindow(server, sessionId, { + pageToken: pagination.olderPageToken, + }); + if (window.itemsAsc.length === 0) { + return replaceSessionSnapshot(snapshot, { + historyPagination: { hasOlder: false }, + }); + } + + const olderSnap = createEmptySessionSnapshot(); + ingestSessionEventsIntoSnapshot(olderSnap, window.itemsAsc); + + const existingIds = new Set(snapshot.turns.map(turn => turn.id)); + const olderTurns = olderSnap.turns.filter(turn => !existingIds.has(turn.id)); + const mergedFold = prependFoldState(olderSnap.fold, snapshot.fold); + const historyEvents = [...window.itemsAsc, ...(snapshot.historyEvents ?? [])]; + const olderRootIds = olderTurns.flatMap(turn => turn.rootModelMessageIds ?? []); + + // Forward-propagate sandbox identity revealed by older pages onto + // already-loaded newer turns that reused the sandbox without emitting + // sandbox.created. A turn's own sandboxId (a mid-session re-create) wins. + let knownSandboxId: string | undefined; + const mergedTurns = [...olderTurns, ...snapshot.turns].map(turn => { + if (turn.sandboxId != null) { + knownSandboxId = turn.sandboxId; + return turn; + } + if (knownSandboxId != null) { + return { ...turn, sandboxId: knownSandboxId }; + } + return turn; + }); + + return replaceSessionSnapshot(snapshot, { + fold: mergedFold, + turns: mergedTurns, + historyEvents, + historyPagination: { + hasOlder: window.hasOlder, + ...(window.olderPageToken != null ? { olderPageToken: window.olderPageToken } : {}), + }, + ...(snapshot.groupRootBaseline != null + ? { + groupRootBaseline: [...olderRootIds, ...snapshot.groupRootBaseline], + } + : {}), + }); +} + +export interface ConvertTurnsResult { + messages: ThreadMessage[]; + foldState: PeerThreadFoldState; + runningTurn?: Turn; + unstable_resume?: boolean; +} + +function assistantStatusFromTurnState(state: Turn['state']): MessageStatus { + switch (state.status) { + case 'done': + return { type: 'complete', reason: 'stop' }; + case 'error': + return { type: 'incomplete', reason: 'error', error: state.message }; + case 'cancelled': + return { type: 'incomplete', reason: 'cancelled' }; + case 'running': + return { type: 'running' }; + default: + return { type: 'complete', reason: 'unknown' }; + } +} + +function resolveCreatedAt( + messageId: string, + fallback: Date, + options?: ProjectSessionMessagesOptions, + replace = false, +): Date { + return options?.getCreatedAt?.(messageId, fallback, replace) ?? fallback; +} + +function parseDataUriMime(data: string): string { + if (!data.startsWith('data:')) { + return 'application/octet-stream'; + } + const match = /^data:([^;,]+)/.exec(data); + return match?.[1] ?? 'application/octet-stream'; +} + +function fileContentToAttachment(file: FileContent, attachmentId: string): CompleteAttachment { + const mimeType = parseDataUriMime(file.data); + if (mimeType.startsWith('image/')) { + return { + id: attachmentId, + type: 'image', + name: file.name, + contentType: mimeType, + status: { type: 'complete' }, + content: [{ type: 'image', image: file.data, filename: file.name }], + }; + } + return { + id: attachmentId, + type: 'file', + name: file.name, + contentType: mimeType, + status: { type: 'complete' }, + content: [ + { + type: 'file', + mimeType, + filename: file.name, + data: file.data, + }, + ], + }; +} + +/** Projects gateway turn input onto an assistant-ui user message (text + attachments). */ +export function buildUserMessageFromTurnInput( + turnId: string, + input: Turn['input'], + createdAt: string | Date, + options?: ProjectSessionMessagesOptions, +): ThreadMessage { + const fallback = createdAt instanceof Date ? createdAt : new Date(createdAt); + const id = `${turnId}-user`; + const content: ThreadUserMessagePart[] = []; + const attachments: CompleteAttachment[] = []; + + for (const item of input ?? []) { + if (item.type !== 'user.message') { + continue; + } + const messageContent = item.content; + if (typeof messageContent === 'string') { + content.push({ type: 'text', text: messageContent }); + continue; + } + for (const part of messageContent) { + const imageUrl = extractImageUrlFromUserContentItem(part); + if (imageUrl != null) { + attachments.push(imageUrlToAttachment(imageUrl, `${turnId}-file-${String(attachments.length)}`)); + continue; + } + if (part.type === 'text') { + content.push({ type: 'text', text: part.text }); + } else { + attachments.push(fileContentToAttachment(part, `${turnId}-file-${String(attachments.length)}`)); + } + } + } + + return { + id, + role: 'user', + content: content.length > 0 ? content : [{ type: 'text', text: '' }], + attachments, + createdAt: resolveCreatedAt(id, fallback, options), + metadata: { custom: {} }, + }; +} + +function buildAssistantMessage( + turnId: string, + content: AssistantContentPart[], + createdAt: string | Date, + status: MessageStatus, + custom: Record = {}, + options?: ProjectSessionMessagesOptions, + replaceCreatedAt = false, +): ThreadMessage { + const fallback = createdAt instanceof Date ? createdAt : new Date(createdAt); + const id = `${turnId}-assistant`; + return { + id, + role: 'assistant', + content, + status, + createdAt: resolveCreatedAt(id, fallback, options, replaceCreatedAt), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom, + }, + }; +} + +async function ingestTurnEventsIntoFold( + server: AgentChatServer, + sessionId: string, + turnId: string, + foldState: PeerThreadFoldState, +): Promise { + if (server.listTurnEvents == null) { + return; + } + const listTurnEvents = server.listTurnEvents.bind(server); + const events = await drainListPages(pageToken => + listTurnEvents({ + sessionId, + turnId, + order: 'asc', + limit: TURN_EVENTS_PAGE_SIZE, + ...(pageToken != null ? { pageToken } : {}), + }), + ); + for (const event of events) { + ingestTurnEvent(foldState, event); + } +} + +async function fetchTurnEvents(server: AgentChatServer, sessionId: string, turnId: string): Promise { + if (server.listTurnEvents == null) { + return []; + } + const listTurnEvents = server.listTurnEvents.bind(server); + const events = await drainListPages(pageToken => + listTurnEvents({ + sessionId, + turnId, + order: 'asc', + limit: TURN_EVENTS_PAGE_SIZE, + ...(pageToken != null ? { pageToken } : {}), + }), + ); + return events; +} + +function ingestCollectedEventsIntoFold(foldState: PeerThreadFoldState, events: TurnEvent[]): void { + for (const event of events) { + ingestTurnEvent(foldState, event); + } +} + +async function fetchAllTurnEventsWithConcurrency( + server: AgentChatServer, + sessionId: string, + turns: Turn[], + concurrency: number, +): Promise { + const results = Array.from({ length: turns.length }, (): TurnEvent[] => []); + const pool = new Set>(); + for (let i = 0; i < turns.length; i++) { + const idx = i; + const turn = turns[idx]; + if (turn == null) { + continue; + } + const p: Promise = fetchTurnEvents(server, sessionId, turn.id).then(events => { + results[idx] = events; + pool.delete(p); + }); + pool.add(p); + if (pool.size >= concurrency) { + await Promise.race(pool); + } + } + await Promise.all(pool); + return results; +} + +export function rootModelMessageIdsSinceBaseline( + foldState: PeerThreadFoldState, + baseline: readonly string[], +): string[] { + const bucket = foldState.threads.get(ROOT_THREAD_ID); + if (bucket == null) { + return []; + } + if (baseline.length === 0) { + return [...bucket.modelMessageIds]; + } + const baselineSet = new Set(baseline); + return bucket.modelMessageIds.filter(id => !baselineSet.has(id)); +} + +export function computeGroupRootBaseline(turns: SessionTurnRecord[]): string[] { + let groupStartIndex = turns.length - 1; + while (groupStartIndex >= 0 && turns[groupStartIndex]?.userText == null) { + groupStartIndex--; + } + const baseline: string[] = []; + for (let i = 0; i < groupStartIndex; i++) { + baseline.push(...(turns[i]?.rootModelMessageIds ?? [])); + } + return baseline; +} + +function buildTurnUpdateFromFold( + foldState: PeerThreadFoldState, + turn: Pick, + rootModelMessageIds: readonly string[], +): TurnStreamUpdate { + const content = buildRootAssistantContentForIds(foldState, rootModelMessageIds); + let update: TurnStreamUpdate = { content }; + update = appendMcpAuthToTurnContent(update.content, turn); + update = appendToolApprovalToTurnContent(update, turn); + return update; +} + +function contentHasPendingRequiredActions(content: readonly AssistantContentPart[]): boolean { + const message: ThreadMessage = { + id: 'pending-check', + role: 'assistant', + content, + status: { type: 'complete', reason: 'stop' }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }; + return messageHasPendingApprovals(message) || messageHasPendingResponses(message); +} + +function resolveProjectedRequiredActionState( + turnUpdate: TurnStreamUpdate, + content: readonly AssistantContentPart[], + record: Pick, +): { status: MessageStatus; custom: Record } { + let status = turnUpdate.status ?? assistantStatusFromTurnState(record.state); + let custom = { ...(turnUpdate.metadata?.custom ?? {}) }; + + if ( + status.type === 'requires-action' && + status.reason === 'tool-calls' && + !contentHasPendingRequiredActions(content) + ) { + status = assistantStatusFromTurnState(record.state); + custom = Object.fromEntries( + Object.entries(custom).filter( + ([key]) => key !== TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY && key !== TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY, + ), + ); + } + + return { status, custom }; +} + +function projectActiveStreamUpdate(snapshot: SessionSnapshot): TurnStreamUpdate { + const activeStream = snapshot.activeStream; + if (activeStream == null) { + throw new Error('projectActiveStreamUpdate requires an active stream'); + } + + const hasStagedOverlay = + snapshot.requiredActions.approvals.size > 0 || snapshot.requiredActions.toolResponses.size > 0; + + // While the user has staged a response locally, keep the paused stream update + // so required-action collection can pair interrupt + result before resume. + if (hasStagedOverlay) { + return activeStream.update; + } + + const baseline = snapshot.groupRootBaseline ?? computeGroupRootBaseline(snapshot.turns); + const rootModelMessageIds = rootModelMessageIdsSinceBaseline(snapshot.fold, baseline); + + const foldContent = buildRootAssistantContentForIds(snapshot.fold, rootModelMessageIds); + const content = foldContent.length > 0 ? foldContent : activeStream.update.content; + + const turnRecord = snapshot.turns.find(turn => turn.id === activeStream.turnId); + const turnLike = turnRecord ?? snapshot.runningTurn; + + const rebuilt = + turnLike != null ? buildTurnUpdateFromFold(snapshot.fold, turnLike, rootModelMessageIds) : { content }; + + const metadata = rebuilt.metadata ?? activeStream.update.metadata; + const status = rebuilt.status ?? activeStream.update.status; + return { + ...activeStream.update, + content, + ...(metadata == null ? {} : { metadata }), + ...(status == null ? {} : { status }), + }; +} + +function applyRequiredActionsOverlayToMessages( + messages: ThreadMessage[], + overlay: RequiredActionsOverlay, +): ThreadMessage[] { + if (overlay.approvals.size === 0 && overlay.toolResponses.size === 0) { + return messages; + } + + return messages.map(message => { + if (message.role !== 'assistant') { + return message; + } + + let { content } = message; + if (overlay.approvals.size > 0) { + content = applyApprovalDecisionsToContent(content, overlay.approvals); + } + if (overlay.toolResponses.size > 0) { + content = applyStagedResponsesToContent(content, overlay.toolResponses); + } + + if (content === message.content) { + return message; + } + + return { ...message, content: [...content] }; + }); +} + +function projectHistoryTurns(snapshot: SessionSnapshot, options?: ProjectSessionMessagesOptions): ThreadMessage[] { + const messages: ThreadMessage[] = []; + let lastAssistantIndex: number | undefined; + let groupRootIds: string[] = []; + let sandboxId: string | undefined; + + for (let turnIndex = 0; turnIndex < snapshot.turns.length; turnIndex++) { + const record = snapshot.turns[turnIndex]; + if (record == null) { + continue; + } + const turnRootIds = record.rootModelMessageIds ?? []; + sandboxId = record.sandboxId ?? sandboxId; + + if (record.userText !== undefined) { + groupRootIds = [...turnRootIds]; + } else { + groupRootIds = [...groupRootIds, ...turnRootIds]; + } + + const turnUpdate = buildTurnUpdateFromFold(snapshot.fold, record, groupRootIds); + let content = turnUpdate.content; + + const subsequentDecisions = collectSubsequentApprovalDecisions(snapshot.turns, turnIndex); + if (subsequentDecisions.size > 0) { + content = applyApprovalDecisionsToContent(content, subsequentDecisions); + } + + const subsequentResponses = collectSubsequentToolResponses(snapshot.turns, turnIndex); + if (subsequentResponses.size > 0) { + content = applyStagedResponsesToContent(content, subsequentResponses); + } + + const currentResponses = collectToolResponsesFromTurnInput(record.input); + if (currentResponses.size > 0) { + content = applyStagedResponsesToContent(content, currentResponses); + } + + const currentDecisions = collectApprovalDecisionsFromTurnInput(record.input); + if (currentDecisions.size > 0) { + content = applyApprovalDecisionsToContent(content, currentDecisions); + } + + const { status, custom: baseCustom } = resolveProjectedRequiredActionState(turnUpdate, content, record); + // Continuation turns fold into the previous assistant message and overwrite this custom, + // so a folded message reports the latest turn that contributed to it — the one whose + // sandbox wrote the newest artifacts. + const custom = { + ...baseCustom, + turnId: record.id, + ...(sandboxId != null ? { sandboxId } : {}), + }; + const assistantCreatedAt = record.state.status === 'running' ? record.createdAt : record.state.completedAt; + const replaceAssistantCreatedAt = record.state.status !== 'running'; + + if (record.userText !== undefined) { + messages.push(buildUserMessageFromTurnInput(record.id, record.input, record.createdAt, options)); + + if (record.state.status === 'running') { + if (content.length > 0) { + messages.push( + buildAssistantMessage( + record.id, + content, + assistantCreatedAt, + status, + custom, + options, + replaceAssistantCreatedAt, + ), + ); + } + break; + } + + if (content.length > 0) { + messages.push( + buildAssistantMessage( + record.id, + content, + assistantCreatedAt, + status, + custom, + options, + replaceAssistantCreatedAt, + ), + ); + lastAssistantIndex = messages.length - 1; + } + } else if (content.length > 0 && lastAssistantIndex != null) { + if (record.state.status === 'running') { + break; + } + const existing = messages[lastAssistantIndex]; + if (existing?.role !== 'assistant') { + continue; + } + messages[lastAssistantIndex] = { + ...existing, + content, + status, + createdAt: resolveCreatedAt(existing.id, new Date(assistantCreatedAt), options, true), + metadata: { + ...existing.metadata, + custom, + }, + }; + } else if (record.state.status === 'running') { + break; + } + } + + return messages; +} + +export function projectSessionMessages( + snapshot: SessionSnapshot, + options?: ProjectSessionMessagesOptions, +): ThreadMessage[] { + let messages = projectHistoryTurns(snapshot, options); + + if (snapshot.pendingUser != null) { + messages.push( + buildUserMessageFromTurnInput( + snapshot.pendingUser.turnId, + [{ type: 'user.message', content: snapshot.pendingUser.content }], + snapshot.pendingUser.createdAt, + options, + ), + ); + } + + if (snapshot.activeStream != null) { + const { turnId, update, isContinuation, streamComplete } = snapshot.activeStream; + const resolvedUpdate = streamComplete === true ? projectActiveStreamUpdate(snapshot) : update; + + const last = messages.at(-1); + const existingAssistant = isContinuation && last?.role === 'assistant' ? last : undefined; + let assistantMessage = turnStreamUpdateToAssistantMessage(turnId, resolvedUpdate, existingAssistant, options); + if (streamComplete === true && resolvedUpdate.status == null) { + assistantMessage = { + ...assistantMessage, + status: { type: 'complete', reason: 'stop' }, + }; + } + + if (isContinuation && last?.role === 'assistant') { + messages = [...messages.slice(0, -1), assistantMessage]; + } else { + messages = [...messages, assistantMessage]; + } + } + + return applyRequiredActionsOverlayToMessages(messages, snapshot.requiredActions); +} + +const DEFAULT_LIST_EVENTS_CONCURRENCY = 5; + +function ingestTurnsIntoSnapshot( + snapshot: SessionSnapshot, + turns: Turn[], + eventArrays: TurnEvent[][], +): Turn | undefined { + let runningTurn: Turn | undefined; + // Session-scoped: sandbox.created fires when a sandbox is (re)created and the + // sandbox is reused by later turns, so carry the latest one forward. + let sessionSandboxId: string | undefined; + + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]; + if (turn == null) { + continue; + } + const rootBucket = snapshot.fold.threads.get(ROOT_THREAD_ID); + const beforeCount = rootBucket?.modelMessageIds.length ?? 0; + + ingestCollectedEventsIntoFold(snapshot.fold, eventArrays[i] ?? []); + applyUserToolResponsesToFold(snapshot.fold, turn.input ?? []); + + const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID); + const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(beforeCount); + + const sandboxEvent = (eventArrays[i] ?? []).find( + (event): event is Extract => event.type === 'sandbox.created', + ); + sessionSandboxId = sandboxEvent?.sandboxId ?? sessionSandboxId; + + snapshot.turns.push({ + ...turnToSessionRecord(turn), + rootModelMessageIds, + ...(sessionSandboxId != null ? { sandboxId: sessionSandboxId } : {}), + }); + + if (turn.state.status === 'running') { + runningTurn = turn; + break; + } + } + + return runningTurn; +} + +export async function buildSnapshotFromSession( + server: AgentChatServer, + sessionId: string, + concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY, +): Promise { + // Completed history comes from session-level listEvents. The session API + // excludes the running turn — hydrate that turn via listTurnEvents so + // convertTurnsToThreadMessages still surfaces in-flight content. + const snapshot = await buildSnapshotFromSessionEvents(server, sessionId); + if (snapshot.runningTurn == null) { + return snapshot; + } + + const turn = snapshot.runningTurn; + const eventArrays = await fetchAllTurnEventsWithConcurrency(server, sessionId, [turn], concurrency); + ingestTurnsIntoSnapshot(snapshot, [turn], eventArrays); + + // Hydrated in-flight content lives in `turns` / `fold` now — drop the + // reconnect seed so projection does not duplicate the user bubble. + const snapshotWithoutPendingUser = { ...snapshot }; + delete snapshotWithoutPendingUser.pendingUser; + return replaceSessionSnapshot(snapshotWithoutPendingUser, { + runningTurn: turn, + unstable_resume: true, + groupRootBaseline: computeGroupRootBaseline(snapshot.turns), + }); +} + +/** + * Rebuilds the conversation through `anchorTurnId`, including that turn. + * The server follows parent links from the anchor, so turns from abandoned + * branches are excluded. A null anchor represents an empty conversation. + */ +export async function buildSnapshotThroughTurn( + server: AgentChatServer, + sessionId: string, + anchorTurnId: string | null, +): Promise { + if (anchorTurnId == null) { + return createEmptySessionSnapshot(); + } + const items = await fetchAllSessionEvents(server, sessionId, { + lastTurnId: anchorTurnId, + }); + const snapshot = createEmptySessionSnapshot(); + ingestSessionEventsIntoSnapshot(snapshot, items); + return snapshot; +} + +/** + * Resolves `previousTurnId` for edit/retry of `turnId` from the turn's own + * parent pointer (`"none"` for roots). Independent of listTurns order. + */ +export async function resolveGatewayBranchPreviousTurnIdForTurn( + server: AgentChatServer, + sessionId: string, + turnId: string, +): Promise { + const turn = await server.getTurn({ sessionId, turnId }); + return turn.previousTurnId ?? 'none'; +} + +export async function buildTurnAssistantContent( + server: AgentChatServer, + sessionId: string, + turn: Pick, + foldState?: PeerThreadFoldState, +): Promise { + const state = foldState ?? new PeerThreadFoldState(); + const beforeCount = state.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0; + await ingestTurnEventsIntoFold(server, sessionId, turn.id, state); + const afterIds = state.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? []; + const rootModelMessageIds = afterIds.slice(beforeCount); + return buildTurnUpdateFromFold(state, turn, rootModelMessageIds).content; +} + +export async function convertTurnsToThreadMessages( + server: AgentChatServer, + sessionId: string, +): Promise { + const snapshot = await buildSnapshotFromSession(server, sessionId); + const messages = projectSessionMessages(snapshot); + + return { + messages, + foldState: snapshot.fold, + ...(snapshot.runningTurn != null + ? { + runningTurn: snapshot.runningTurn, + unstable_resume: true, + } + : {}), + }; +} + +export function getTurnMessageContent(message: AppendMessage): string { + const parts: string[] = []; + for (const part of message.content) { + if (part.type === 'text') { + parts.push(part.text); + } + } + const text = parts.join('\n').trim(); + if (!text) { + throw new Error('User message must contain text content.'); + } + return text; +} + +/** + * Gateway `user.message` content (string for text-only, or text/file parts). + * Derived from SDK `TurnInputItem` so it tracks API changes. + */ +export type UserMessageContent = Extract['content']; + +type UserMessageContentItem = Exclude[number]; +type FileContent = Extract; +type TextContent = Extract; + +function toFileDataUri(data: string, mimeType: string): string { + // FileContent expects a data URI (`data:;base64,`). + // Attachment adapters typically already produce one via FileReader.readAsDataURL; + // only wrap bare base64 payloads. + if (data.startsWith('data:')) { + return data; + } + return `data:${mimeType};base64,${data}`; +} + +function toFileContent(name: string, data: string, mimeType: string): FileContent { + return { + type: 'file', + name, + data: toFileDataUri(data, mimeType), + }; +} + +/** + * Builds the gateway turn input content from a composer message, forwarding + * attachments as SDK `FileContent` parts. Mirrors how assistant-ui surfaces + * attachment content on `message.attachments[].content`. + */ +export function buildUserMessageContent(message: AppendMessage): UserMessageContent { + const inputParts = [ + ...message.content, + ...(message.attachments?.flatMap(attachment => + attachment.content.map(part => ({ + ...part, + filename: attachment.name, + })), + ) ?? []), + ]; + + const items: UserMessageContentItem[] = []; + for (const part of inputParts) { + switch (part.type) { + case 'text': + if (part.text.trim().length > 0) { + const textPart: TextContent = { type: 'text', text: part.text }; + items.push(textPart); + } + break; + case 'image': + items.push(toFileContent(part.filename ?? 'image', part.image, 'image/png')); + break; + case 'file': + items.push(toFileContent(part.filename ?? 'file', part.data, part.mimeType)); + break; + default: + break; + } + } + + const hasFile = items.some((item): item is FileContent => item.type === 'file'); + if (!hasFile) { + // Text-only: keep the string form and the non-empty invariant. + return getTurnMessageContent(message); + } + return items; +} + +/** Derives display text from gateway user-message content (drops file parts). */ +export function userMessageContentToText(content: UserMessageContent): string { + if (typeof content === 'string') { + return content; + } + return content + .filter((item): item is TextContent => item.type === 'text') + .map(item => item.text) + .join('\n') + .trim(); +} + +/** Strips the `-user` suffix from a projected user message id to recover the turn id. */ +export function parseTurnIdFromMessageId(messageId: string): string { + return messageId.replace(/-user$/, ''); +} + +/** Parent turn id for branching before `turnId`; `null` when editing the first turn. */ +export function resolveBranchPreviousTurnId(turns: readonly SessionTurnRecord[], turnId: string): string | null { + const turnIndex = turns.findIndex(turn => turn.id === turnId); + if (turnIndex <= 0) { + return null; + } + return turns[turnIndex - 1]?.id ?? null; +} + +/** Extracts edited text from an assistant-ui append message (text parts only). */ +export function extractEditedText(message: AppendMessage): string { + return getTurnMessageContent(message); +} + +/** Original gateway user-message content from a turn record (for reset). */ +export function extractTurnUserMessageContent(input: TurnInputItem[] | undefined): UserMessageContent { + for (const item of input ?? []) { + if (item.type === 'user.message') { + return item.content; + } + } + return ''; +} + +/** + * Builds resubmit content for a text-only edit: replaces text with `editedText` + * while preserving original file parts from the turn record. + */ +export function buildEditedUserMessageContent( + editedText: string, + originalInput: TurnInputItem[] | undefined, +): UserMessageContent { + const fileParts: FileContent[] = []; + for (const item of originalInput ?? []) { + if (item.type !== 'user.message') { + continue; + } + const content = item.content; + if (typeof content === 'string') { + continue; + } + for (const part of content) { + if (part.type === 'file') { + fileParts.push(part); + } + } + } + + if (fileParts.length === 0) { + return editedText; + } + + const items: UserMessageContentItem[] = []; + if (editedText.trim().length > 0) { + items.push({ type: 'text', text: editedText }); + } + items.push(...fileParts); + return items; +} + +function buildMcpAuthUpdate( + pendingMcpAuth: McpAuthRequiredEvent, + foldState: PeerThreadFoldState, + groupRootBaseline?: readonly string[], +): TurnStreamUpdate { + const base = + groupRootBaseline != null + ? buildRootAssistantContentForIds(foldState, rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline)) + : buildRootAssistantContent(foldState); + return { + content: [...base, ...buildMcpAuthTextParts()], + status: mcpAuthAssistantStatus(), + metadata: { custom: mcpAuthMessageCustom(pendingMcpAuth.mcpServers) }, + }; +} + +export async function* streamTurnEvents( + stream: AsyncIterable, + foldState: PeerThreadFoldState, + groupRootBaseline?: readonly string[], + onTurnIdAvailable?: (turnId: string) => void, +): AsyncGenerator { + let pendingMcpAuth: McpAuthRequiredEvent | undefined; + let sandboxId: string | undefined; + let sandboxIdYielded = false; + + const withSandbox = (update: TurnStreamUpdate): TurnStreamUpdate => { + if (sandboxId == null) { + return update; + } + return { + ...update, + metadata: { ...update.metadata, custom: { ...update.metadata?.custom, sandboxId } }, + }; + }; + + const yieldContent = (): AssistantContentPart[] | undefined => { + const ids = + groupRootBaseline != null + ? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline) + : (foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? []); + const content = buildRootAssistantContentForIds(foldState, ids); + return content.length > 0 ? content : undefined; + }; + + for await (const data of stream) { + const event = data.event; + + if (event.type === 'turn.created') { + onTurnIdAvailable?.(event.turnId); + continue; + } + + if (event.type === 'sandbox.created') { + sandboxId = event.sandboxId; + continue; + } + + if (event.type === 'mcp.auth_required') { + pendingMcpAuth = event; + continue; + } + + if (event.type === 'turn.done') { + if (event.state.status === 'error') { + throw new Error(event.state.message); + } + // The turn is logically complete once `turn.done` is observed. The + // resumed-turn transport (`subscribeToTurn`) is a reconnectable live + // tail and is not guaranteed to close its SSE body right after this + // event, so we must stop consuming explicitly rather than waiting + // for the underlying stream to end — otherwise `isRunning` never + // clears and the composer's cancel/spinner button gets stuck. + break; + } + + if (!ingestStreamEvent(foldState, event)) { + continue; + } + + const content = yieldContent(); + if (content != null) { + if (sandboxId != null) { + sandboxIdYielded = true; + } + yield withSandbox({ content }); + } + } + + if (pendingMcpAuth != null) { + yield withSandbox(buildMcpAuthUpdate(pendingMcpAuth, foldState, groupRootBaseline)); + return; + } + + const approvalThreadId = findFirstPendingApprovalThreadId(foldState); + const responseThreadId = findFirstPendingResponseThreadId(foldState); + if (approvalThreadId != null || responseThreadId != null) { + const custom: Record = {}; + if (approvalThreadId != null) { + Object.assign( + custom, + toolApprovalMessageCustom(approvalThreadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : approvalThreadId), + ); + } + if (responseThreadId != null) { + Object.assign( + custom, + toolResponseMessageCustom(responseThreadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : responseThreadId), + ); + } + const ids = + groupRootBaseline != null + ? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline) + : (foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? []); + yield withSandbox({ + content: buildRootAssistantContentForIds(foldState, ids), + status: approvalThreadId != null ? toolApprovalStatus() : toolResponseStatus(), + metadata: { custom }, + }); + return; + } + + if (sandboxId != null && !sandboxIdYielded) { + const ids = + groupRootBaseline != null + ? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline) + : (foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? []); + yield withSandbox({ content: buildRootAssistantContentForIds(foldState, ids) }); + } +} + +export function turnStreamUpdateToAssistantMessage( + turnId: string, + update: TurnStreamUpdate, + existing?: ThreadMessage, + options?: ProjectSessionMessagesOptions, +): ThreadMessage { + const id = existing?.role === 'assistant' ? existing.id : `${turnId}-assistant`; + const fallbackCreatedAt = existing?.createdAt ?? new Date(); + return { + id, + role: 'assistant', + content: update.content, + status: update.status ?? { type: 'running' }, + createdAt: resolveCreatedAt(id, fallbackCreatedAt, options), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { + ...(existing?.role === 'assistant' ? existing.metadata.custom : {}), + ...update.metadata?.custom, + turnId, + }, + }, + }; +} + +export function repositoryItemsFromMessages(messages: readonly ThreadMessage[]): ExportedMessageRepositoryItem[] { + const items: ExportedMessageRepositoryItem[] = []; + let parentId: string | null = null; + for (const message of messages) { + items.push({ parentId, message }); + parentId = message.id; + } + return items; +} + +export type { ProjectSessionMessagesOptions, SessionSnapshot } from './sessionSnapshot.js'; diff --git a/packages/assistant-ui-runtime/src/createSubAgent.ts b/packages/assistant-ui-runtime/src/createSubAgent.ts new file mode 100644 index 000000000..c4635db81 --- /dev/null +++ b/packages/assistant-ui-runtime/src/createSubAgent.ts @@ -0,0 +1,11 @@ +import type { ToolCall } from './server/index.js'; + +export function isCreateSubAgentToolCall(toolCall: Pick): boolean { + // Gateway may attach trueforge-system toolInfo on persisted turns, but streamed + // model.message tool calls often only carry function.name. Without the fallback, + // foldPeerThreads never nests child threads under the spawning tool call. + if (toolCall.toolInfo?.type === 'trueforge-system' && toolCall.toolInfo.name === 'create_sub_agent') { + return true; + } + return toolCall.function.name === 'create_sub_agent'; +} diff --git a/packages/assistant-ui-runtime/src/draft/agentSpec.ts b/packages/assistant-ui-runtime/src/draft/agentSpec.ts new file mode 100644 index 000000000..8ea216b22 --- /dev/null +++ b/packages/assistant-ui-runtime/src/draft/agentSpec.ts @@ -0,0 +1,34 @@ +import type { AgentSpec } from '../server/types.js'; + +export interface DraftSession { + title?: string | null; + agentSpec: AgentSpec; +} + +/** Partial update — host fields flow through when `TSpec` is widened. */ +export type AgentSpecUpdate = { + [K in keyof TSpec]?: K extends 'model' + ? Omit, 'params'> & { + params?: Partial>; + } + : TSpec[K]; +}; + +export function mergeAgentSpec(base: TSpec, update: AgentSpecUpdate): TSpec { + const { model: modelUpdate, ...rest } = update; + + const next: TSpec = Object.assign({}, base, rest); + + if (modelUpdate != null) { + next.model = Object.assign({}, base.model, modelUpdate, { + name: modelUpdate.name ?? base.model.name, + params: modelUpdate.params != null ? { ...base.model.params, ...modelUpdate.params } : base.model.params, + }); + } + + return next; +} + +export function draftSessionTitle(draft: DraftSession): string { + return draft.title ?? draft.agentSpec.model.name; +} diff --git a/packages/assistant-ui-runtime/src/draft/draftSessionBridge.ts b/packages/assistant-ui-runtime/src/draft/draftSessionBridge.ts new file mode 100644 index 000000000..03fbf4ff0 --- /dev/null +++ b/packages/assistant-ui-runtime/src/draft/draftSessionBridge.ts @@ -0,0 +1,28 @@ +import type { AgentChatServer, AgentSpec } from '../server/types.js'; + +export const DRAFT_SESSION_LAST_UPDATED_AT_HEADER = 'x-tfy-session-last-updated-at'; + +export interface DraftSessionBridge { + syncAgentSpec: (draftSessionId: string, agentSpec: AgentSpec) => Promise; + getDraftAgentSpec: (draftSessionId: string) => Promise; +} + +export function createDraftSessionBridge(server: AgentChatServer): DraftSessionBridge { + return { + async getDraftAgentSpec(draftSessionId) { + const session = await server.getSession({ sessionId: draftSessionId }); + if (session.agentSpec == null) { + throw new Error(`Session ${draftSessionId} has no agentSpec (isMutable=${String(session.isMutable)}).`); + } + return session.agentSpec; + }, + + async syncAgentSpec(draftSessionId, agentSpec) { + const updated = await server.updateSession({ + sessionId: draftSessionId, + agentSpec, + }); + return updated.updatedAt; + }, + }; +} diff --git a/packages/assistant-ui-runtime/src/draft/trueforgeDraftThreadListAdapter.ts b/packages/assistant-ui-runtime/src/draft/trueforgeDraftThreadListAdapter.ts new file mode 100644 index 000000000..3976cc538 --- /dev/null +++ b/packages/assistant-ui-runtime/src/draft/trueforgeDraftThreadListAdapter.ts @@ -0,0 +1,73 @@ +import type { RemoteThreadListAdapter } from '@assistant-ui/core'; + +import type { AgentChatServer, AgentSpec } from '../server/types.js'; +import { sessionListStartTimestamp } from '../sessionListStartTimestamp.js'; +import { sessionDisplayTitle, sessionToThreadMetadata } from '../sessionThreadMetadata.js'; + +const THREAD_LIST_PAGE_SIZE = 20; + +export function createTrueForgeDraftThreadListAdapter(options: { + server: AgentChatServer; + defaultAgentSpec: AgentSpec; + getAgentSpec?: () => AgentSpec; + /** When set, filters `listSessions` by this agent id. Omit for all chats. */ + listSessionsAgentId?: string; + /** Restrict history to sessions created by the authenticated subject. */ + listSessionsCreatedByMe?: boolean; +}): RemoteThreadListAdapter { + const { server, defaultAgentSpec, getAgentSpec, listSessionsAgentId, listSessionsCreatedByMe = false } = options; + + return { + async list({ after } = {}) { + const page = await server.listSessions({ + ...(listSessionsAgentId != null ? { agentId: listSessionsAgentId } : {}), + createdByMe: listSessionsCreatedByMe, + limit: THREAD_LIST_PAGE_SIZE, + ...(after == null ? {} : { pageToken: after }), + startTimestamp: sessionListStartTimestamp(), + }); + const threads = page.data.map(session => + sessionToThreadMetadata(session, sessionDisplayTitle(session, defaultAgentSpec)), + ); + return { + threads, + nextCursor: page.nextPageToken ?? undefined, + }; + }, + + async initialize() { + const draft = await server.createSession({ + agentSpec: getAgentSpec?.() ?? defaultAgentSpec, + }); + return { remoteId: draft.id, externalId: undefined }; + }, + + async fetch(remoteId) { + const draft = await server.getSession({ sessionId: remoteId }); + return sessionToThreadMetadata(draft, sessionDisplayTitle(draft, defaultAgentSpec)); + }, + + async rename(remoteId, newTitle) { + if (typeof server.renameSession !== 'function') { + return; + } + await server.renameSession({ sessionId: remoteId, title: newTitle }); + }, + archive() { + return Promise.resolve(); + }, + unarchive() { + return Promise.resolve(); + }, + async delete(remoteId) { + if (typeof server.deleteSession !== 'function') { + return; + } + await server.deleteSession({ sessionId: remoteId }); + }, + + generateTitle() { + return Promise.resolve(new ReadableStream()); + }, + }; +} diff --git a/packages/assistant-ui-runtime/src/draft/useDraftAgentSpec.ts b/packages/assistant-ui-runtime/src/draft/useDraftAgentSpec.ts new file mode 100644 index 000000000..3c57d5309 --- /dev/null +++ b/packages/assistant-ui-runtime/src/draft/useDraftAgentSpec.ts @@ -0,0 +1,289 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type { AgentSpec } from '../server/types.js'; +import { mergeAgentSpec, type AgentSpecUpdate } from './agentSpec.js'; +import type { DraftSessionBridge } from './draftSessionBridge.js'; + +const SPEC_SYNC_DEBOUNCE_MS = 400; + +export interface UseDraftAgentSpecOptions { + draftSessionId: string | undefined; + draftBridge: DraftSessionBridge | null; + defaultAgentSpec: AgentSpec; + onAgentSpecChange?: ((spec: AgentSpec) => void) | undefined; + onError?: ((error: unknown) => void) | undefined; +} + +export interface UseDraftAgentSpecResult { + agentSpec: AgentSpec | null; + draftSessionId: string | undefined; + isSpecLoading: boolean; + isSpecSyncing: boolean; + specError: unknown; + updateAgentSpec: (update: AgentSpecUpdate) => void; + flushAgentSpec: () => Promise; + adoptAgentSpec: (request: { agentSpec: AgentSpec; updatedAt?: string }) => void; + takeTurnHeaderTimestamp: () => Promise; +} + +export function useDraftAgentSpec({ + draftSessionId, + draftBridge, + defaultAgentSpec, + onAgentSpecChange, + onError, +}: UseDraftAgentSpecOptions): UseDraftAgentSpecResult { + const enabled = draftBridge != null; + const [agentSpec, setAgentSpec] = useState(defaultAgentSpec); + const [isSpecLoading, setIsSpecLoading] = useState(false); + const [isSpecSyncing, setIsSpecSyncing] = useState(false); + const [specError, setSpecError] = useState(null); + + const agentSpecRef = useRef(agentSpec); + agentSpecRef.current = agentSpec; + + const syncTimeoutRef = useRef | undefined>(undefined); + const syncGenerationRef = useRef(0); + const loadedDraftIdRef = useRef(undefined); + const localDirtyRef = useRef(false); + const lastUpdatedAtRef = useRef(undefined); + const pendingFlushRef = useRef<(() => Promise) | undefined>(undefined); + const inFlightFlushRef = useRef | undefined>(undefined); + const activeDraftIdRef = useRef(draftSessionId); + + // Invalidate sync state from the previous draft so a stale pending flush or + // stored updatedAt can't update the wrong draft or leak into the next + // turn's header. Bumping the generation makes any in-flight sync a no-op. + useEffect(() => { + const previousDraftId = activeDraftIdRef.current; + if (previousDraftId === draftSessionId) { + return; + } + activeDraftIdRef.current = draftSessionId; + syncGenerationRef.current++; + if (syncTimeoutRef.current != null) { + clearTimeout(syncTimeoutRef.current); + syncTimeoutRef.current = undefined; + } + pendingFlushRef.current = undefined; + inFlightFlushRef.current = undefined; + lastUpdatedAtRef.current = undefined; + setIsSpecSyncing(false); + // Dirty edits made against a previous draft must not be replayed onto + // the new one. Keep them only for lazy creation (undefined -> id). + if (previousDraftId != null) { + localDirtyRef.current = false; + } + }, [draftSessionId]); + + useEffect(() => { + if (draftBridge == null) { + return; + } + if (draftSessionId == null) { + loadedDraftIdRef.current = undefined; + setAgentSpec(defaultAgentSpec); + localDirtyRef.current = false; + setSpecError(null); + setIsSpecLoading(false); + return; + } + + if (loadedDraftIdRef.current === draftSessionId) { + return; + } + + const abortController = new AbortController(); + setIsSpecLoading(true); + void (async () => { + try { + const loaded = await draftBridge.getDraftAgentSpec(draftSessionId); + if (abortController.signal.aborted) { + return; + } + loadedDraftIdRef.current = draftSessionId; + + if (localDirtyRef.current) { + scheduleSpecSyncRef.current(draftSessionId, agentSpecRef.current); + localDirtyRef.current = false; + setSpecError(null); + setIsSpecLoading(false); + return; + } + + setAgentSpec(loaded); + setSpecError(null); + setIsSpecLoading(false); + } catch (error) { + if (!abortController.signal.aborted) { + onError?.(error); + setSpecError(error); + setIsSpecLoading(false); + } + } + })(); + + return () => { + abortController.abort(); + // The cancelled load can no longer clear the flag itself. Releasing it + // here keeps it from sticking when the next run early-returns on an + // already-loaded draft; a run that starts a fresh load re-raises it in + // the same commit. + setIsSpecLoading(false); + }; + }, [defaultAgentSpec, draftBridge, draftSessionId, enabled, onError]); + + const flushSpecSync = useCallback( + async (draftId: string, spec: AgentSpec, generation: number) => { + if (draftBridge == null) { + return; + } + setIsSpecSyncing(true); + try { + const updatedAt = await draftBridge.syncAgentSpec(draftId, spec); + if (generation !== syncGenerationRef.current) { + return; + } + lastUpdatedAtRef.current = updatedAt || new Date().toISOString(); + setSpecError(null); + onAgentSpecChange?.(spec); + } catch (error) { + if (generation === syncGenerationRef.current) { + setSpecError(error); + onError?.(error); + } + } finally { + if (generation === syncGenerationRef.current) { + setIsSpecSyncing(false); + } + } + }, + [draftBridge, onAgentSpecChange, onError], + ); + + const scheduleSpecSync = useCallback( + (draftId: string, spec: AgentSpec) => { + if (syncTimeoutRef.current != null) { + clearTimeout(syncTimeoutRef.current); + } + const generation = ++syncGenerationRef.current; + const flush = () => { + pendingFlushRef.current = undefined; + syncTimeoutRef.current = undefined; + const promise = flushSpecSync(draftId, spec, generation).finally(() => { + if (inFlightFlushRef.current === promise) { + inFlightFlushRef.current = undefined; + } + }); + inFlightFlushRef.current = promise; + return promise; + }; + pendingFlushRef.current = flush; + syncTimeoutRef.current = setTimeout(() => { + void flush(); + }, SPEC_SYNC_DEBOUNCE_MS); + }, + [flushSpecSync], + ); + + const scheduleSpecSyncRef = useRef(scheduleSpecSync); + scheduleSpecSyncRef.current = scheduleSpecSync; + + const flushPendingSpecSyncNow = useCallback(async () => { + if (syncTimeoutRef.current != null) { + clearTimeout(syncTimeoutRef.current); + syncTimeoutRef.current = undefined; + } + const pending = pendingFlushRef.current; + if (pending != null) { + pendingFlushRef.current = undefined; + await pending(); + return; + } + if (inFlightFlushRef.current != null) { + await inFlightFlushRef.current; + } + }, []); + + const adoptAgentSpec = useCallback( + ({ agentSpec: persistedSpec, updatedAt }: { agentSpec: AgentSpec; updatedAt?: string }) => { + if (syncTimeoutRef.current != null) { + clearTimeout(syncTimeoutRef.current); + syncTimeoutRef.current = undefined; + } + syncGenerationRef.current++; + pendingFlushRef.current = undefined; + inFlightFlushRef.current = undefined; + localDirtyRef.current = false; + // Keep the last successful sync timestamp when the caller omits + // updatedAt (e.g. a save that did not return sessionUpdatedAt). + if (updatedAt !== undefined) { + lastUpdatedAtRef.current = updatedAt; + } + agentSpecRef.current = persistedSpec; + setAgentSpec(persistedSpec); + setSpecError(null); + setIsSpecSyncing(false); + }, + [], + ); + + const takeTurnHeaderTimestamp = useCallback(async () => { + await flushPendingSpecSyncNow(); + const updatedAt = lastUpdatedAtRef.current; + lastUpdatedAtRef.current = undefined; + return updatedAt; + }, [flushPendingSpecSyncNow]); + + useEffect( + () => () => { + if (syncTimeoutRef.current != null) { + clearTimeout(syncTimeoutRef.current); + } + }, + [], + ); + + const updateAgentSpec = useCallback( + (update: AgentSpecUpdate) => { + if (draftBridge == null) { + return; + } + const next = mergeAgentSpec(agentSpecRef.current, update); + setAgentSpec(next); + localDirtyRef.current = true; + if (draftSessionId != null) { + scheduleSpecSync(draftSessionId, next); + } + }, + [draftBridge, draftSessionId, enabled, scheduleSpecSync], + ); + + return useMemo( + () => ({ + agentSpec: enabled ? agentSpec : null, + draftSessionId: enabled ? draftSessionId : undefined, + isSpecLoading: enabled ? isSpecLoading : false, + isSpecSyncing: enabled ? isSpecSyncing : false, + specError: enabled ? specError : null, + updateAgentSpec, + flushAgentSpec: flushPendingSpecSyncNow, + adoptAgentSpec, + takeTurnHeaderTimestamp, + }), + [ + agentSpec, + draftSessionId, + enabled, + isSpecLoading, + isSpecSyncing, + specError, + flushPendingSpecSyncNow, + adoptAgentSpec, + takeTurnHeaderTimestamp, + updateAgentSpec, + ], + ); +} diff --git a/packages/assistant-ui-runtime/src/extractTurnUserText.ts b/packages/assistant-ui-runtime/src/extractTurnUserText.ts new file mode 100644 index 000000000..762ee7739 --- /dev/null +++ b/packages/assistant-ui-runtime/src/extractTurnUserText.ts @@ -0,0 +1,23 @@ +import type { Turn } from './server/index.js'; + +export function extractTurnUserText(input: Turn['input']): string | undefined { + const parts: string[] = []; + let hasUserMessage = false; + for (const item of input ?? []) { + if (item.type !== 'user.message') { + continue; + } + hasUserMessage = true; + const { content } = item; + if (typeof content === 'string') { + parts.push(content); + continue; + } + for (const part of content) { + if (part.type === 'text') { + parts.push(part.text); + } + } + } + return hasUserMessage ? parts.join('\n').trim() : undefined; +} diff --git a/packages/assistant-ui-runtime/src/foldPeerThreads.ts b/packages/assistant-ui-runtime/src/foldPeerThreads.ts new file mode 100644 index 000000000..f02240fa3 --- /dev/null +++ b/packages/assistant-ui-runtime/src/foldPeerThreads.ts @@ -0,0 +1,553 @@ +import type { MessageStatus, ThreadMessage } from '@assistant-ui/core'; +import { + isEventDelta, + type ThreadCreatedEvent, + type ToolResponseRequiredEvent, + type TurnEvent, + type TurnStreamingEvent, +} from './server/index.js'; + +import { parseAskUserQuestionArgs } from './askUserQuestion.js'; +import { ROOT_THREAD_ID } from './constants.js'; +import { isCreateSubAgentToolCall } from './createSubAgent.js'; +import type { SubAgentMessageCustomMetadata } from './messageCustomMetadata.js'; +import { buildAssistantContent, type AssistantContentPart, type SdkToolCall } from './modelMessageContent.js'; +import { mergeStreamEventDelta } from './modelMessageImageContent.js'; +import { toolApprovalMessageCustom, toolApprovalStatus } from './toolApproval.js'; +import { toolResponseMessageCustom, toolResponseStatus } from './toolResponse.js'; + +export { ROOT_THREAD_ID } from './constants.js'; + +type AgentInfo = ThreadCreatedEvent['agentInfo']; + +export interface SubAgentCustomMetadata { + threadId: string; + title?: string; + name?: string; + model?: string; + input?: string; +} + +export interface SubAgentArtifact { + subAgents: { + threadId: string; + title?: string; + agentInfo?: AgentInfo; + }[]; +} + +type ToolCallRef = ToolResponseRequiredEvent['toolCalls'][number]; + +export interface PendingResponseRef { + id: string; + sourceEventId: string; + question?: string; + options?: string[]; +} + +export interface ApprovalDecisionRef { + id: string; + approved: boolean; + reason?: string; +} + +export interface ThreadBucket { + events: Map; + modelMessageIds: string[]; + toolResults: Map; + pendingApprovals: Map; + approvalDecisions: Map; + pendingResponses: Map; + done: boolean; + title?: string; + agentInfo?: AgentInfo; +} + +export interface ThreadParentLink { + parentThreadId: string; + toolCallId: string; +} + +const isDev = + typeof process !== 'undefined' && typeof process.env !== 'undefined' && process.env['NODE_ENV'] !== 'production'; + +function warnUnexpectedRootThread(threadId: string, eventType: string): void { + if (!isDev) { + return; + } + console.warn( + `[@truefoundry/assistant-ui-runtime] Expected root thread "${ROOT_THREAD_ID}" but received "${threadId}" on ${eventType}.`, + ); +} + +function assertRootThreadEvent(threadId: string, eventType: string): void { + if (threadId === ROOT_THREAD_ID) { + return; + } + if (isDev) { + throw new Error( + `[@truefoundry/assistant-ui-runtime] Root-looking event ${eventType} arrived on thread "${threadId}" instead of "${ROOT_THREAD_ID}".`, + ); + } + warnUnexpectedRootThread(threadId, eventType); +} + +export class PeerThreadFoldState { + readonly threads = new Map(); + readonly threadParents = new Map(); + + getOrCreateBucket(threadId: string): ThreadBucket { + let bucket = this.threads.get(threadId); + if (bucket == null) { + bucket = { + events: new Map(), + modelMessageIds: [], + toolResults: new Map(), + pendingApprovals: new Map(), + approvalDecisions: new Map(), + pendingResponses: new Map(), + done: false, + }; + this.threads.set(threadId, bucket); + } + return bucket; + } +} + +function isTurnScopedEvent( + message: TurnStreamingEvent, +): message is Extract { + return message.type === 'turn.created' || message.type === 'turn.done'; +} + +function ingestEventIntoBucket(bucket: ThreadBucket, message: TurnStreamingEvent): void { + if (isTurnScopedEvent(message)) { + return; + } + + if (isEventDelta(message)) { + const base = bucket.events.get(message.id); + if (base != null) { + mergeStreamEventDelta(base, message); + } + return; + } + + bucket.events.set(message.id, message); + + if (message.type === 'model.message') { + if (!bucket.modelMessageIds.includes(message.id)) { + bucket.modelMessageIds.push(message.id); + } + return; + } + + if (message.type === 'tool.response') { + bucket.toolResults.set(message.toolCallId, message.content); + bucket.pendingResponses.delete(message.toolCallId); + return; + } + + if (message.type === 'tool.approval_required') { + for (const ref of message.toolCalls) { + bucket.pendingApprovals.set(ref.id, { id: ref.id }); + } + return; + } + + if (message.type === 'tool.response_required') { + for (const ref of message.toolCalls) { + const resolved = resolveAskUserQuestionFromBucket(bucket, ref); + bucket.pendingResponses.set(ref.id, { + id: ref.id, + sourceEventId: ref.sourceEventId, + ...(resolved?.question != null ? { question: resolved.question } : {}), + ...(resolved?.options != null ? { options: resolved.options } : {}), + }); + } + return; + } + + if (message.type === 'thread.done') { + bucket.done = true; + if (message.title) { + bucket.title = message.title; + } + } +} + +function isContentAffectingEvent(message: TurnStreamingEvent): boolean { + return ( + message.type === 'thread.created' || + message.type === 'thread.done' || + message.type === 'model.message' || + message.type === 'model.message.delta' || + message.type === 'tool.response' || + message.type === 'tool.approval_required' || + message.type === 'tool.response_required' + ); +} + +export function ingestStreamEvent(state: PeerThreadFoldState, message: TurnStreamingEvent): boolean { + if (message.type === 'mcp.auth_required' || isTurnScopedEvent(message)) { + return false; + } + + if (message.threadId == null) { + return false; + } + + if (message.type === 'thread.created') { + state.threadParents.set(message.threadId, { + parentThreadId: message.parent.threadId, + toolCallId: message.parent.toolCallId, + }); + const bucket = state.getOrCreateBucket(message.threadId); + bucket.title = message.title; + bucket.agentInfo = message.agentInfo; + return true; + } + + if (message.type === 'model.message' && message.threadId === ROOT_THREAD_ID) { + assertRootThreadEvent(message.threadId, message.type); + } + + const bucket = state.getOrCreateBucket(message.threadId); + ingestEventIntoBucket(bucket, message); + + return isContentAffectingEvent(message); +} + +export function ingestTurnEvent(state: PeerThreadFoldState, event: TurnEvent): void { + if (event.threadId == null) { + return; + } + + if (event.type === 'thread.created') { + state.threadParents.set(event.threadId, { + parentThreadId: event.parent.threadId, + toolCallId: event.parent.toolCallId, + }); + const bucket = state.getOrCreateBucket(event.threadId); + bucket.title = event.title; + bucket.agentInfo = event.agentInfo; + } else if (event.type === 'model.message' && event.threadId === ROOT_THREAD_ID) { + assertRootThreadEvent(event.threadId, event.type); + } + + ingestEventIntoBucket(state.getOrCreateBucket(event.threadId), event); +} + +function resolveAskUserQuestionFromBucket( + bucket: ThreadBucket, + ref: Pick, +): { question?: string; options?: string[] } | undefined { + const modelMessage = bucket.events.get(ref.sourceEventId); + if (modelMessage?.type !== 'model.message') { + return undefined; + } + const toolCall = modelMessage.toolCalls?.find(call => call.id === ref.id); + if (toolCall == null) { + return undefined; + } + return parseAskUserQuestionArgs(toolCall.function.arguments); +} + +function findToolCallInBucket(bucket: ThreadBucket, toolCallId: string): SdkToolCall | undefined { + for (const id of bucket.modelMessageIds) { + const event = bucket.events.get(id); + if (event?.type !== 'model.message') { + continue; + } + const match = event.toolCalls?.find(toolCall => toolCall.id === toolCallId); + if (match != null) { + return match; + } + } + return undefined; +} + +function isLinkedCreateSubAgentThread(state: PeerThreadFoldState, subThreadId: string): boolean { + const link = state.threadParents.get(subThreadId); + if (link == null) { + return false; + } + const parentBucket = state.threads.get(link.parentThreadId); + if (parentBucket == null) { + return false; + } + const toolCall = findToolCallInBucket(parentBucket, link.toolCallId); + return toolCall != null && isCreateSubAgentToolCall(toolCall); +} + +function childSubThreadIds(state: PeerThreadFoldState, parentThreadId: string, toolCallId: string): string[] { + const ids: string[] = []; + for (const [subThreadId, link] of state.threadParents) { + if ( + link.parentThreadId === parentThreadId && + link.toolCallId === toolCallId && + isLinkedCreateSubAgentThread(state, subThreadId) + ) { + ids.push(subThreadId); + } + } + return ids; +} + +function bucketHasUnresolvedPendingResponses(bucket: ThreadBucket): boolean { + for (const id of bucket.pendingResponses.keys()) { + if (!bucket.toolResults.has(id)) { + return true; + } + } + return false; +} + +function bucketAssistantStatus(bucket: ThreadBucket): MessageStatus { + if (bucket.pendingApprovals.size > 0) { + return toolApprovalStatus(); + } + if (bucketHasUnresolvedPendingResponses(bucket)) { + return toolResponseStatus(); + } + if (!bucket.done && bucket.modelMessageIds.length > 0) { + return { type: 'running' }; + } + return { type: 'complete', reason: 'stop' }; +} + +function buildSubAgentCustomMetadata(threadId: string, bucket: ThreadBucket): SubAgentMessageCustomMetadata { + const metadata: SubAgentCustomMetadata = { + threadId, + ...(bucket.title != null ? { title: bucket.title } : {}), + ...(bucket.agentInfo?.name != null ? { name: bucket.agentInfo.name } : {}), + ...(bucket.agentInfo?.model != null ? { model: bucket.agentInfo.model } : {}), + ...(bucket.agentInfo?.input != null ? { input: bucket.agentInfo.input } : {}), + }; + return { subAgent: metadata }; +} + +function attachSubAgentMessages( + state: PeerThreadFoldState, + parentThreadId: string, + parts: AssistantContentPart[], +): AssistantContentPart[] { + const parentBucket = state.threads.get(parentThreadId); + + return parts.map(part => { + if (part.type !== 'tool-call' || parentBucket == null) { + return part; + } + + const sdkToolCall = findToolCallInBucket(parentBucket, part.toolCallId); + if (sdkToolCall == null || !isCreateSubAgentToolCall(sdkToolCall)) { + return part; + } + + const childIds = childSubThreadIds(state, parentThreadId, part.toolCallId); + if (childIds.length === 0) { + return part; + } + + const messages: ThreadMessage[] = []; + const subAgents: SubAgentArtifact['subAgents'] = []; + for (const childId of childIds) { + const childMessages = buildSubThreadMessages(state, childId); + if (childMessages.length > 0) { + messages.push(...childMessages); + } + const childBucket = state.threads.get(childId); + if (childBucket != null) { + subAgents.push({ + threadId: childId, + ...(childBucket.title != null ? { title: childBucket.title } : {}), + ...(childBucket.agentInfo != null ? { agentInfo: childBucket.agentInfo } : {}), + }); + } + } + if (messages.length === 0 && subAgents.length === 0) { + return part; + } + + const artifact: SubAgentArtifact = { subAgents }; + // thread.created (title, agentInfo) arrives before the child's first model.message. + // Attach artifact-only so UI can render the sub-agent header immediately; see README + // SubAgentArtifact / MessagePartPrimitive.Messages in agent-ui ToolCallContainer. + if (messages.length === 0) { + return { ...part, artifact }; + } + return { ...part, messages, artifact }; + }); +} + +function buildThreadAssistantParts( + state: PeerThreadFoldState, + threadId: string, + modelMessageIds?: readonly string[], +): AssistantContentPart[] { + const bucket = state.threads.get(threadId); + if (bucket == null) { + return []; + } + + const ids = threadId === ROOT_THREAD_ID && modelMessageIds != null ? modelMessageIds : bucket.modelMessageIds; + + const parts: AssistantContentPart[] = []; + // A single tool call can appear in more than one `model.message` event (e.g. + // once in the paused turn and again in the resumed turn after a tool + // approval). assistant-ui keys tool parts by `toolCallId` within a message, + // so emitting the same id twice crashes the render. Collapse duplicates, + // letting the latest occurrence win while preserving the original position. + const toolCallIndexById = new Map(); + for (const id of ids) { + const event = bucket.events.get(id); + if (event?.type !== 'model.message') { + continue; + } + for (const part of buildAssistantContent(event, { + toolResults: bucket.toolResults, + pendingApprovals: bucket.pendingApprovals, + approvalDecisions: bucket.approvalDecisions, + pendingResponses: bucket.pendingResponses, + })) { + if (part.type === 'tool-call') { + const existingIndex = toolCallIndexById.get(part.toolCallId); + if (existingIndex != null) { + parts[existingIndex] = part; + continue; + } + toolCallIndexById.set(part.toolCallId, parts.length); + } + parts.push(part); + } + } + + return attachSubAgentMessages(state, threadId, parts); +} + +function buildSubThreadMessages(state: PeerThreadFoldState, threadId: string): ThreadMessage[] { + const bucket = state.threads.get(threadId); + if (bucket == null) { + return []; + } + + const content = buildThreadAssistantParts(state, threadId); + if (content.length === 0) { + return []; + } + + const custom = { + ...buildSubAgentCustomMetadata(threadId, bucket), + ...(bucket.pendingApprovals.size > 0 ? toolApprovalMessageCustom(threadId) : {}), + ...(bucketHasUnresolvedPendingResponses(bucket) ? toolResponseMessageCustom(threadId) : {}), + }; + + return [ + { + id: `${threadId}-assistant`, + role: 'assistant', + content, + status: bucketAssistantStatus(bucket), + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom, + }, + }, + ]; +} + +export function buildRootAssistantContent(state: PeerThreadFoldState): AssistantContentPart[] { + return buildThreadAssistantParts(state, ROOT_THREAD_ID); +} + +export function buildRootAssistantContentForIds( + state: PeerThreadFoldState, + modelMessageIds: readonly string[], +): AssistantContentPart[] { + return buildThreadAssistantParts(state, ROOT_THREAD_ID, modelMessageIds); +} + +export function findFirstPendingApprovalThreadId(state: PeerThreadFoldState): string | undefined { + for (const [threadId, bucket] of state.threads) { + if (bucket.pendingApprovals.size > 0) { + return threadId; + } + } + return undefined; +} + +export function findFirstPendingResponseThreadId(state: PeerThreadFoldState): string | undefined { + for (const [threadId, bucket] of state.threads) { + if (bucketHasUnresolvedPendingResponses(bucket)) { + return threadId; + } + } + return undefined; +} + +export function resolveAskUserQuestion( + state: PeerThreadFoldState, + threadId: string, + ref: Pick, +): { question?: string; options?: string[] } | undefined { + const bucket = state.threads.get(threadId); + if (bucket == null) { + return undefined; + } + return resolveAskUserQuestionFromBucket(bucket, ref); +} + +export function isRootThreadId(threadId: string | undefined): boolean { + return threadId === ROOT_THREAD_ID; +} + +export function recordToolApprovalInFold( + fold: PeerThreadFoldState, + decision: { toolCallId: string; approved: boolean; reason?: string }, +): void { + const record: ApprovalDecisionRef = { + id: decision.toolCallId, + approved: decision.approved, + ...(decision.reason != null ? { reason: decision.reason } : {}), + }; + let applied = false; + for (const bucket of fold.threads.values()) { + if (!bucket.pendingApprovals.has(decision.toolCallId)) { + continue; + } + bucket.pendingApprovals.delete(decision.toolCallId); + bucket.approvalDecisions.set(decision.toolCallId, record); + applied = true; + } + if (applied) { + return; + } + const rootBucket = fold.getOrCreateBucket(ROOT_THREAD_ID); + rootBucket.approvalDecisions.set(decision.toolCallId, record); +} + +/** Persist a user.tool_response answer into fold state (clears pending ask-user). */ +export function recordToolResponseInFold( + fold: PeerThreadFoldState, + response: { toolCallId: string; content: string }, +): void { + let applied = false; + for (const bucket of fold.threads.values()) { + if (!bucket.pendingResponses.has(response.toolCallId)) { + continue; + } + bucket.toolResults.set(response.toolCallId, response.content); + bucket.pendingResponses.delete(response.toolCallId); + applied = true; + } + if (applied) { + return; + } + const rootBucket = fold.getOrCreateBucket(ROOT_THREAD_ID); + rootBucket.toolResults.set(response.toolCallId, response.content); + rootBucket.pendingResponses.delete(response.toolCallId); +} diff --git a/packages/assistant-ui-runtime/src/hooks.ts b/packages/assistant-ui-runtime/src/hooks.ts new file mode 100644 index 000000000..65241461f --- /dev/null +++ b/packages/assistant-ui-runtime/src/hooks.ts @@ -0,0 +1,176 @@ +'use client'; + +import { useAui, useAuiState } from '@assistant-ui/store'; +import { useMemo } from 'react'; + +import type { RespondToToolApprovalOptions } from './toolApproval.js'; +import type { RespondToToolResponseOptions } from './toolResponse.js'; +import { + EMPTY_DRAFT_EXTRAS, + getTrueForgeExtras, + useTrueForgeRuntimeExtras, + type TrueForgeDraftRuntimeExtras, +} from './trueforgeExtras.js'; + +/** Pending tool approvals plus a respond action. */ +export const useTrueForgeApprovals = () => { + const extras = useTrueForgeRuntimeExtras(); + + return useMemo( + () => ({ + pending: extras?.pendingApprovals ?? [], + respond: + extras?.respondToToolApproval ?? + (() => { + throw new Error('TrueForge runtime is not ready yet'); + }), + }), + [extras], + ); +}; + +/** Pending ask-user tool responses plus a respond action. */ +export const useTrueForgeToolResponses = () => { + const extras = useTrueForgeRuntimeExtras(); + + return useMemo( + () => ({ + pending: extras?.pendingToolResponses ?? [], + respond: + extras?.respondToToolResponse ?? + (() => { + throw new Error('TrueForge runtime is not ready yet'); + }), + }), + [extras], + ); +}; + +/** Pending MCP OAuth plus a resume action. */ +export const useTrueForgeMcpAuth = () => { + const extras = useTrueForgeRuntimeExtras(); + + return useMemo( + () => ({ + pending: extras?.pendingMcpAuth ?? null, + resume: extras?.resumeMcpAuth ?? (() => Promise.reject(new Error('TrueForge runtime is not ready yet'))), + }), + [extras], + ); +}; + +/** Returns a function to respond to a tool approval from any render context. */ +export const useTrueForgeRespondToToolApproval = () => { + const aui = useAui(); + return (response: RespondToToolApprovalOptions) => { + getTrueForgeExtras(aui).respondToToolApproval(response); + }; +}; + +/** Returns a function to respond to a pending tool response from any render context. */ +export const useTrueForgeRespondToToolResponse = () => { + const aui = useAui(); + return (response: RespondToToolResponseOptions) => { + getTrueForgeExtras(aui).respondToToolResponse(response); + }; +}; + +/** Returns a function to resume after MCP OAuth from any render context. */ +export const useTrueForgeResumeMcpAuth = () => { + const aui = useAui(); + return () => getTrueForgeExtras(aui).resumeMcpAuth(); +}; + +/** Current sandboxId for this session, if a sandbox has been created. */ +export const useTrueForgeSandboxId = (): string | undefined => useTrueForgeRuntimeExtras()?.sandboxId; + +/** Turn that produced the message being rendered. Only defined inside a message scope. */ +export const useTrueForgeTurnId = (): string | undefined => + useAuiState(state => { + const turnId = state.message.metadata.custom['turnId']; + return typeof turnId === 'string' ? turnId : undefined; + }); + +/** + * Returns a function to download a file the current turn wrote to its sandbox. Must be called + * from a message scope, since the artifact belongs to the turn that rendered it. + * Does not require extras `sandboxId` — turn-scoped hosts resolve the sandbox from `turnId`. + */ +export const useTrueForgeDownloadSandboxFile = () => { + const aui = useAui(); + const turnId = useTrueForgeTurnId(); + return (path: string) => { + if (turnId == null) { + throw new Error('Downloading a sandbox file requires a message scope to resolve its turn.'); + } + return getTrueForgeExtras(aui).downloadSandboxFile({ turnId, path }); + }; +}; + +/** Returns a function to cancel the current run from any render context. */ +export const useTrueForgeCancel = () => { + const aui = useAui(); + return () => getTrueForgeExtras(aui).cancel(); +}; + +/** Returns a function to reload (retry) the current session from any render context. */ +export const useTrueForgeReload = () => { + const aui = useAui(); + return () => { + getTrueForgeExtras(aui).reload(); + }; +}; + +/** Older history pagination state plus a load-more action for scroll-up. */ +export const useTrueForgeHistoryPagination = () => { + const extras = useTrueForgeRuntimeExtras(); + + return useMemo( + () => ({ + hasOlderHistory: extras?.hasOlderHistory ?? false, + isLoadingOlderHistory: extras?.isLoadingOlderHistory ?? false, + loadOlderHistory: + extras?.loadOlderHistory ?? (() => Promise.reject(new Error('TrueForge runtime is not ready yet'))), + }), + [extras], + ); +}; + +/** + * True while a turn runs that this server cannot stream, so its result will not + * arrive in this client. Falls back to `false` on runtimes that predate the flag. + */ +export const useTrueForgeResumeUnavailable = () => useTrueForgeRuntimeExtras()?.resumeUnavailable ?? false; + +/** Returns a function to reset (re-submit) a user turn from any render context. */ +export const useTrueForgeResetFromTurn = () => { + const aui = useAui(); + return (turnId: string) => getTrueForgeExtras(aui).resetFromTurn(turnId); +}; + +/** Current draft agent spec and sync state (draft mode only). */ +export const useTrueForgeAgentSpec = () => { + const extras = useTrueForgeRuntimeExtras()?.draft ?? null; + + return useMemo(() => ({ ...EMPTY_DRAFT_EXTRAS, ...extras }), [extras]); +}; + +/** Returns a draft spec updater from any render context. */ +export const useTrueForgeUpdateAgentSpec = () => { + const aui = useAui(); + return (update: Parameters[0]) => + getTrueForgeExtras(aui).draft?.updateAgentSpec(update); +}; + +/** Flushes any pending draft-spec synchronization before a coordinated write. */ +export const useTrueForgeFlushAgentSpec = () => { + const aui = useAui(); + return () => getTrueForgeExtras(aui).draft?.flushAgentSpec() ?? Promise.resolve(); +}; + +/** Adopts a spec already persisted by another server operation without syncing again. */ +export const useTrueForgeAdoptAgentSpec = () => { + const aui = useAui(); + return (request: Parameters[0]) => + getTrueForgeExtras(aui).draft?.adoptAgentSpec(request); +}; diff --git a/packages/assistant-ui-runtime/src/index.ts b/packages/assistant-ui-runtime/src/index.ts new file mode 100644 index 000000000..383f49f38 --- /dev/null +++ b/packages/assistant-ui-runtime/src/index.ts @@ -0,0 +1,218 @@ +/// + +export { trueForgeAttachmentAdapter } from './attachmentAdapter.js'; +export type { PendingApproval, PendingToolResponse } from './collectPending.js'; +export { ROOT_THREAD_ID } from './constants.js'; +export { + buildEditedUserMessageContent, + buildTurnAssistantContent, + buildUserMessageContent, + convertTurnsToThreadMessages, + getTurnMessageContent, + parseTurnIdFromMessageId, + repositoryItemsFromMessages, +} from './convertTurnMessages.js'; +export type { ConvertTurnsResult, UserMessageContent } from './convertTurnMessages.js'; +export { draftSessionTitle, mergeAgentSpec } from './draft/agentSpec.js'; +export type { AgentSpecUpdate, DraftSession } from './draft/agentSpec.js'; +export { createDraftSessionBridge } from './draft/draftSessionBridge.js'; +export type { DraftSessionBridge } from './draft/draftSessionBridge.js'; +export { createTrueForgeDraftThreadListAdapter } from './draft/trueforgeDraftThreadListAdapter.js'; +export type { SubAgentArtifact, SubAgentCustomMetadata } from './foldPeerThreads.js'; +export { + useTrueForgeAdoptAgentSpec, + useTrueForgeAgentSpec, + useTrueForgeApprovals, + useTrueForgeCancel, + useTrueForgeDownloadSandboxFile, + useTrueForgeFlushAgentSpec, + useTrueForgeHistoryPagination, + useTrueForgeMcpAuth, + useTrueForgeReload, + useTrueForgeResetFromTurn, + useTrueForgeRespondToToolApproval, + useTrueForgeRespondToToolResponse, + useTrueForgeResumeMcpAuth, + useTrueForgeResumeUnavailable, + useTrueForgeSandboxId, + useTrueForgeToolResponses, + useTrueForgeTurnId, + useTrueForgeUpdateAgentSpec, +} from './hooks.js'; +export type { + McpAuthMessageCustomMetadata, + SandboxMessageCustomMetadata, + SubAgentMessageCustomMetadata, + ToolApprovalMessageCustomMetadata, + ToolResponseMessageCustomMetadata, + TrueForgeMessageCustomMetadata, +} from './messageCustomMetadata.js'; +export { + collectRequiredActionInputs, + findPausedAssistantMessage, + messageHasPendingRequiredActions, +} from './requiredActionInputs.js'; +export { getSession } from './sessions.js'; +export { collectApprovalInputs, messageHasPendingApprovals, toTrueForgeApprovalInputs } from './toolApproval.js'; +export { + TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY, + collectResponseInputs, + messageHasPendingResponses, +} from './toolResponse.js'; +export { getTrueForgeExtras, trueForgeExtras, tryGetTrueForgeExtras } from './trueforgeExtras.js'; +export type { TrueForgeDraftRuntimeExtras, TrueForgeRuntimeExtras } from './trueforgeExtras.js'; +export { createTrueForgeOwnedSessionsThreadListAdapter } from './trueforgeOwnedSessionsThreadListAdapter.js'; +export { createTrueForgeThreadListAdapter } from './trueforgeThreadListAdapter.js'; +export type { + DraftAgentConfig, + NamedAgentConfig, + TrueForgeAgentConfig, + UseTrueForgeAgentRuntimeOptions, +} from './types.js'; +export { useTrueForgeAgentRuntime } from './useTrueForgeAgentRuntime.js'; + +export { isEventDelta, mergeEventDelta } from './server/index.js'; +export type { + AgentBuilderCapabilitiesResponse, + AgentBuilderServer, + AgentCapabilityConfig, + AgentChatServer, + AgentCompactionConfig, + AgentContextManagementConfig, + AgentDetail, + AgentInputTokensCompactionTrigger, + AgentLibraryEntry, + AgentMetricChartData, + AgentMetricChartDataRequest, + AgentMetricChartDefinition, + AgentMetricChartType, + AgentMetricGraph, + AgentMetricGraphLine, + AgentMetricMeter, + AgentMetricPoint, + AgentMetricRangeRequest, + AgentMetricsServer, + AgentRuntimeConfig, + AgentSandboxConfig, + AgentSelectorEntry, + AgentSessionsServer, + AgentSkill, + AgentSpec, + AgentUIServer, + AgentUIServerPort, + ApprovalDecision, + AuthenticateConnectorRequest, + CatalogServer, + CodeSnippet, + CodeSnippetSampleCode, + ConnectorAuth, + ConnectorAuthApiKey, + ConnectorAuthNone, + ConnectorAuthOAuth, + ConnectorAuthPublic, + ConnectorAuthPublicApiKey, + ConnectorAuthPublicNone, + ConnectorAuthPublicOAuth, + ConnectorAuthType, + ConnectorAuthenticationResult, + ConnectorBase, + ConnectorCatalogEntry, + ConnectorCatalogServer, + ConnectorConfigBase, + ConnectorSelectorEntry, + ConnectorState, + CreateConnectorRequest, + CreateModelProviderRequest, + CreateSandboxProviderRequest, + CreateSandboxRequest, + CreateScheduleRequest, + CreateScheduleRunRequest, + CreateSessionRequest, + CreateSkillRequest, + CreateSkillRequestBase, + CreatedBySubject, + DefinedSkill, + GithubSkill, + ImportGithubSkillRequest, + ListPermissionsRequest, + ListPermissionsResponse, + ListResult, + ListSchedulesParams, + ListSessionEventsParams, + ListSessionsOrder, + ListSessionsParams, + McpAuthRequiredEvent, + McpServerMount, + McpToolSelection, + Model, + ModelCatalogServer, + ModelEntry, + ModelMessageEvent, + ModelParams, + ModelProperties, + ModelProviderBase, + ModelProviderCatalogEntry, + ModelProviderConfigBase, + ModelSelection, + ModelSelectorEntry, + PageParams, + PermissionResourceType, + PermissionsServer, + PreviousTurnIdInput, + ProviderEntry, + ProviderType, + RegistrySkill, + ResourcePermission, + SandboxBase, + SandboxCatalogEntry, + SandboxCatalogServer, + SandboxConfig, + SandboxCreatedEvent, + SandboxProviderBase, + SandboxProviderCatalogEntry, + SandboxProviderConfig, + SandboxProviderListEntry, + SandboxSnapshotSyncStatus, + SaveAgentRequest, + SaveAgentResult, + Schedule, + ScheduleRun, + ScheduleRunStatus, + ScheduleServer, + ScheduleStatus, + SearchAgentSelectorParams, + SearchAgentsParams, + SelectRegistrySkillRequest, + Session, + SessionEventItem, + SessionListEntry, + SessionListMetrics, + SkillBase, + SkillCatalogEntry, + SkillCatalogServer, + SkillConfigBase, + SkillMount, + SkillSelectorEntry, + ThreadCreatedEvent, + ToolApprovalRequiredEvent, + ToolBase, + ToolCall, + ToolResponseRequiredEvent, + Turn, + TurnDoneMetrics, + TurnEvent, + TurnInputItem, + TurnState, + TurnStateDone, + TurnStreamData, + TurnStreamingEvent, + UpdateConnectorRequest, + UpdateModelProviderRequest, + UpdateSandboxProviderRequest, + UpdateSandboxRequest, + UpdateScheduleRequest, + UpdateSessionRequest, + UserMessage, + UserToolApprovalEvent, + UserToolResponseEvent, +} from './server/index.js'; diff --git a/packages/assistant-ui-runtime/src/lastUserMessageText.ts b/packages/assistant-ui-runtime/src/lastUserMessageText.ts new file mode 100644 index 000000000..aca03345f --- /dev/null +++ b/packages/assistant-ui-runtime/src/lastUserMessageText.ts @@ -0,0 +1,19 @@ +import type { ThreadMessage } from '@assistant-ui/core'; + +export function lastUserMessageText(messages: readonly ThreadMessage[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.role !== 'user') { + continue; + } + const text = message.content + .filter(part => part.type === 'text') + .map(part => part.text) + .join('\n') + .trim(); + if (text) { + return text; + } + } + throw new Error('No user message with text content found.'); +} diff --git a/packages/assistant-ui-runtime/src/listPages.ts b/packages/assistant-ui-runtime/src/listPages.ts new file mode 100644 index 000000000..22d2c5ebb --- /dev/null +++ b/packages/assistant-ui-runtime/src/listPages.ts @@ -0,0 +1,19 @@ +import type { ListResult } from './server/types.js'; + +/** + * Drains all pages of a token-paginated list into a single array. + * Newest-first APIs should reverse after calling this. + */ +export async function drainListPages(fetchPage: (pageToken?: string) => Promise>): Promise { + const items: T[] = []; + let pageToken: string | undefined; + for (;;) { + const page = await fetchPage(pageToken); + items.push(...page.data); + if (page.nextPageToken == null || page.nextPageToken === '') { + break; + } + pageToken = page.nextPageToken; + } + return items; +} diff --git a/packages/assistant-ui-runtime/src/loadSessionSnapshot.ts b/packages/assistant-ui-runtime/src/loadSessionSnapshot.ts new file mode 100644 index 000000000..80a8dcac8 --- /dev/null +++ b/packages/assistant-ui-runtime/src/loadSessionSnapshot.ts @@ -0,0 +1,34 @@ +import type { AgentChatServer } from './server/types.js'; + +import { buildSnapshotFromSessionEvents } from './convertTurnMessages.js'; +import { getSession } from './sessions.js'; +import type { SessionSnapshot } from './sessionSnapshot.js'; + +const inflightBySessionId = new Map>(); + +/** + * Loads a session snapshot once per concurrent burst for a given session id. + * React Strict Mode and overlapping fetch/load paths share the same in-flight + * request instead of duplicating getSession + listEvents calls. + * + * `onProgress` is called after each complete turn is ingested so the caller + * can update the UI progressively while history is being processed. + */ +export function loadSessionSnapshot( + server: AgentChatServer, + sessionId: string, + onProgress?: (snap: SessionSnapshot) => void, +): Promise { + let inflight = inflightBySessionId.get(sessionId); + if (inflight == null) { + inflight = getSession(server, sessionId) + .then(() => buildSnapshotFromSessionEvents(server, sessionId, onProgress)) + .finally(() => { + if (inflightBySessionId.get(sessionId) === inflight) { + inflightBySessionId.delete(sessionId); + } + }); + inflightBySessionId.set(sessionId, inflight); + } + return inflight; +} diff --git a/packages/assistant-ui-runtime/src/mcpAuth.ts b/packages/assistant-ui-runtime/src/mcpAuth.ts new file mode 100644 index 000000000..260374647 --- /dev/null +++ b/packages/assistant-ui-runtime/src/mcpAuth.ts @@ -0,0 +1,35 @@ +import type { MessageStatus } from '@assistant-ui/core'; +import type { McpAuthRequiredEvent, TurnStateDone } from './server/index.js'; + +import type { McpAuthMessageCustomMetadata } from './messageCustomMetadata.js'; +import type { AssistantContentPart } from './modelMessageContent.js'; + +/** `runConfig.custom` flag: resume after MCP OAuth with an empty SDK turn input. */ +export const MCP_AUTH_RESUME_RUN_CUSTOM_KEY = 'resumeMcpAuth'; + +type McpServerAuthInfo = McpAuthRequiredEvent['mcpServers'][number]; + +export function buildMcpAuthTextParts(servers?: readonly McpServerAuthInfo[]): AssistantContentPart[] { + void servers; + const text = [ + 'This agent needs access to external services before it can continue.', + '', + 'Click the **Connect** button(s) to authorize the Connectors, then press **Continue**.', + ].join('\n'); + return [{ type: 'text', text }]; +} + +export function findMcpAuthRequired( + requiredActions: TurnStateDone['requiredActions'] | undefined, +): McpAuthRequiredEvent | undefined { + const found = requiredActions?.find(action => action.type === 'mcp.auth_required'); + return found?.type === 'mcp.auth_required' ? found : undefined; +} + +export function mcpAuthAssistantStatus(): MessageStatus { + return { type: 'requires-action', reason: 'interrupt' }; +} + +export function mcpAuthMessageCustom(servers: readonly McpServerAuthInfo[]): McpAuthMessageCustomMetadata { + return { pendingMcpAuth: true, mcpServers: [...servers] }; +} diff --git a/packages/assistant-ui-runtime/src/messageCustomMetadata.ts b/packages/assistant-ui-runtime/src/messageCustomMetadata.ts new file mode 100644 index 000000000..d31dcd2c9 --- /dev/null +++ b/packages/assistant-ui-runtime/src/messageCustomMetadata.ts @@ -0,0 +1,50 @@ +import type { McpAuthRequiredEvent } from './server/index.js'; + +import type { SubAgentCustomMetadata } from './foldPeerThreads.js'; +import { TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY } from './toolApproval.js'; +import { TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY } from './toolResponse.js'; + +/** Keys written to `ThreadMessage.metadata.custom` by this runtime adapter. */ +export interface TrueForgeMessageCustomMetadata { + subAgent?: SubAgentCustomMetadata; + pendingMcpAuth?: true; + mcpServers?: McpAuthRequiredEvent['mcpServers']; + sandboxId?: string; + /** Turn that produced this message. Scopes artifact downloads to their own turn. */ + turnId?: string; + [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]?: string; + [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]?: string; +} + +export type SubAgentMessageCustomMetadata = Pick; + +export type McpAuthMessageCustomMetadata = Pick; + +export type SandboxMessageCustomMetadata = Pick; + +export type ToolApprovalMessageCustomMetadata = Pick< + TrueForgeMessageCustomMetadata, + typeof TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY +>; + +export type ToolResponseMessageCustomMetadata = Pick< + TrueForgeMessageCustomMetadata, + typeof TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY +>; + +export function isUnknownRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +export function isMcpServerAuthInfoList(value: unknown): value is McpAuthRequiredEvent['mcpServers'] { + return ( + Array.isArray(value) && + value.every( + server => + isUnknownRecord(server) && + typeof server['id'] === 'string' && + typeof server['name'] === 'string' && + typeof server['authUrl'] === 'string', + ) + ); +} diff --git a/packages/assistant-ui-runtime/src/modelMessageContent.ts b/packages/assistant-ui-runtime/src/modelMessageContent.ts new file mode 100644 index 000000000..48c9fed0d --- /dev/null +++ b/packages/assistant-ui-runtime/src/modelMessageContent.ts @@ -0,0 +1,149 @@ +import type { ThreadAssistantMessagePart } from '@assistant-ui/core'; +import type { PendingResponseRef } from './foldPeerThreads.js'; +import { extractImagePartsFromModelMessage } from './modelMessageImageContent.js'; +import type { ModelMessageEvent } from './server/index.js'; + +export type AssistantContentPart = ThreadAssistantMessagePart; + +export interface ToolCallContext { + toolResults?: ReadonlyMap; + pendingApprovals?: ReadonlyMap; + approvalDecisions?: ReadonlyMap; + pendingResponses?: ReadonlyMap; +} + +export type SdkToolCall = NonNullable[number]; + +type ToolCallPart = Extract; +type ToolCallArgValue = ToolCallPart['args'][string]; + +function isToolCallArgValue(value: unknown): value is ToolCallArgValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return true; + } + if (typeof value === 'number') { + return Number.isFinite(value); + } + if (Array.isArray(value)) { + return value.every(isToolCallArgValue); + } + if (value != null && typeof value === 'object') { + return Object.keys(value).every(key => isToolCallArgValue(Reflect.get(value, key))); + } + return false; +} + +function parseToolArgs(argsText: string): ToolCallPart['args'] { + if (!argsText) { + return {}; + } + try { + const parsed: unknown = JSON.parse(argsText); + if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) { + const entries: [string, ToolCallArgValue][] = []; + for (const key of Object.keys(parsed)) { + const value: unknown = Reflect.get(parsed, key); + if (isToolCallArgValue(value)) { + entries.push([key, value]); + } + } + return Object.fromEntries(entries); + } + return {}; + } catch { + return {}; + } +} + +function toolCallToPart(toolCall: SdkToolCall, context?: ToolCallContext): ToolCallPart { + const argsText = toolCall.function.arguments; + const toolResult = context?.toolResults?.get(toolCall.id); + const pendingResponse = context?.pendingResponses?.get(toolCall.id); + const pendingApproval = context?.pendingApprovals?.get(toolCall.id); + const approvalDecision = context?.approvalDecisions?.get(toolCall.id); + + let interrupt: ToolCallPart['interrupt']; + if (pendingResponse != null && toolResult === undefined) { + interrupt = { + type: 'human', + payload: { + ...(pendingResponse.question != null ? { question: pendingResponse.question } : {}), + ...(pendingResponse.options != null ? { options: pendingResponse.options } : {}), + }, + }; + } + + let approval: ToolCallPart['approval']; + if (approvalDecision != null) { + approval = { + id: approvalDecision.id, + approved: approvalDecision.approved, + ...(approvalDecision.reason != null ? { reason: approvalDecision.reason } : {}), + }; + } else if (pendingApproval != null) { + approval = { id: pendingApproval.id }; + } + + let result: ToolCallPart['result']; + let isError = false; + if (toolResult !== undefined) { + result = toolResult; + } else if (approvalDecision?.approved === false) { + result = { + error: + approvalDecision.reason == null || approvalDecision.reason.length === 0 + ? 'Tool approval denied' + : approvalDecision.reason, + }; + isError = true; + } + + return { + type: 'tool-call', + toolCallId: toolCall.id, + toolName: toolCall.function.name, + argsText, + args: parseToolArgs(argsText), + ...(result !== undefined ? { result } : {}), + ...(isError ? { isError: true } : {}), + ...(approval != null ? { approval } : {}), + ...(interrupt != null ? { interrupt } : {}), + }; +} + +function extractText(message: ModelMessageEvent): string { + const { content, refusal } = message; + if (content == null) { + return refusal ?? ''; + } + if (typeof content === 'string') { + return content; + } + return content + .map(part => { + if (part.type === 'text') { + return part.text; + } + if (part.type === 'refusal') { + return part.refusal; + } + return ''; + }) + .join(''); +} + +export function buildAssistantContent(message: ModelMessageEvent, context?: ToolCallContext): AssistantContentPart[] { + const parts: AssistantContentPart[] = []; + if (message.reasoningContent) { + parts.push({ type: 'reasoning', text: message.reasoningContent }); + } + const text = extractText(message); + if (text) { + parts.push({ type: 'text', text }); + } + parts.push(...extractImagePartsFromModelMessage(message)); + for (const toolCall of message.toolCalls ?? []) { + parts.push(toolCallToPart(toolCall, context)); + } + return parts; +} diff --git a/packages/assistant-ui-runtime/src/modelMessageImageContent.ts b/packages/assistant-ui-runtime/src/modelMessageImageContent.ts new file mode 100644 index 000000000..41eb4a29c --- /dev/null +++ b/packages/assistant-ui-runtime/src/modelMessageImageContent.ts @@ -0,0 +1,154 @@ +import type { CompleteAttachment } from '@assistant-ui/core'; +import { + isEventDelta, + mergeEventDelta, + type ModelMessageDeltaEvent, + type ModelMessageEvent, + type TurnEvent, + type TurnStreamingEvent, +} from './server/index.js'; + +import type { AssistantContentPart } from './modelMessageContent.js'; + +export interface ImageUrlContentPart { + type: 'image_url'; + image_url: { url: string }; +} + +type ModelMessageContentPart = + { type: 'text'; text: string } | { type: 'refusal'; refusal: string } | ImageUrlContentPart; + +type ContentBlockDelta = NonNullable[number]; + +function parseDataUriMime(data: string): string { + if (!data.startsWith('data:')) { + return 'image/png'; + } + const match = /^data:([^;,]+)/.exec(data); + return match?.[1] ?? 'image/png'; +} + +function imageFilenameFromUrl(url: string, index: number): string { + const mimeType = parseDataUriMime(url); + const ext = mimeType.split('/')[1] ?? 'png'; + return `image-${String(index + 1)}.${ext}`; +} + +export function isImageUrlContentPart(part: unknown): part is ImageUrlContentPart { + if (!isUnknownRecord(part) || part['type'] !== 'image_url' || !isUnknownRecord(part['image_url'])) { + return false; + } + return typeof part['image_url']['url'] === 'string'; +} + +function isUnknownRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeModelMessageContent(message: ModelMessageEvent): ModelMessageContentPart[] { + const { content } = message; + if (content == null) { + return []; + } + if (typeof content === 'string') { + return content.length > 0 ? [{ type: 'text', text: content }] : []; + } + return content; +} + +function mergeContentBlockDeltas(message: ModelMessageEvent, blocks: readonly ContentBlockDelta[]): void { + const content = normalizeModelMessageContent(message); + message.content = content; + + for (const block of blocks) { + const index = block.index; + while (content.length <= index) { + content.push({ type: 'text', text: '' }); + } + + const delta = block.delta; + if (delta.type === 'text') { + const existing = content[index]; + if (existing?.type === 'text') { + existing.text += delta.text ?? ''; + } else { + content[index] = { type: 'text', text: delta.text ?? '' }; + } + continue; + } + + const chunk = delta.image_url?.url ?? ''; + const existing = content[index]; + if (isImageUrlContentPart(existing)) { + existing.image_url.url += chunk; + } else { + content[index] = { type: 'image_url', image_url: { url: chunk } }; + } + } +} + +export function mergeStreamEventDelta(base: TurnEvent, delta: TurnStreamingEvent): void { + if (!isEventDelta(delta)) { + return; + } + + mergeEventDelta(base, delta); + + if (base.type !== 'model.message') { + return; + } + + const blocks = delta.contentBlocks ?? delta.content_blocks; + if (blocks == null || blocks.length === 0) { + return; + } + + mergeContentBlockDeltas(base, blocks); +} + +export function imageUrlToAttachment(url: string, attachmentId: string): CompleteAttachment { + const mimeType = parseDataUriMime(url); + return { + id: attachmentId, + type: 'image', + name: imageFilenameFromUrl(url, 0), + contentType: mimeType, + status: { type: 'complete' }, + content: [{ type: 'image', image: url, filename: imageFilenameFromUrl(url, 0) }], + }; +} + +export function imagePartToAssistantImage(url: string, index: number): AssistantContentPart { + return { + type: 'image', + image: url, + filename: imageFilenameFromUrl(url, index), + }; +} + +export function extractImagePartsFromModelMessage(message: ModelMessageEvent): AssistantContentPart[] { + const parts: AssistantContentPart[] = []; + let imageIndex = 0; + + for (const part of normalizeModelMessageContent(message)) { + if (!isImageUrlContentPart(part)) { + continue; + } + const url = part.image_url.url.trim(); + if (url.length === 0) { + continue; + } + parts.push(imagePartToAssistantImage(url, imageIndex)); + imageIndex += 1; + } + + return parts; +} + +export function extractImageUrlFromUserContentItem(part: unknown): string | undefined { + if (isImageUrlContentPart(part)) { + const url = part.image_url.url.trim(); + return url.length > 0 ? url : undefined; + } + return undefined; +} diff --git a/packages/assistant-ui-runtime/src/requiredActionInputs.ts b/packages/assistant-ui-runtime/src/requiredActionInputs.ts new file mode 100644 index 000000000..13dd69ce2 --- /dev/null +++ b/packages/assistant-ui-runtime/src/requiredActionInputs.ts @@ -0,0 +1,38 @@ +import type { ThreadMessage } from '@assistant-ui/core'; +import type { TurnInputItem, UserToolApprovalEvent, UserToolResponseEvent } from './server/index.js'; + +import { ROOT_THREAD_ID } from './constants.js'; +import { collectApprovalInputs, messageHasPendingApprovals } from './toolApproval.js'; +import { collectResponseInputs, messageHasPendingResponses } from './toolResponse.js'; + +export type RequiredActionInput = Extract; + +export function messageHasPendingRequiredActions(message: ThreadMessage | undefined): boolean { + return messageHasPendingApprovals(message) || messageHasPendingResponses(message); +} + +export function collectRequiredActionInputs( + message: ThreadMessage, + defaultThreadId: string = ROOT_THREAD_ID, +): RequiredActionInput[] { + if (messageHasPendingRequiredActions(message)) { + return []; + } + return [...collectApprovalInputs(message, defaultThreadId), ...collectResponseInputs(message, defaultThreadId)]; +} + +export function isRequiredActionInput(item: TurnInputItem): item is RequiredActionInput { + return item.type === 'user.tool_approval' || item.type === 'user.tool_response'; +} + +export function findPausedAssistantMessage( + messages: readonly ThreadMessage[], +): Extract | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const candidate = messages[i]; + if (candidate?.role === 'assistant' && candidate.status.type === 'requires-action') { + return candidate; + } + } + return undefined; +} diff --git a/packages/assistant-ui-runtime/src/sandboxDownload.ts b/packages/assistant-ui-runtime/src/sandboxDownload.ts new file mode 100644 index 000000000..50a2edef9 --- /dev/null +++ b/packages/assistant-ui-runtime/src/sandboxDownload.ts @@ -0,0 +1,33 @@ +/** + * Request shape for {@link AgentChatServer.downloadSandboxFile}. + * Turn-scoped hosts resolve the sandbox from `turnId` and may omit `sandboxId`; + * hosts that address sandboxes directly still receive `sandboxId` when known. + */ +export interface SandboxDownloadRequest { + sessionId: string; + turnId: string; + path: string; + sandboxId?: string; +} + +/** + * Builds the host download request. Requires a saved session and a turn scope; + * does not require `sandboxId` (it may be missing after resume when + * `sandbox.created` fell outside the loaded history window). + */ +export function buildSandboxDownloadRequest(args: { + sessionId: string | undefined; + turnId: string; + path: string; + sandboxId?: string; +}): SandboxDownloadRequest { + if (args.sessionId == null) { + throw new Error('This session has not been saved yet, so its files cannot be downloaded.'); + } + return { + sessionId: args.sessionId, + turnId: args.turnId, + path: args.path, + ...(args.sandboxId != null ? { sandboxId: args.sandboxId } : {}), + }; +} diff --git a/packages/assistant-ui-runtime/src/server/eventUtils.ts b/packages/assistant-ui-runtime/src/server/eventUtils.ts new file mode 100644 index 000000000..2fd71784a --- /dev/null +++ b/packages/assistant-ui-runtime/src/server/eventUtils.ts @@ -0,0 +1,125 @@ +/** + * Local implementations of streaming delta helpers. + * Formerly imported from trueforge-gateway-sdk/agents. + */ + +import type { + DeltaEvents, + ModelMessageDeltaEvent, + ModelMessageEvent, + ToolCall, + ToolInfo, + TurnEvent, + TurnStreamingEvent, +} from './events.js'; + +/** True for `.delta` streaming events. */ +export function isEventDelta(event: TurnStreamingEvent): event is DeltaEvents { + return typeof event.type === 'string' && event.type.endsWith('.delta'); +} + +/** + * Merge `delta` into `base` in place (same `id` required). + * Currently handles `model.message.delta` → `model.message`. + */ +export function mergeEventDelta(base: TurnEvent, delta: DeltaEvents): void { + if (base.id !== delta.id) { + throw new Error(`Cannot merge delta into a different event: base id "${base.id}" != delta id "${delta.id}".`); + } + if (base.type === 'model.message') { + mergeModelMessageDelta(base, delta); + } +} + +function asToolInfo(value: unknown): ToolInfo | undefined { + if (value == null || typeof value !== 'object') { + return undefined; + } + const type: unknown = Reflect.get(value, 'type'); + if (typeof type !== 'string') { + return undefined; + } + const name: unknown = Reflect.get(value, 'name'); + if (type === 'trueforge-system') { + return typeof name === 'string' ? { type, name } : undefined; + } + if (type === 'mcp') { + const serverId: unknown = Reflect.get(value, 'serverId'); + const serverName: unknown = Reflect.get(value, 'serverName'); + return typeof serverId === 'string' && typeof serverName === 'string' && typeof name === 'string' + ? { type, serverId, serverName, name } + : undefined; + } + return typeof name === 'string' ? { type, name } : { type }; +} + +function mergeModelMessageDelta(base: ModelMessageEvent, delta: ModelMessageDeltaEvent): void { + if (delta.content) { + if (base.content === undefined || base.content === null || typeof base.content === 'string') { + base.content = (base.content ?? '') + delta.content; + } else { + const last = base.content[base.content.length - 1]; + if (last?.type === 'text') { + last.text += delta.content; + } else { + base.content.push({ type: 'text', text: delta.content }); + } + } + } + + if (delta.refusal) { + base.refusal = (base.refusal ?? '') + delta.refusal; + } + + if (delta.toolCalls) { + base.toolCalls ??= []; + for (const d of delta.toolCalls) { + let tc: ToolCall | undefined = base.toolCalls[d.index]; + if (tc === undefined) { + const toolInfo = asToolInfo(d.toolInfo); + tc = { + id: d.id ?? '', + type: d.type ?? 'function', + function: { + name: d.function?.name ?? '', + arguments: '', + }, + ...(toolInfo != null ? { toolInfo } : {}), + }; + base.toolCalls[d.index] = tc; + } + if (d.id) { + tc.id = d.id; + } + if (d.type) { + tc.type = d.type; + } + if (d.function?.name) { + tc.function.name = d.function.name; + } + if (d.function?.arguments) { + tc.function.arguments += d.function.arguments; + } + const toolInfo = asToolInfo(d.toolInfo); + if (toolInfo != null) { + tc.toolInfo = toolInfo; + } + if (d.providerSpecificFields) { + tc.providerSpecificFields = { + ...(tc.providerSpecificFields ?? {}), + ...d.providerSpecificFields, + }; + } + } + } + + if (delta.finishReason) { + base.finishReason = delta.finishReason; + } + if (delta.reasoningContent) { + base.reasoningContent = (base.reasoningContent ?? '') + delta.reasoningContent; + } + if (delta.usage) { + base.usage = delta.usage; + } +} diff --git a/packages/assistant-ui-runtime/src/server/events.ts b/packages/assistant-ui-runtime/src/server/events.ts new file mode 100644 index 000000000..05f07edc2 --- /dev/null +++ b/packages/assistant-ui-runtime/src/server/events.ts @@ -0,0 +1,232 @@ +/** + * Runtime-owned turn/stream event protocol. + * + * Hosts must emit events matching these shapes. + */ + +import type { TurnInputItem, TurnState } from './types.js'; + +// --------------------------------------------------------------------------- +// Tool call shapes +// --------------------------------------------------------------------------- + +export interface ToolCallFunction { + name: string; + arguments: string; +} + +export type ToolInfo = + | { type: 'trueforge-system'; name: string } + | { type: 'mcp'; serverId: string; serverName: string; name: string } + | { type: string; name?: string }; + +export interface ToolCall { + id: string; + type: 'function'; + function: ToolCallFunction; + toolInfo?: ToolInfo; + providerSpecificFields?: Record; +} + +/** Ref used by approval/response-required events. */ +export interface ToolCallRef { + id: string; + sourceEventId: string; +} + +export interface ChunkDeltaToolCall { + index: number; + id?: string; + type?: 'function'; + function?: { name?: string; arguments?: string }; + toolInfo?: ToolInfo; + providerSpecificFields?: Record; +} + +// --------------------------------------------------------------------------- +// Content events +// --------------------------------------------------------------------------- + +export type ModelMessageContentPart = + | { type: 'text'; text: string } + | { type: 'refusal'; refusal: string } + | { type: 'image_url'; image_url: { url: string } }; + +export interface ModelMessageEvent { + type: 'model.message'; + id: string; + threadId: string; + content?: string | ModelMessageContentPart[] | null; + name?: string; + refusal?: string | null; + reasoningContent?: string; + toolCalls?: ToolCall[]; + finishReason?: string | null; + createdAt: string; + usage?: unknown; +} + +export interface ModelMessageDeltaEvent { + type: 'model.message.delta'; + id: string; + threadId: string; + content?: string | null; + refusal?: string | null; + reasoningContent?: string; + toolCalls?: ChunkDeltaToolCall[]; + finishReason?: string | null; + createdAt?: string; + usage?: unknown; + /** Extended content-block deltas (image streaming). */ + contentBlocks?: { + index: number; + delta: { type: 'text'; text?: string } | { type: 'image_url'; image_url?: { url?: string } }; + }[]; + content_blocks?: { + index: number; + delta: { type: 'text'; text?: string } | { type: 'image_url'; image_url?: { url?: string } }; + }[]; +} + +export interface ToolResponseEvent { + type: 'tool.response'; + id: string; + threadId: string; + toolCallId: string; + content: string; + createdAt: string; +} + +export interface ToolApprovalRequiredEvent { + type: 'tool.approval_required'; + id: string; + createdAt: string; + threadId: string; + toolCalls: ToolCallRef[]; +} + +export interface ToolResponseRequiredEvent { + type: 'tool.response_required'; + id: string; + createdAt: string; + threadId: string; + toolCalls: ToolCallRef[]; +} + +export interface AgentInfo { + type?: string; + name: string; + input: string; + model?: string; +} + +export interface AgentParent { + threadId: string; + toolCallId: string; +} + +export interface ThreadCreatedEvent { + type: 'thread.created'; + id: string; + threadId: string; + title: string; + agentInfo: AgentInfo; + parent: AgentParent; + createdAt: string; +} + +export interface ThreadDoneEvent { + type: 'thread.done'; + id: string; + threadId: string; + title?: string; + createdAt: string; + state?: unknown; +} + +export interface McpServerAuthInfo { + id: string; + name: string; + authUrl: string; +} + +export interface McpAuthRequiredEvent { + type: 'mcp.auth_required'; + id: string; + createdAt: string; + threadId?: string | null; + mcpServers: McpServerAuthInfo[]; +} + +export interface SandboxCreatedEvent { + type: 'sandbox.created'; + id: string; + createdAt: string; + sandboxId: string; + threadId: string | null; +} + +export interface McpInitializeEvent { + type: 'mcp.initialize'; + id: string; + createdAt: string; + threadId: string | null; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Turn lifecycle events +// --------------------------------------------------------------------------- + +export interface TurnCreatedEvent { + type: 'turn.created'; + id: string; + turnId: string; + previousTurnId?: string | null; + input?: TurnInputItem[]; + state?: { status: 'running' }; + createdAt: string; + threadId?: string | null; +} + +export interface TurnDoneEvent { + type: 'turn.done'; + id: string; + state: Exclude; + createdAt: string; + threadId?: string | null; +} + +// --------------------------------------------------------------------------- +// Unions +// --------------------------------------------------------------------------- + +/** Events stored in fold buckets (non-delta, non-turn-lifecycle). */ +export type TurnEvent = + | ModelMessageEvent + | ToolResponseEvent + | ThreadCreatedEvent + | ThreadDoneEvent + | McpAuthRequiredEvent + | McpInitializeEvent + | SandboxCreatedEvent + | ToolApprovalRequiredEvent + | ToolResponseRequiredEvent; + +/** Full streaming event union (includes deltas + turn lifecycle). */ +export type TurnStreamingEvent = TurnEvent | ModelMessageDeltaEvent | TurnCreatedEvent | TurnDoneEvent; + +export type ActionRequiredEvent = ToolApprovalRequiredEvent | ToolResponseRequiredEvent | McpAuthRequiredEvent; + +export interface TurnStreamData { + sequenceNumber: number; + event: TStreamEvent; +} + +/** Session-level event item from `listEvents`. */ +export interface SessionEventItem { + turnId: string; + event: TurnCreatedEvent | TurnDoneEvent | TurnEvent; +} + +export type DeltaEvents = ModelMessageDeltaEvent; diff --git a/packages/assistant-ui-runtime/src/server/index.ts b/packages/assistant-ui-runtime/src/server/index.ts new file mode 100644 index 000000000..0364daeb5 --- /dev/null +++ b/packages/assistant-ui-runtime/src/server/index.ts @@ -0,0 +1,169 @@ +export type { + AgentBuilderCapabilitiesResponse, + AgentBuilderServer, + AgentCapabilityConfig, + AgentChatServer, + AgentCompactionConfig, + AgentContextManagementConfig, + AgentDetail, + AgentInputTokensCompactionTrigger, + AgentLibraryEntry, + AgentMetricChartData, + AgentMetricChartDataRequest, + AgentMetricChartDefinition, + AgentMetricChartType, + AgentMetricGraph, + AgentMetricGraphLine, + AgentMetricMeter, + AgentMetricPoint, + AgentMetricRangeRequest, + AgentMetricsServer, + AgentRuntimeConfig, + AgentSandboxConfig, + AgentSelectorEntry, + AgentSessionsServer, + AgentSkill, + AgentSpec, + AgentUIServer, + AgentUIServerPort, + ApprovalDecision, + AuthenticateConnectorRequest, + CatalogServer, + CodeSnippet, + CodeSnippetSampleCode, + ConnectorAuth, + ConnectorAuthApiKey, + ConnectorAuthNone, + ConnectorAuthOAuth, + ConnectorAuthPublic, + ConnectorAuthPublicApiKey, + ConnectorAuthPublicNone, + ConnectorAuthPublicOAuth, + ConnectorAuthType, + ConnectorAuthenticationResult, + ConnectorBase, + ConnectorCatalogEntry, + ConnectorCatalogServer, + ConnectorConfigBase, + ConnectorSelectorEntry, + ConnectorState, + CreateConnectorRequest, + CreateModelProviderRequest, + CreateSandboxProviderRequest, + CreateSandboxRequest, + CreateScheduleRequest, + CreateScheduleRunRequest, + CreateSessionRequest, + CreateSkillRequest, + CreateSkillRequestBase, + CreatedBySubject, + DefinedSkill, + GithubSkill, + ImportGithubSkillRequest, + ListPermissionsRequest, + ListPermissionsResponse, + ListResult, + ListSchedulesParams, + ListSessionEventsParams, + ListSessionsOrder, + ListSessionsParams, + McpServerMount, + McpToolSelection, + Model, + ModelCatalogServer, + ModelEntry, + ModelParams, + ModelProperties, + ModelProviderBase, + ModelProviderCatalogEntry, + ModelProviderConfigBase, + ModelSelection, + ModelSelectorEntry, + PageParams, + PermissionResourceType, + PermissionsServer, + PreviousTurnIdInput, + ProviderEntry, + ProviderType, + RegistrySkill, + ResourcePermission, + SandboxBase, + SandboxCatalogEntry, + SandboxCatalogServer, + SandboxConfig, + SandboxProviderBase, + SandboxProviderCatalogEntry, + SandboxProviderConfig, + SandboxProviderListEntry, + SandboxSnapshotSyncStatus, + SaveAgentRequest, + SaveAgentResult, + Schedule, + ScheduleRun, + ScheduleRunStatus, + ScheduleServer, + ScheduleStatus, + SearchAgentSelectorParams, + SearchAgentsParams, + SelectRegistrySkillRequest, + Session, + SessionListEntry, + SessionListMetrics, + SkillBase, + SkillCatalogEntry, + SkillCatalogServer, + SkillConfigBase, + SkillMount, + SkillSelectorEntry, + ToolBase, + Turn, + TurnDoneMetrics, + TurnInputItem, + TurnState, + TurnStateCancelled, + TurnStateDone, + TurnStateError, + TurnStateRunning, + UpdateConnectorRequest, + UpdateModelProviderRequest, + UpdateSandboxProviderRequest, + UpdateSandboxRequest, + UpdateScheduleRequest, + UpdateSessionRequest, + UserMessage, + UserMessageContent, + UserToolApprovalEvent, + UserToolResponseEvent, +} from './types.js'; + +export type { + ActionRequiredEvent, + AgentInfo, + AgentParent, + ChunkDeltaToolCall, + DeltaEvents, + McpAuthRequiredEvent, + McpInitializeEvent, + McpServerAuthInfo, + ModelMessageContentPart, + ModelMessageDeltaEvent, + ModelMessageEvent, + SandboxCreatedEvent, + SessionEventItem, + ThreadCreatedEvent, + ThreadDoneEvent, + ToolApprovalRequiredEvent, + ToolCall, + ToolCallFunction, + ToolCallRef, + ToolInfo, + ToolResponseEvent, + ToolResponseRequiredEvent, + TurnCreatedEvent, + TurnDoneEvent, + TurnEvent, + TurnStreamData, + TurnStreamingEvent, +} from './events.js'; + +export { isEventDelta, mergeEventDelta } from './eventUtils.js'; diff --git a/packages/assistant-ui-runtime/src/server/types.ts b/packages/assistant-ui-runtime/src/server/types.ts new file mode 100644 index 000000000..7ad5a2b94 --- /dev/null +++ b/packages/assistant-ui-runtime/src/server/types.ts @@ -0,0 +1,1136 @@ +/** + * FE-owned AgentUIServer contract. + * + * Rule: only methods/fields the runtime or UI invokes. + * Hosts extend via `T extends Base` for system-specific extras. + * No dependency on trueforge-gateway-sdk. + */ + +import type { ActionRequiredEvent, SessionEventItem, TurnEvent, TurnStreamData } from './events.js'; + +// --------------------------------------------------------------------------- +// Catalog — selector rows (SDK-minimal; host extends via generics) +// --------------------------------------------------------------------------- +export interface ProviderEntry { + name: string; + logo?: string; +} + +export interface ModelProperties { + reasoningEfforts?: string[]; + contextLength?: number; + maxOutputTokens?: number; +} + +/** Model selector row. Host extends for apiModel, modelId, etc. */ +export interface ModelSelectorEntry< + TProvider extends ProviderEntry = ProviderEntry, + TProperties extends ModelProperties = ModelProperties, +> { + name: string; + id: string; + provider: TProvider; + properties: TProperties; +} + +/** Skill selector row. Host extends for fqn, preload, etc. */ +export interface SkillSelectorEntry { + id: string; + name: string; + description?: string; +} + +/** MCP connector selector row. Host extends for type, enableTools, url, etc. */ +export interface ConnectorSelectorEntry { + id: string; + name: string; + description?: string; + /** When true, the connector must be authenticated before use. Omitted when the host does not report auth. */ + requiresAuth?: boolean; + /** When true, the connector is already authenticated. Omitted when the host does not report auth. */ + authenticated?: boolean; +} + +/** Who created a resource. Optional on list DTOs when the host does not track creators. */ +export interface CreatedBySubject { + subjectId: string; + subjectType: string; + subjectDisplayName: string; +} + +/** Agent selector row. Host extends for metadata; `agentSpec` enables Edit. */ +export interface AgentSelectorEntry { + name: string; + /** Stable id when distinct from display `name`. Falls back to `name` when omitted. */ + agentId?: string; + /** Published-agent description; not part of the executable agent spec. */ + description?: string; + /** Published agent spec — required for Edit; optional for Try-only hosts. */ + agentSpec?: AgentSpec; + /** Creator when the host persists one; omit to hide Created-by UI. */ + createdBySubject?: CreatedBySubject; +} + +export interface SearchAgentSelectorParams { + query?: string; + limit?: number; + offset?: number; +} + +// --------------------------------------------------------------------------- +// Mounts — neutral SDK base (host extends with backend-specific fields) +// --------------------------------------------------------------------------- + +/** + * Mounts written to AgentSpec.skills[] / AgentSpec.mcpServers[]. + * + * These are opaque to the runtime — it stores and forwards them but never reads + * a field, and the backend owns the shape (the gateway identifies a skill by + * `fqn`, with no `id` or `name` anywhere). So the base constrains only that a + * mount is an object; hosts intersect their concrete mount type over it, as + * `TfySkillMount` / `TfyMcpServerMount` do in the gateway adapter. + * + * Naming a field here would not just be unread, it would be wrong: a base with + * required fields rejects the backend's own payloads, and one with only optional + * fields is a weak type, which TypeScript rejects for a source that shares no + * property with it — the gateway's registry skill shares none. + */ +export type SkillMount = object; + +export type McpServerMount = object; + +// --------------------------------------------------------------------------- +// AgentSpec — model / skills / mcpServers are type params; host widens the rest +// --------------------------------------------------------------------------- + +export interface ModelParams { + maxTokens?: number; + reasoningEffort?: string; + temperature?: number; + topP?: number; + topK?: number; + parallelToolCalls?: boolean; +} + +export interface Model { + name: string; + params?: ModelParams; +} + +export interface AgentCapabilityConfig { + enabled?: boolean; +} + +export interface AgentSandboxConfig extends AgentCapabilityConfig { + fileDownloads?: boolean; +} + +export interface AgentInputTokensCompactionTrigger { + type: 'input_tokens'; + value: number; +} + +export interface AgentCompactionConfig extends AgentCapabilityConfig { + trigger?: AgentInputTokensCompactionTrigger; +} + +export interface AgentContextManagementConfig { + compaction?: AgentCompactionConfig; + largeToolResponse?: AgentCapabilityConfig; +} + +export interface AgentRuntimeConfig { + iterationLimit?: number; + sandbox?: AgentSandboxConfig; + contextManagement?: AgentContextManagementConfig; + generativeUi?: AgentCapabilityConfig; + dynamicSubAgents?: AgentCapabilityConfig; + askUserQuestions?: AgentCapabilityConfig; + webSearch?: AgentCapabilityConfig; +} + +/** + * SDK-owned agent definition — fields the FE reads/writes. + * Host widens `model` / `skills` / `mcpServers` / `config` via type params, + * and adds extra fields via `TSpec extends AgentSpec<...>`. + */ +export interface AgentSpec< + TModel extends Model = Model, + TSkill extends SkillMount = SkillMount, + TMcp extends McpServerMount = McpServerMount, + TConfig extends AgentRuntimeConfig = AgentRuntimeConfig, +> { + model: TModel; + skills?: TSkill[]; + mcpServers?: TMcp[]; + config?: TConfig; + instructions?: string; + variables?: Record; +} + +// --------------------------------------------------------------------------- +// Session — plain DTO, generic in TSpec so host's spec type flows through +// --------------------------------------------------------------------------- + +export interface Session { + id: string; + title?: string | null; + agentName?: string | null; + agentSpec?: TSpec; + /** true → mutable builder + updateSession(spec) allowed. */ + isMutable: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateSessionRequest { + agentName?: string; + agentSpec?: TSpec; + title?: string; +} + +export interface UpdateSessionRequest { + sessionId: string; + agentSpec?: TSpec; + title?: string; +} + +// --------------------------------------------------------------------------- +// Pagination — flat token-based (no Page objects with methods) +// --------------------------------------------------------------------------- + +export interface ListResult { + data: T[]; + nextPageToken?: string; +} + +export type ListSessionsOrder = 'asc' | 'desc'; + +export interface PageParams { + limit?: number; + order?: ListSessionsOrder; + pageToken?: string; +} + +export interface ListSessionsParams extends PageParams { + /** Host-owned agent identity filter. Omit for all sessions (current user). */ + agentId?: string; + /** + * When true, only sessions created by the authenticated subject. + * Omit to include managed-agent visibility where the host supports it. + */ + createdByMe?: boolean; + /** Inclusive lower bound on session activity (ISO-8601). */ + startTimestamp?: string; + /** Inclusive upper bound on session activity (ISO-8601). */ + endTimestamp?: string; +} + +export type PreviousTurnIdInput = string; + +// --------------------------------------------------------------------------- +// Turn input / state — what runtime sends and reads +// --------------------------------------------------------------------------- + +export type UserMessageContent = + string | ({ type: 'text'; text: string } | { type: 'file'; name: string; data: string })[]; + +export interface UserMessage { + type: 'user.message'; + content: UserMessageContent; +} + +export type ApprovalDecision = { status: 'allow' } | { status: 'deny'; reason?: string }; + +export interface UserToolApprovalEvent { + type: 'user.tool_approval'; + threadId: string; + toolCallId: string; + approval: ApprovalDecision; +} + +export interface UserToolResponseEvent { + type: 'user.tool_response'; + threadId: string; + toolCallId: string; + content: string; +} + +export type TurnInputItem = UserMessage | UserToolApprovalEvent | UserToolResponseEvent; + +export interface TurnStateRunning { + status: 'running'; +} + +/** + * Aggregated token metrics on a finished turn (`turn.done.state.metrics`). + * Host maps wire snake_case (`total_input_tokens`, …) → camelCase here. + */ +export interface TurnDoneMetrics { + totalInputTokens: number; + totalOutputTokens: number; + totalTokens: number; + totalCacheReadTokens: number; + totalCacheWriteTokens: number; + totalReasoningTokens: number; +} + +export interface TurnStateDone { + status: 'done'; + output?: unknown; + requiredActions?: ActionRequiredEvent[]; + completedAt: string; + /** Present when the host reports per-turn token totals. */ + metrics?: TurnDoneMetrics; +} + +export interface TurnStateCancelled { + status: 'cancelled'; + reason: string; + completedAt: string; +} + +export interface TurnStateError { + status: 'error'; + message: string; + completedAt: string; +} + +export type TurnState = TurnStateRunning | TurnStateDone | TurnStateCancelled | TurnStateError; + +/** Plain turn DTO — no methods. */ +export interface Turn { + id: string; + sessionId: string; + previousTurnId?: string | null; + input?: TurnInputItem[]; + state: TurnState; + createdAt: string; +} + +// --------------------------------------------------------------------------- +// Server ports — flat methods, no session-with-methods objects +// --------------------------------------------------------------------------- + +/** + * Chat / session port — the runtime calls these. + * + * All session ops are flat (sessionId param). No gateway client dependency. + * `createTrueForgeServer` is one possible implementation (TFY adapter). + */ +export interface AgentChatServer< + TSpec extends AgentSpec = AgentSpec, + TSession extends Session = Session, + TCreate extends CreateSessionRequest = CreateSessionRequest, + TList extends ListSessionsParams = ListSessionsParams, + TUpdate extends UpdateSessionRequest = UpdateSessionRequest, + TTurn extends Turn = Turn, +> { + createSession(req: TCreate): Promise; + listSessions(req?: TList): Promise>; + getSession(req: { sessionId: string }): Promise; + updateSession(req: TUpdate): Promise; + + createTurn(req: { + sessionId: string; + input?: TurnInputItem[]; + previousTurnId?: PreviousTurnIdInput; + abortSignal?: AbortSignal; + headers?: Record; + }): AsyncIterable; + + cancelSession(req: { sessionId: string }): Promise; + deleteSession?(req: { sessionId: string }): Promise; + /** + * When present, thread-list `rename` persists via this method. + * Omitted: rename is a no-op (TrueForge Gateway does not persist titles). + */ + renameSession?(req: { sessionId: string; title: string }): Promise; + + listTurns(req: { + sessionId: string; + limit?: number; + pageToken?: string; + order?: ListSessionsOrder; + }): Promise>; + getTurn(req: { sessionId: string; turnId: string }): Promise; + listEvents(req: { + sessionId: string; + pageToken?: string; + lastTurnId?: string; + limit?: number; + }): Promise>; + + /** Optional per-turn event listing (hydrate in-flight turn content). */ + listTurnEvents?(req: { + sessionId: string; + turnId: string; + limit?: number; + pageToken?: string; + order?: ListSessionsOrder; + }): Promise>; + + subscribeToTurn?(req: { + sessionId: string; + turnId: string; + afterSequenceNumber?: number; + abortSignal?: AbortSignal; + }): AsyncIterable; + + /** + * Reads a file the agent wrote inside its sandbox. Hosts whose download route is scoped to a + * turn resolve the sandbox from `turnId` and ignore `sandboxId`; hosts addressing sandboxes + * directly use `sandboxId` when the runtime was able to recover it (optional after resume when + * `sandbox.created` is outside the loaded history window). + */ + downloadSandboxFile?(req: { + sessionId: string; + turnId: string; + /** Present when a `sandbox.created` event was observed in session history or the live stream. */ + sandboxId?: string; + path: string; + }): Promise; +} + +/** Request body for `AgentBuilderServer.saveAgent`. */ +export interface SaveAgentRequest { + agentName: string; + /** Published-agent description; hosts may require it when creating an agent. */ + description?: string; + agentSpec: TSpec; + intent: 'create' | 'update'; + /** Current mutable session to update atomically with the named agent. */ + sessionId?: string; +} + +export interface SaveAgentResult { + /** Immutable id allocated by the host registry. */ + agentId?: string; + /** Version id allocated by the host registry for this save. */ + versionId?: string; + /** Timestamp returned when the active mutable session was updated. */ + sessionUpdatedAt?: string; +} + +/** MCP tool row used by the per-agent connector tool selector. */ +export interface McpToolSelection { + id: string; + name: string; + description?: string; +} + +/** + * Builder feature flags — atoms gate sandbox / skill / settings surfaces on these. + */ +export interface AgentBuilderCapabilitiesResponse { + data: { + sandbox: { enabled: boolean }; + skill: { enabled: boolean; reason?: string }; + settings?: { enabled: boolean }; + webSearch?: { enabled: boolean }; + }; +} + +/** + * Builder catalog + persist port — atoms call these. + * Passed separately from the runtime's chat server. + */ +export interface AgentBuilderServer< + TSpec extends AgentSpec = AgentSpec, + TModel extends ModelSelectorEntry = ModelSelectorEntry, + TSkill extends SkillSelectorEntry = SkillSelectorEntry, + TMcp extends ConnectorSelectorEntry = ConnectorSelectorEntry, + TAgent extends AgentSelectorEntry = AgentSelectorEntry, + TSave = SaveAgentResult, + TCapabilities extends AgentBuilderCapabilitiesResponse = AgentBuilderCapabilitiesResponse, + TMcpTool extends McpToolSelection = McpToolSelection, +> { + getCapabilities(): Promise; + getModels(): Promise; + getSkills(): Promise; + getMcp(): Promise; + getMcpConnector?(req: { connectorId: string }): Promise; + getMcpTools?(req: { connectorId: string }): Promise; + searchAgents(req?: SearchAgentSelectorParams): Promise; + saveAgent(req: SaveAgentRequest): Promise; + deleteAgent?(req: { agentName: string }): Promise; +} + +// --------------------------------------------------------------------------- +// Catalog management — FE-minimal settings DTOs (host extends via generics) +// --------------------------------------------------------------------------- + +/** + * Provider type id. Reserved literal: `"custom"` for user-defined providers; + * any other string is a builtin (e.g. `"openai"`, `"anthropic"`). + * + * Note: `string | "custom"` is useless in TypeScript (`"custom"` ⊆ `string`), + * so this stays `string` and `"custom"` is a documented convention. + */ +export type ProviderType = string; + +/** + * Model row — form "Model ID" + "Display name". + * Host extends for properties, etc. + */ +export interface ModelEntry { + id: string; + name: string; +} + +/** + * Write config for create/update (custom form + catalog "Save key"). + * Host extends. `baseUrl` present iff `type === "custom"`. + */ +export interface ModelProviderConfigBase { + type: ProviderType; + name: string; + /** Present iff `type === "custom"`. */ + baseUrl?: string; + apiKey: string; + models: TModel[]; +} + +/** + * Configured provider card (list/read). No raw `apiKey`. + * Host extends for apiKeySet, timestamps, etc. + */ +export interface ModelProviderBase { + id: string; + type: ProviderType; + name: string; + /** Present iff `type === "custom"`. */ + baseUrl?: string; + models: TModel[]; +} + +/** + * Discovery-only catalog provider (AVAILABLE list). + * `type` must not be `"custom"` — custom providers use the custom form. + * Host extends for richer model rows. + */ +export interface ModelProviderCatalogEntry { + type: ProviderType; + name: string; + models: TModel[]; + supportedReasoningEfforts?: string[]; + logo?: string; +} + +/** Create — no `id`; server assigns it. Catalog path = entry + apiKey. */ +export type CreateModelProviderRequest = ModelProviderConfigBase; + +/** Update — `id` required. */ +export type UpdateModelProviderRequest = ModelProviderConfigBase & { + id: string; +}; + +export interface ModelCatalogServer< + TModel extends ModelEntry = ModelEntry, + TProvider extends ModelProviderBase = ModelProviderBase, + TCatalogProvider extends ModelProviderCatalogEntry = ModelProviderCatalogEntry, + TCreate extends CreateModelProviderRequest = CreateModelProviderRequest, + TUpdate extends UpdateModelProviderRequest = UpdateModelProviderRequest, +> { + getModelProviderCatalog(): Promise; + listModelProviders(): Promise; + createModelProvider(req: TCreate): Promise; + /** Full replace update keyed by provider `id`. */ + updateModelProvider(req: TUpdate): Promise; + deleteModelProvider?(req: { id: string }): Promise; +} + +/** Tool row on a connector detail. Host extends for schemas, etc. */ +export interface ToolBase { + id: string; + name: string; + description: string; +} + +/** Strict auth type id. Hosts widen branches via intersection + re-union. */ +export type ConnectorAuthType = 'dcr' | 'header' | 'none'; + +// Write (create/update) — export branches so hosts can intersect extras +export interface ConnectorAuthOAuth { + type: 'dcr'; + authUrl?: string; +} +export interface ConnectorAuthApiKey { + type: 'header'; + apiKey?: string; + headerName?: string; +} +export interface ConnectorAuthNone { + type: 'none'; +} +export type ConnectorAuth = ConnectorAuthOAuth | ConnectorAuthApiKey | ConnectorAuthNone; + +// Public (list/detail) — no secrets +export interface ConnectorAuthPublicOAuth { + type: 'dcr'; + authUrl?: string; +} +export interface ConnectorAuthPublicApiKey { + type: 'header'; + headerName?: string; +} +export interface ConnectorAuthPublicNone { + type: 'none'; +} +export type ConnectorAuthPublic = ConnectorAuthPublicOAuth | ConnectorAuthPublicApiKey | ConnectorAuthPublicNone; + +/** + * MCP / connector create-edit config. Host extends for extra fields, etc. + */ +export interface ConnectorConfigBase { + name: string; + description: string; + url: string; + auth: TAuth; +} + +/** + * Connected connector row (settings/connectors). No raw `apiKey` or tools. + * Tools are fetched separately with `getToolsByConnectorId`. + * Host extends. + */ +export interface ConnectorBase { + id: string; + name: string; + description: string; + url: string; + auth: TAuth; + /** When true, UI should not show Disconnect. */ + requiresAuth: boolean; + authenticated: boolean; +} + +/** Discovery catalog entry for "+ Add MCP server". Host extends. */ +export interface ConnectorCatalogEntry { + id: string; + name: string; + description?: string; + url: string; + auth: TAuth; + logo?: string; +} + +/** Create connector — no `id`; server assigns it. Host extends. */ +export type CreateConnectorRequest = ConnectorConfigBase; + +/** Update connector — `id` required. Host extends. */ +export type UpdateConnectorRequest = ConnectorConfigBase & { + id: string; +}; + +export interface AuthenticateConnectorRequest { + id: string; + /** OAuth callback page owned by the host application. */ + returnTo?: string; +} + +export interface ConnectorAuthenticationResult { + connector?: TConnector; + status?: string; + authorization_endpoint?: string | undefined; +} + +export interface ConnectorCatalogServer< + TTool extends ToolBase = ToolBase, + TAuthWrite extends ConnectorAuth = ConnectorAuth, + TAuthPublic extends ConnectorAuthPublic = ConnectorAuthPublic, + TConnector extends ConnectorBase = ConnectorBase, + TCatalogEntry extends ConnectorCatalogEntry = ConnectorCatalogEntry, + TCreate extends CreateConnectorRequest = CreateConnectorRequest, + TUpdate extends UpdateConnectorRequest = UpdateConnectorRequest, +> { + getConnectorCatalog(): Promise; + getConnector(req: { id: string }): Promise; + listConnectors(req?: { query?: string }): Promise; + getToolsByConnectorId(req: { id: string }): Promise; + createConnector(req: TCreate): Promise; + /** Full replace update keyed by connector `id`. */ + updateConnector(req: TUpdate): Promise; + /** + * Start connector auth (e.g. OAuth). + * May return a connector (already authenticated / with `auth.authUrl`) or a + * result carrying `authorization_endpoint` for the popup flow. + */ + authenticateConnector( + req: AuthenticateConnectorRequest, + ): Promise>; + /** Clear connector auth. */ + disconnectConnector(req: { id: string }): Promise; + deleteConnector?(req: { id: string }): Promise; +} + +// --------------------------------------------------------------------------- +// Skills catalog — FE-minimal settings DTOs (host extends via generics) +// --------------------------------------------------------------------------- + +/** Skill row shown in settings/skills (list + delete). Host extends for fqn, etc. */ +export interface SkillBase { + id: string; + name: string; + description: string; +} + +export interface RegistrySkill extends SkillBase { + /** `SkillCatalogEntry.id` this skill was created from. */ + catalogId: string; +} + +export type GithubSkill = SkillBase; + +export type DefinedSkill = RegistrySkill | GithubSkill; + +/** Git source fields shared by catalog entries and create requests. */ +export interface SkillConfigBase { + name: string; + description: string; + repoURL: string; + path: string; + ref: string; +} + +export interface SkillCatalogEntry extends SkillConfigBase { + id: string; +} + +/** Create-skill base. Hosts may intersect extra fields and re-union. */ +export type CreateSkillRequestBase = SkillConfigBase; + +export interface SelectRegistrySkillRequest extends CreateSkillRequestBase { + /** `SkillCatalogEntry.id`, persisted so the created skill links back to it. */ + catalogId: string; +} + +export type ImportGithubSkillRequest = CreateSkillRequestBase; + +export type CreateSkillRequest = SelectRegistrySkillRequest | ImportGithubSkillRequest; + +export interface SkillCatalogServer< + TSkill extends SkillBase = SkillBase, + TCatalogEntry extends SkillCatalogEntry = SkillCatalogEntry, + TCreate extends CreateSkillRequest = CreateSkillRequest, +> { + getSkillCatalog(): Promise; + listSkills(req?: { query?: string }): Promise; + createSkill(req: TCreate): Promise; + deleteSkill?(req: { id: string }): Promise; +} + +// --------------------------------------------------------------------------- +// Sandbox providers catalog — public rows omit credentials; writes accept them +// --------------------------------------------------------------------------- + +/** Mutable sandbox provider settings shared by catalog rows, create, and update. */ +export interface SandboxConfig { + execTimeoutMs: number; + autoStopIntervalInMinutes: number; + autoArchiveIntervalInMinutes: number; + autoDeleteIntervalInMinutes: number; +} + +export interface SandboxCatalogEntry extends SandboxConfig { + id: string; + name: string; + type: string; +} + +/** + * Connected sandbox provider row (settings/sandboxes). No raw `apiKey`. + * Includes last-saved config so update forms can show previous values. + */ +export interface SandboxBase extends SandboxConfig { + id: string; + name: string; + catalogId: string; + isConnected: boolean; +} + +export interface SandboxSnapshotSyncStatus { + status: 'pending' | 'ready' | 'failed'; + statusReason?: string | null; +} + +export interface SandboxProviderListEntry { + data: TSandbox; + snapshotSyncStatus: SandboxSnapshotSyncStatus; +} + +export interface CreateSandboxRequest extends SandboxConfig { + /** `SandboxCatalogEntry.id` used to create this sandbox provider. */ + catalogId: string; + name: string; + type: string; + apiKey: string; +} + +export interface UpdateSandboxRequest extends SandboxConfig { + id: string; + /** Omit to keep the existing key; send a value to rotate. */ + apiKey?: string; +} + +/** Host-facing aliases (trueforge-ui public names). */ +export type SandboxProviderConfig = SandboxConfig; +export type SandboxProviderCatalogEntry = SandboxCatalogEntry; +export type SandboxProviderBase = SandboxBase; +export type CreateSandboxProviderRequest = CreateSandboxRequest; +export type UpdateSandboxProviderRequest = UpdateSandboxRequest; + +export interface SandboxCatalogServer< + TProvider extends SandboxBase = SandboxBase, + TCatalogEntry extends SandboxCatalogEntry = SandboxCatalogEntry, + TCreate extends CreateSandboxRequest = CreateSandboxRequest, + TUpdate extends UpdateSandboxRequest = UpdateSandboxRequest, + TListEntry extends SandboxProviderListEntry = SandboxProviderListEntry, +> { + getSandboxProviderCatalog(): Promise; + listSandboxProviders(req?: { query?: string }): Promise; + createSandboxProvider(req: TCreate): Promise; + updateSandboxProvider(req: TUpdate): Promise; + deleteSandboxProvider?(req: { id: string }): Promise; +} + +/** Host-facing selector / compose aliases (trueforge-ui public names). */ +export type ModelSelection = ModelSelectorEntry; +export type AgentSkill = SkillSelectorEntry; +export type ConnectorState = ConnectorSelectorEntry; +export type AgentLibraryEntry = AgentSelectorEntry; +export type SearchAgentsParams = SearchAgentSelectorParams; + +/** + * Settings management aggregate — modelCatalog + connectorCatalog + optional + * skill and sandbox catalogs. + * Hosts may pass the whole object to an app shell, or a focused sub-port to a page. + */ +export interface CatalogServer< + TModelCatalog extends ModelCatalogServer = ModelCatalogServer, + TConnectorCatalog extends ConnectorCatalogServer = ConnectorCatalogServer, + TSkillCatalog extends SkillCatalogServer = SkillCatalogServer, + TSandboxCatalog extends SandboxCatalogServer = SandboxCatalogServer, +> { + modelCatalog: TModelCatalog; + connectorCatalog: TConnectorCatalog; + /** Optional — omit when the host has no skills settings surface. */ + skillCatalog?: TSkillCatalog; + /** Optional — omit when the host has no sandboxes settings surface. */ + sandboxCatalog?: TSandboxCatalog; +} + +// --------------------------------------------------------------------------- +// AgentSessions — optional agent-detail shell (Overview + sessions under agent) +// --------------------------------------------------------------------------- + +/** + * Published agent identity + spec for the agent-detail Overview (read-only). + * Host widens `TSpec` (and may extend this DTO) for mounts / config / extras. + */ +export interface AgentDetail { + agentId: string; + /** Display name (e.g. "release-notes-writer"). */ + name: string; + /** Published-agent description; not part of the executable agent spec. */ + description?: string; + agentSpec: TSpec; +} + +/** Stream vs non-stream bodies for one language on the Use In Code tab. */ +export interface CodeSnippetSampleCode { + stream: string; + nonStream: string; +} + +/** + * One language row for Use In Code. + * Host maps wire `sample_code` / `non_stream` → camelCase here. + */ +export interface CodeSnippet { + /** Sidebar label (e.g. "TypeScript"). */ + labelName: string; + /** Highlighter / tab id (e.g. "typescript"). */ + language: string; + icon?: string; + sampleCode: TSample; +} + +/** + * Aggregated session metrics for the sessions list sidebar. + * Host maps wire snake_case (`total_turns`, `total_cost_in_usd`, …) → camelCase. + */ +export interface SessionListMetrics { + totalTurns: number; + totalCostInUsd?: number; + totalDurationMs: number; +} + +/** + * One row in the Agent Sessions list (left pane). + * + * Binding: `agentName` → named / immutable agent; `agentSpec` → mutable / draft. + * Host may send one, both, or neither depending on how the session was created. + */ +export interface SessionListEntry { + id: string; + title?: string | null; + createdAt: string; + updatedAt: string; + lastActivityAt: string; + metrics: SessionListMetrics; + /** Present when bound to a published (immutable) agent. */ + agentName?: string | null; + /** Present when bound to a mutable / draft agent spec. */ + agentSpec?: TSpec; +} + +/** Params for `AgentSessionsServer.listSessionEvents` (session event timeline). */ +export interface ListSessionEventsParams extends Pick { + sessionId: string; +} + +/** + * Optional plug-in for agent-detail UI: Overview, Use In Code, sessions list, + * and per-session event timeline. Omit `sessions` on `AgentUIServerPort` when + * the host has no agent-detail surface. + * + * Read-only — create/update/delete stay on `AgentChatServer` / `AgentBuilderServer`. + */ +export interface AgentSessionsServer< + TSpec extends AgentSpec = AgentSpec, + TDetail extends AgentDetail = AgentDetail, + TSnippet extends CodeSnippet = CodeSnippet, + TListEntry extends SessionListEntry = SessionListEntry, + TList extends ListSessionsParams = ListSessionsParams, +> { + /** Fetch published agent details by id for the Overview tab. */ + getAgent(req: { agentId: string }): Promise; + /** Fetch Use In Code snippets for the agent (one row per language). */ + getCodeSnippets(req: { agentId: string }): Promise; + /** + * List sessions visible to the current user. Pass `agentId` to scope to one + * agent; `createdByMe: true` for only sessions they created. Use + * `startTimestamp` / `endTimestamp` for date filters. + */ + listSessions(req?: TList): Promise>; + /** + * Fetch the session event timeline (right pane). Paginate with `pageToken` + * until exhausted; rebuild turns from `turn.created` / `turn.done` + + * nested `TurnEvent`s. Per-turn token metrics live on `turn.done.state.metrics`. + */ + listSessionEvents(req: ListSessionEventsParams): Promise>; +} + +// --------------------------------------------------------------------------- +// Schedules — optional schedules listing + CRUD + manual runs (host extends) +// --------------------------------------------------------------------------- + +export type ScheduleStatus = 'active' | 'paused'; + +export type ScheduleRunStatus = 'scheduled' | 'triggered' | 'failed'; + +export interface Schedule { + id: string; + name: string; + /** Stable agent id (`AgentSelectorEntry.agentId`). */ + agentId: string; + /** Display name; omit when the host only has `agentId`. */ + agentName?: string; + task: string; + cron: string; + timezone: string; + status: ScheduleStatus; + lastRunAt: string | null; + /** Creator when the host persists one; omit to hide Created-by UI. */ + createdBySubject?: CreatedBySubject; +} + +/** Flat schedule-run DTO (ISO-8601 timestamps; host maps from wire Dates). */ +export interface ScheduleRun { + id: string; + scheduleId: string; + name: string; + scheduledFor: string; + status: ScheduleRunStatus; + triggeredAt: string | null; + triggeredBy: string; +} + +export interface ListSchedulesParams extends Pick { + agentId?: string; + /** Multi-agent filter; combined with `agentId` when both are set. */ + agentIds?: string[]; +} + +export interface CreateScheduleRequest { + agentId: string; + name: string; + task: string; + cron: string; + timezone?: string; + status?: ScheduleStatus; +} + +/** `agentId` is not on the base update body; hosts that allow rebinding can extend `TUpdate`. */ +export interface UpdateScheduleRequest { + id: string; + name: string; + task: string; + cron: string; + timezone?: string; + status?: ScheduleStatus; +} + +export interface CreateScheduleRunRequest { + scheduleId: string; +} + +export interface ScheduleServer< + TSchedule extends Schedule = Schedule, + TCreate extends CreateScheduleRequest = CreateScheduleRequest, + TUpdate extends UpdateScheduleRequest = UpdateScheduleRequest, + TRun extends ScheduleRun = ScheduleRun, +> { + listSchedules(req?: ListSchedulesParams): Promise>; + getSchedule(req: { id: string }): Promise; + createSchedule(req: TCreate): Promise; + updateSchedule(req: TUpdate): Promise; + deleteSchedule(req: { id: string }): Promise; + listScheduleRuns(req: { scheduleId: string }): Promise; + createScheduleRun(req: CreateScheduleRunRequest): Promise; +} + +// --------------------------------------------------------------------------- +// Permissions — optional resource mutation grants +// --------------------------------------------------------------------------- + +export type PermissionResourceType = 'agent' | 'schedule' | 'session' | 'tenant'; + +export type ResourcePermission = 'USE' | 'MANAGE' | 'DELETE' | 'CREATE'; + +export interface ListPermissionsRequest { + resourceType: PermissionResourceType; + resourceIds: string[]; +} + +export interface ListPermissionsResponse { + data: { + type: PermissionResourceType; + permissions: Record; + }; +} + +export interface PermissionsServer< + TRequest extends ListPermissionsRequest = ListPermissionsRequest, + TResponse extends ListPermissionsResponse = ListPermissionsResponse, +> { + listPermissions(req: TRequest): Promise; +} + +// --------------------------------------------------------------------------- +// AgentMetrics — optional aggregate metrics + time-series charts +// --------------------------------------------------------------------------- + +export type AgentMetricChartType = 'line'; + +export interface AgentMetricChartDefinition { + name: string; + displayName: string; + description: string; + chartType: AgentMetricChartType; +} + +export interface AgentMetricMeter { + name: string; + aggregateValue: number; + description: string; + unit: string; +} + +export interface AgentMetricPoint { + timestamp: string; + value: number; +} + +export interface AgentMetricGraphLine { + name: string; + values: TPoint[]; +} + +export interface AgentMetricGraph { + name: string; + displayName: string; + description: string; + unit: string; + chartType: AgentMetricChartType; + graphLines: TLine[]; +} + +export interface AgentMetricChartData { + step: string; + graphs: TGraph[]; +} + +export interface AgentMetricRangeRequest { + agentId: string; + startTimestamp: string; + endTimestamp: string; +} + +export interface AgentMetricChartDataRequest extends AgentMetricRangeRequest { + chartName: string; +} + +export interface AgentMetricsServer< + TChart extends AgentMetricChartDefinition = AgentMetricChartDefinition, + TMeter extends AgentMetricMeter = AgentMetricMeter, + TChartData extends AgentMetricChartData = AgentMetricChartData, + TRangeRequest extends AgentMetricRangeRequest = AgentMetricRangeRequest, + TChartDataRequest extends AgentMetricChartDataRequest = AgentMetricChartDataRequest, +> { + getCharts(): Promise; + getMeters(req: TRangeRequest): Promise; + getChartData(req: TChartDataRequest): Promise; +} + +/** + * Composed host port: chat + builder + optional settings catalog + optional + * agent-detail / sessions shell + optional schedules surface + optional metrics. + * + * `catalog` is optional — if the host passes it, settings UI can call + * `useCatalogServer()` / show modelCatalog, connectorCatalog, and skillCatalog; + * if omitted, those surfaces stay hidden. + * + * `sessions` is optional — if the host passes it, agent-detail UI can call + * `useAgentSessionsServer()` / Overview + sessions under an agent; if omitted, + * that surface stays hidden. + * + * `schedules` is optional — if the host passes it, schedules UI can call + * `useScheduleServer()` / list and manage schedules; if omitted, that surface + * stays hidden. + * + * `metrics` is optional — if the host passes it, agent-detail UI can render + * aggregate meter cards and time-series charts. + * + * trueforge-ui re-exports this as `AgentUIServer`. + */ +export type AgentUIServerPort< + TChat extends AgentChatServer = AgentChatServer, + TBuilder extends AgentBuilderServer = AgentBuilderServer, + TCatalog extends CatalogServer = CatalogServer, + TSessions extends AgentSessionsServer = AgentSessionsServer, + TSchedules extends ScheduleServer = ScheduleServer, + TMetrics extends AgentMetricsServer = AgentMetricsServer, + TPermissions extends PermissionsServer = PermissionsServer, +> = TChat & + TBuilder & { + catalog?: TCatalog; + sessions?: TSessions; + schedules?: TSchedules; + metrics?: TMetrics; + permissions?: TPermissions; + }; + +/** Host-facing alias used by trueforge-ui. */ +export type AgentUIServer = AgentUIServerPort; diff --git a/packages/assistant-ui-runtime/src/sessionListStartTimestamp.ts b/packages/assistant-ui-runtime/src/sessionListStartTimestamp.ts new file mode 100644 index 000000000..7e7bed74b --- /dev/null +++ b/packages/assistant-ui-runtime/src/sessionListStartTimestamp.ts @@ -0,0 +1,6 @@ +/** ISO timestamp one year before now — used as `listSessions` `startTimestamp`. */ +export function sessionListStartTimestamp(): string { + const start = new Date(); + start.setFullYear(start.getFullYear() - 1); + return start.toISOString(); +} diff --git a/packages/assistant-ui-runtime/src/sessionSnapshot.ts b/packages/assistant-ui-runtime/src/sessionSnapshot.ts new file mode 100644 index 000000000..b0085141d --- /dev/null +++ b/packages/assistant-ui-runtime/src/sessionSnapshot.ts @@ -0,0 +1,146 @@ +import type { + McpAuthRequiredEvent, + Turn, + TurnCreatedEvent, + TurnDoneEvent, + TurnEvent, + TurnInputItem, +} from './server/index.js'; + +import { extractTurnUserText } from './extractTurnUserText.js'; +import { PeerThreadFoldState } from './foldPeerThreads.js'; +import type { StoredApprovalDecision } from './toolApproval.js'; +import type { StoredToolResponse } from './toolResponse.js'; +import type { TurnStreamUpdate } from './turnStreamUpdate.js'; + +/** Session-level event item from `AgentSession.listEvents`. */ +export interface GatewaySessionEventItem { + turnId: string; + event: TurnCreatedEvent | TurnDoneEvent | TurnEvent; +} + +/** Cursor for fetching older `listEvents` pages (scroll-up history). */ +export interface SessionHistoryPagination { + /** Token for the next older page; omit when exhausted. */ + olderPageToken?: string | undefined; + hasOlder: boolean; +} + +/** Turn metadata retained for cross-turn projection and subsequent required-action replay. */ +export type SessionTurnRecord = Pick & { + /** Denormalized from `input` for user/assistant interleaving during projection. */ + userText?: string | undefined; + /** Root-thread `model.message` ids ingested with this turn (for per-group projection). */ + rootModelMessageIds?: readonly string[] | undefined; + /** sandboxId observed via `sandbox.created` on this or an earlier turn (session-scoped). */ + sandboxId?: string | undefined; +}; + +export interface RequiredActionsOverlay { + approvals: Map; + toolResponses: Map; +} + +export interface ActiveStreamState { + turnId: string; + update: TurnStreamUpdate; + isContinuation: boolean; + streamComplete?: boolean | undefined; +} + +export interface PendingUserMessage { + turnId: string; + /** Gateway user.message content (text-only string or text/file parts). */ + content: Extract['content']; + createdAt: Date; +} + +export interface SessionSnapshot { + fold: PeerThreadFoldState; + turns: SessionTurnRecord[]; + pendingMcpAuth?: McpAuthRequiredEvent | undefined; + pendingUser?: PendingUserMessage | undefined; + activeStream?: ActiveStreamState | undefined; + /** Root `model.message` ids present before the active turn group started (streaming scope). */ + groupRootBaseline?: readonly string[] | undefined; + requiredActions: RequiredActionsOverlay; + runningTurn?: Turn | undefined; + unstable_resume?: boolean | undefined; + /** + * Chronological `listEvents` items loaded so far (for prepend-on-scroll rebuild). + * Live stream commits are not appended here — they live in `turns` / `fold`. + */ + historyEvents?: readonly GatewaySessionEventItem[] | undefined; + historyPagination?: SessionHistoryPagination | undefined; +} + +export interface ProjectSessionMessagesOptions { + getCreatedAt?: (messageId: string, fallback: Date, replace?: boolean) => Date; +} + +export function emptyRequiredActionsOverlay(): RequiredActionsOverlay { + return { + approvals: new Map(), + toolResponses: new Map(), + }; +} + +export function createEmptySessionSnapshot(): SessionSnapshot { + return { + fold: new PeerThreadFoldState(), + turns: [], + requiredActions: emptyRequiredActionsOverlay(), + }; +} + +export function turnToSessionRecord(turn: Turn): SessionTurnRecord { + const userText = extractTurnUserText(turn.input); + return { + id: turn.id, + ...(userText !== undefined ? { userText } : {}), + createdAt: turn.createdAt, + state: turn.state, + input: turn.input ?? [], + }; +} + +/** Builds a SessionTurnRecord from session-level TurnCreatedEvent + TurnDoneEvent data. */ +export function sessionEventsToSessionRecord( + turnId: string, + createdEvent: TurnCreatedEvent, + doneEvent: TurnDoneEvent, + rootModelMessageIds: readonly string[], + sandboxId?: string, +): SessionTurnRecord { + const userText = extractTurnUserText(createdEvent.input); + return { + id: turnId, + ...(userText !== undefined ? { userText } : {}), + createdAt: createdEvent.createdAt, + state: doneEvent.state, + input: createdEvent.input ?? [], + rootModelMessageIds, + ...(sandboxId != null ? { sandboxId } : {}), + }; +} + +/** Returns a new snapshot wrapper; fold maps may be mutated in place before calling. */ +export function replaceSessionSnapshot( + snapshot: SessionSnapshot, + patch: Partial> & { + requiredActions?: RequiredActionsOverlay; + }, +): SessionSnapshot { + return { + ...snapshot, + ...patch, + ...(patch.requiredActions != null ? { requiredActions: patch.requiredActions } : {}), + }; +} + +export function cloneRequiredActionsOverlay(overlay: RequiredActionsOverlay): RequiredActionsOverlay { + return { + approvals: new Map(overlay.approvals), + toolResponses: new Map(overlay.toolResponses), + }; +} diff --git a/packages/assistant-ui-runtime/src/sessionThreadMetadata.ts b/packages/assistant-ui-runtime/src/sessionThreadMetadata.ts new file mode 100644 index 000000000..abfac695f --- /dev/null +++ b/packages/assistant-ui-runtime/src/sessionThreadMetadata.ts @@ -0,0 +1,36 @@ +import type { RemoteThreadMetadata } from '@assistant-ui/core'; + +import { draftSessionTitle } from './draft/agentSpec.js'; +import type { AgentSpec, Session } from './server/types.js'; + +/** + * Title for a session row in a mixed (draft + named) thread list. + * Mutable sessions use draftSessionTitle (optionally falling back to + * `defaultAgentSpec`); named sessions use title → agentName → id. + */ +export function sessionDisplayTitle(session: Session, defaultAgentSpec?: AgentSpec): string { + if (session.isMutable) { + const agentSpec = session.agentSpec ?? defaultAgentSpec; + if (agentSpec != null) { + return draftSessionTitle({ + agentSpec, + ...(session.title === undefined ? {} : { title: session.title }), + }); + } + } + return session.title ?? session.agentName ?? session.id; +} + +/** Map a Session DTO onto RemoteThreadMetadata (mutability + display name in custom). */ +export function sessionToThreadMetadata(session: Session, title: string | undefined): RemoteThreadMetadata { + return { + status: 'regular', + remoteId: session.id, + title, + lastMessageAt: new Date(session.updatedAt), + custom: { + isMutable: session.isMutable, + ...(session.agentName != null ? { agentName: session.agentName } : {}), + }, + }; +} diff --git a/packages/assistant-ui-runtime/src/sessions.ts b/packages/assistant-ui-runtime/src/sessions.ts new file mode 100644 index 000000000..33a3ae6ee --- /dev/null +++ b/packages/assistant-ui-runtime/src/sessions.ts @@ -0,0 +1,17 @@ +import type { AgentChatServer, Session } from './server/types.js'; + +const inflightBySessionId = new Map>(); + +/** `sessionId` is the assistant-ui thread `remoteId` from `RemoteThreadListAdapter.initialize`. */ +export function getSession(server: AgentChatServer, sessionId: string): Promise { + let inflight = inflightBySessionId.get(sessionId); + if (inflight == null) { + inflight = server.getSession({ sessionId }).finally(() => { + if (inflightBySessionId.get(sessionId) === inflight) { + inflightBySessionId.delete(sessionId); + } + }); + inflightBySessionId.set(sessionId, inflight); + } + return inflight; +} diff --git a/packages/assistant-ui-runtime/src/streamTurn.ts b/packages/assistant-ui-runtime/src/streamTurn.ts new file mode 100644 index 000000000..8e6c3977b --- /dev/null +++ b/packages/assistant-ui-runtime/src/streamTurn.ts @@ -0,0 +1,118 @@ +import type { TurnStreamData } from './server/events.js'; +import type { AgentChatServer, PreviousTurnIdInput, TurnInputItem } from './server/types.js'; + +import { streamTurnEvents, type UserMessageContent } from './convertTurnMessages.js'; +import { PeerThreadFoldState } from './foldPeerThreads.js'; +import type { RequiredActionInput } from './requiredActionInputs.js'; +import type { TurnStreamUpdate } from './turnStreamUpdate.js'; + +export interface StreamTurnOptions { + userMessage?: UserMessageContent; + resumeMcpAuth?: boolean; + inputs?: RequiredActionInput[]; + /** + * Branch anchor for createTurn. Omit for `"auto"`. Pass `"none"` for a fresh + * root turn. + */ + previousTurnId?: PreviousTurnIdInput; + /** Extra headers for the turn request. */ + headers?: Record; +} + +function buildTurnInput(options: StreamTurnOptions): TurnInputItem[] { + if (options.inputs != null) { + return options.inputs; + } + if (options.resumeMcpAuth === true) { + return []; + } + return [{ type: 'user.message', content: options.userMessage ?? '' }]; +} + +export async function* streamTurnContent( + server: AgentChatServer, + sessionId: string, + foldState: PeerThreadFoldState, + options: StreamTurnOptions, + abortSignal: AbortSignal, + groupRootBaseline?: readonly string[], + /** + * Called once with the turn ID as soon as it becomes available (first + * `turn.created` SSE event). Use this to reconcile the locally-generated + * optimistic ID with the real turn ID. + */ + onTurnIdAvailable?: (turnId: string) => void, +): AsyncGenerator { + // Aborting only detaches this client from the run; the turn keeps running on + // the backend so switching sessions (or remounting) can reattach via + // `subscribeToTurn`. Stopping the run is an explicit `cancelSession` call. + if (abortSignal.aborted) { + return; + } + + let turnIdNotified = false; + const notifyTurnId = (turnId: string) => { + if (!turnIdNotified) { + onTurnIdAvailable?.(turnId); + turnIdNotified = true; + } + }; + + const stream: AsyncIterable = server.createTurn({ + sessionId, + input: buildTurnInput(options), + previousTurnId: options.previousTurnId ?? 'auto', + abortSignal, + ...(options.headers != null ? { headers: options.headers } : {}), + }); + + try { + for await (const update of streamTurnEvents(stream, foldState, groupRootBaseline, notifyTurnId)) { + yield update; + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + throw error; + } +} + +/** TODO: wire `afterSequenceNumber` from the last ingested stream event to skip replay on reconnect. */ +export async function* resumeTurnStream( + server: AgentChatServer, + sessionId: string, + turnId: string, + foldState: PeerThreadFoldState, + abortSignal: AbortSignal, + afterSequenceNumber?: number, + groupRootBaseline?: readonly string[], +): AsyncGenerator { + // Optional on custom backends. Callers detect the gap and report it, so an + // empty stream here is safer than throwing mid-render. + if (server.subscribeToTurn == null) { + return; + } + + if (abortSignal.aborted) { + return; + } + + try { + yield* streamTurnEvents( + server.subscribeToTurn({ + sessionId, + turnId, + ...(afterSequenceNumber != null ? { afterSequenceNumber } : {}), + abortSignal, + }), + foldState, + groupRootBaseline, + ); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + throw error; + } +} diff --git a/packages/assistant-ui-runtime/src/toolApproval.ts b/packages/assistant-ui-runtime/src/toolApproval.ts new file mode 100644 index 000000000..4d8473370 --- /dev/null +++ b/packages/assistant-ui-runtime/src/toolApproval.ts @@ -0,0 +1,413 @@ +import type { + MessageStatus, + ThreadAssistantMessage, + ThreadAssistantMessagePart, + ThreadMessage, +} from '@assistant-ui/core'; +import type { ToolApprovalRequiredEvent, Turn, UserToolApprovalEvent } from './server/index.js'; + +import { ROOT_THREAD_ID } from './constants.js'; +import type { ToolApprovalMessageCustomMetadata } from './messageCustomMetadata.js'; +import type { TurnStreamUpdate } from './turnStreamUpdate.js'; + +export { ROOT_THREAD_ID } from './constants.js'; + +export interface StoredApprovalDecision { + approved: boolean; + reason?: string; +} + +export const TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY = 'toolApprovalThreadId'; + +export interface RespondToToolApprovalOptions { + approvalId: string; + approved: boolean; + optionId?: string; + reason?: string; +} + +type ApprovalDecision = UserToolApprovalEvent['approval']; + +type ToolCallPart = Extract; + +type AssistantToolCallPart = Extract; + +export function hasPendingToolApproval(approval: ToolCallPart['approval'] | undefined): boolean { + return approval != null && approval.approved === undefined && approval.resolution === undefined; +} + +function applyApprovalDecisionToToolCall( + part: AssistantToolCallPart, + options: RespondToToolApprovalOptions, +): AssistantToolCallPart { + const { approved, optionId, reason } = options; + const targetApproval = part.approval; + if (targetApproval == null) { + return part; + } + const approval = { + ...targetApproval, + approved, + ...(optionId != null ? { optionId } : {}), + ...(reason != null ? { reason } : {}), + }; + if (approved) { + return { ...part, approval }; + } + return { + ...part, + approval, + result: { error: reason ?? 'Tool approval denied' }, + isError: true, + }; +} + +function updateToolApprovalInContent( + content: readonly ThreadAssistantMessagePart[], + options: RespondToToolApprovalOptions, +): { content: readonly ThreadAssistantMessagePart[]; found: boolean } { + let found = false; + const newContent = content.map(part => { + if (part.type !== 'tool-call') { + return part; + } + + if (part.approval?.id === options.approvalId) { + found = true; + return applyApprovalDecisionToToolCall(part, options); + } + + if (part.messages == null) { + return part; + } + + const messages = part.messages.map(message => { + if (message.role !== 'assistant') { + return message; + } + const nested = updateToolApprovalInContent(message.content, options); + if (!nested.found) { + return message; + } + found = true; + return { ...message, content: nested.content }; + }); + return { ...part, messages }; + }); + + return { content: newContent, found }; +} + +export function applyApprovalDecisionsToMessage( + message: ThreadAssistantMessage, + options: RespondToToolApprovalOptions, +): ThreadAssistantMessage { + const { content } = updateToolApprovalInContent(message.content, options); + return { ...message, content: [...content] }; +} + +export function toolApprovalStatus(): MessageStatus { + return { type: 'requires-action', reason: 'tool-calls' }; +} + +export function toolApprovalMessageCustom(threadId: string): ToolApprovalMessageCustomMetadata { + return { + [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: threadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : threadId, + }; +} + +export function getToolApprovalThreadId(message: ThreadMessage | undefined): string | undefined { + if (message?.role !== 'assistant') { + return undefined; + } + const threadId = message.metadata.custom[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]; + return typeof threadId === 'string' ? threadId : undefined; +} + +export function findApprovalRequiredInTurn(turn: Pick): ToolApprovalRequiredEvent | undefined { + if (turn.state.status !== 'done') { + return undefined; + } + const found = turn.state.requiredActions?.find(action => action.type === 'tool.approval_required'); + return found?.type === 'tool.approval_required' ? found : undefined; +} + +function toolCallPartHasPendingApproval(part: ToolCallPart): boolean { + return hasPendingToolApproval(part.approval); +} + +function nestedMessagesHavePendingApprovals(messages: readonly ThreadMessage[]): boolean { + for (const message of messages) { + if (messageHasPendingApprovals(message)) { + return true; + } + } + return false; +} + +export function messageHasPendingApprovals(message: ThreadMessage | undefined): boolean { + if (message?.role !== 'assistant') { + return false; + } + for (const part of message.content) { + if (part.type !== 'tool-call') { + continue; + } + if (toolCallPartHasPendingApproval(part)) { + return true; + } + if (part.messages != null && nestedMessagesHavePendingApprovals(part.messages)) { + return true; + } + } + return false; +} + +function walkAssistantToolCallParts( + content: readonly ThreadAssistantMessagePart[], + visit: (part: AssistantToolCallPart) => void, +): void { + for (const part of content) { + if (part.type !== 'tool-call') { + continue; + } + visit(part); + if (part.messages == null) { + continue; + } + for (const message of part.messages) { + if (message.role === 'assistant') { + walkAssistantToolCallParts(message.content, visit); + } + } + } +} + +export function collectDecidedApprovalsFromContent( + content: readonly ThreadAssistantMessagePart[], +): Map { + const decisions = new Map(); + walkAssistantToolCallParts(content, part => { + const { approval } = part; + if (approval?.id == null || approval.approved === undefined) { + return; + } + decisions.set(approval.id, { + approved: approval.approved, + ...(approval.reason != null ? { reason: approval.reason } : {}), + }); + }); + return decisions; +} + +function applyApprovalDecisionsToMessages( + messages: readonly ThreadMessage[], + decisions: ReadonlyMap, +): ThreadMessage[] { + return messages.map(message => { + if (message.role !== 'assistant') { + return message; + } + return { + ...message, + content: applyApprovalDecisionsToContent(message.content, decisions), + }; + }); +} + +export function applyApprovalDecisionsToContent( + content: readonly ThreadAssistantMessagePart[], + decisions: ReadonlyMap, +): ThreadAssistantMessagePart[] { + return content.map(part => { + if (part.type !== 'tool-call') { + return part; + } + + const decision = part.approval?.id != null ? decisions.get(part.approval.id) : undefined; + let nextPart: AssistantToolCallPart = part; + + if (decision != null && part.approval != null && part.approval.approved === undefined) { + nextPart = applyApprovalDecisionToToolCall(part, { + approvalId: part.approval.id, + approved: decision.approved, + ...(decision.reason == null ? {} : { reason: decision.reason }), + }); + } + + if (nextPart.messages == null) { + return nextPart; + } + + return { + ...nextPart, + messages: applyApprovalDecisionsToMessages(nextPart.messages, decisions), + }; + }); +} + +export function mergeDecidedApprovalsIntoContent( + incoming: readonly ThreadAssistantMessagePart[], + existing: readonly ThreadAssistantMessagePart[], +): ThreadAssistantMessagePart[] { + const decided = collectDecidedApprovalsFromContent(existing); + if (decided.size === 0) { + return [...incoming]; + } + return applyApprovalDecisionsToContent(incoming, decided); +} + +export function extractToolApprovalsFromTurnInput(input: Turn['input'] | undefined): UserToolApprovalEvent[] { + const events: UserToolApprovalEvent[] = []; + for (const item of input ?? []) { + if (item.type === 'user.tool_approval') { + events.push(item); + } + } + return events; +} + +export function collectSubsequentApprovalDecisions( + turns: readonly Pick[], + fromIndex: number, +): Map { + const decisions = new Map(); + + for (let index = fromIndex + 1; index < turns.length; index++) { + const input = turns[index]?.input ?? []; + if (input.some(item => item.type === 'user.message')) { + break; + } + + for (const event of extractToolApprovalsFromTurnInput(input)) { + decisions.set(event.toolCallId, { + approved: event.approval.status === 'allow', + ...(event.approval.status === 'deny' && event.approval.reason != null ? { reason: event.approval.reason } : {}), + }); + } + } + + return decisions; +} + +export function collectApprovalDecisionsFromTurnInput( + input: Turn['input'] | undefined, +): Map { + const decisions = new Map(); + for (const event of extractToolApprovalsFromTurnInput(input)) { + decisions.set(event.toolCallId, { + approved: event.approval.status === 'allow', + ...(event.approval.status === 'deny' && event.approval.reason != null ? { reason: event.approval.reason } : {}), + }); + } + return decisions; +} + +function contentHasPendingApprovals(content: readonly ThreadAssistantMessagePart[]): boolean { + return messageHasPendingApprovals({ + id: 'pending-check', + role: 'assistant', + content, + status: { type: 'complete', reason: 'stop' }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }); +} + +export function resolveToolApprovalUpdate( + update: TurnStreamUpdate, + priorDecisions?: ReadonlyMap, +): TurnStreamUpdate { + let { content } = update; + if (priorDecisions != null && priorDecisions.size > 0) { + content = applyApprovalDecisionsToContent(content, priorDecisions); + } + + if ( + contentHasPendingApprovals(content) || + update.status?.type !== 'requires-action' || + update.status.reason !== 'tool-calls' + ) { + return { ...update, content }; + } + + return { content }; +} + +export function mapApprovalDecision(approved: boolean, reason?: string): ApprovalDecision { + if (approved) { + return { status: 'allow' }; + } + return { status: 'deny', ...(reason != null ? { reason } : {}) }; +} + +function isDecidedApprovalAwaitingSdk(part: ToolCallPart): boolean { + const { approval, result, isError } = part; + if (approval?.id == null || approval.approved === undefined) { + return false; + } + if (approval.approved) { + return result === undefined; + } + return isError === true; +} + +function collectApprovalInputsFromMessages( + messages: readonly ThreadMessage[], + defaultThreadId: string, +): UserToolApprovalEvent[] { + const events: UserToolApprovalEvent[] = []; + for (const message of messages) { + events.push(...collectApprovalInputs(message, defaultThreadId)); + } + return events; +} + +export function collectApprovalInputs(message: ThreadMessage, threadId: string): UserToolApprovalEvent[] { + if (message.role !== 'assistant' || !threadId) { + return []; + } + if (messageHasPendingApprovals(message)) { + return []; + } + + const scopedThreadId = getToolApprovalThreadId(message) ?? threadId; + const events: UserToolApprovalEvent[] = []; + + for (const part of message.content) { + if (part.type !== 'tool-call') { + continue; + } + if (isDecidedApprovalAwaitingSdk(part)) { + const { approval } = part; + if (approval?.approved === undefined) { + continue; + } + events.push({ + type: 'user.tool_approval', + threadId: scopedThreadId, + toolCallId: approval.id, + approval: mapApprovalDecision(approval.approved, approval.reason), + }); + } + if (part.messages != null) { + events.push(...collectApprovalInputsFromMessages(part.messages, scopedThreadId)); + } + } + return events; +} + +export function toTrueForgeApprovalInputs( + message: Extract, + response: RespondToToolApprovalOptions, + defaultThreadId: string = ROOT_THREAD_ID, +): UserToolApprovalEvent[] { + const updated = applyApprovalDecisionsToMessage(message, response); + return collectApprovalInputs(updated, defaultThreadId); +} diff --git a/packages/assistant-ui-runtime/src/toolResponse.ts b/packages/assistant-ui-runtime/src/toolResponse.ts new file mode 100644 index 000000000..d22ca50e3 --- /dev/null +++ b/packages/assistant-ui-runtime/src/toolResponse.ts @@ -0,0 +1,346 @@ +import type { + MessageStatus, + ThreadAssistantMessage, + ThreadAssistantMessagePart, + ThreadMessage, +} from '@assistant-ui/core'; +import type { ToolResponseRequiredEvent, Turn, TurnInputItem, UserToolResponseEvent } from './server/index.js'; + +import { ROOT_THREAD_ID } from './constants.js'; +import { recordToolApprovalInFold, recordToolResponseInFold, type PeerThreadFoldState } from './foldPeerThreads.js'; +import type { ToolResponseMessageCustomMetadata } from './messageCustomMetadata.js'; +import type { TurnStreamUpdate } from './turnStreamUpdate.js'; + +export { ROOT_THREAD_ID } from './constants.js'; + +export const TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY = 'toolResponseThreadId'; + +export interface AskUserQuestionInterruptPayload { + question?: string; + options?: string[]; +} + +export type StoredToolResponse = Pick; + +export type RespondToToolResponseOptions = Pick; + +type ToolCallPart = Extract; + +type AssistantToolCallPart = Extract; + +export function hasPendingToolResponse(part: Pick): boolean { + return part.interrupt != null && part.result === undefined; +} + +function isStagedResponseAwaitingSdk(part: ToolCallPart): boolean { + return part.interrupt != null && part.result !== undefined; +} + +export function toolResponseStatus(): MessageStatus { + return { type: 'requires-action', reason: 'tool-calls' }; +} + +export function toolResponseMessageCustom(threadId: string): ToolResponseMessageCustomMetadata { + return { + [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: threadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : threadId, + }; +} + +export function getToolResponseThreadId(message: ThreadMessage | undefined): string | undefined { + if (message?.role !== 'assistant') { + return undefined; + } + const threadId = message.metadata.custom[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]; + return typeof threadId === 'string' ? threadId : undefined; +} + +export function findResponseRequiredInTurn(turn: Pick): ToolResponseRequiredEvent | undefined { + if (turn.state.status !== 'done') { + return undefined; + } + const found = turn.state.requiredActions?.find(action => action.type === 'tool.response_required'); + return found?.type === 'tool.response_required' ? found : undefined; +} + +function applyToolResponseToToolCall(part: AssistantToolCallPart, content: string): AssistantToolCallPart { + return { ...part, result: content }; +} + +function updateToolResponseInContent( + content: readonly ThreadAssistantMessagePart[], + options: RespondToToolResponseOptions, +): { content: readonly ThreadAssistantMessagePart[]; found: boolean } { + let found = false; + const newContent = content.map(part => { + if (part.type !== 'tool-call') { + return part; + } + + if (part.toolCallId === options.toolCallId && hasPendingToolResponse(part)) { + found = true; + return applyToolResponseToToolCall(part, options.content); + } + + if (part.messages == null) { + return part; + } + + const messages = part.messages.map(message => { + if (message.role !== 'assistant') { + return message; + } + const nested = updateToolResponseInContent(message.content, options); + if (!nested.found) { + return message; + } + found = true; + return { ...message, content: nested.content }; + }); + return { ...part, messages }; + }); + + return { content: newContent, found }; +} + +export function applyToolResponseToMessage( + message: ThreadAssistantMessage, + options: RespondToToolResponseOptions, +): ThreadAssistantMessage { + const { content } = updateToolResponseInContent(message.content, options); + return { ...message, content: [...content] }; +} + +function nestedMessagesHavePendingResponses(messages: readonly ThreadMessage[]): boolean { + for (const message of messages) { + if (messageHasPendingResponses(message)) { + return true; + } + } + return false; +} + +export function messageHasPendingResponses(message: ThreadMessage | undefined): boolean { + if (message?.role !== 'assistant') { + return false; + } + for (const part of message.content) { + if (part.type !== 'tool-call') { + continue; + } + if (hasPendingToolResponse(part)) { + return true; + } + if (part.messages != null && nestedMessagesHavePendingResponses(part.messages)) { + return true; + } + } + return false; +} + +function collectResponseInputsFromMessages( + messages: readonly ThreadMessage[], + defaultThreadId: string, +): UserToolResponseEvent[] { + const events: UserToolResponseEvent[] = []; + for (const message of messages) { + events.push(...collectResponseInputs(message, defaultThreadId)); + } + return events; +} + +export function collectResponseInputs(message: ThreadMessage, threadId: string): UserToolResponseEvent[] { + if (message.role !== 'assistant' || !threadId) { + return []; + } + if (messageHasPendingResponses(message)) { + return []; + } + + const scopedThreadId = getToolResponseThreadId(message) ?? threadId; + const events: UserToolResponseEvent[] = []; + + for (const part of message.content) { + if (part.type !== 'tool-call') { + continue; + } + if (isStagedResponseAwaitingSdk(part)) { + events.push({ + type: 'user.tool_response', + threadId: scopedThreadId, + toolCallId: part.toolCallId, + content: String(part.result), + }); + } + if (part.messages != null) { + events.push(...collectResponseInputsFromMessages(part.messages, scopedThreadId)); + } + } + return events; +} + +function contentHasPendingResponses(content: readonly ThreadAssistantMessagePart[]): boolean { + return messageHasPendingResponses({ + id: 'pending-check', + role: 'assistant', + content, + status: { type: 'complete', reason: 'stop' }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }); +} + +export function resolveToolResponseUpdate(update: TurnStreamUpdate): TurnStreamUpdate { + if ( + contentHasPendingResponses(update.content) || + update.status?.type !== 'requires-action' || + update.status.reason !== 'tool-calls' + ) { + return update; + } + + return { content: update.content }; +} + +function walkAssistantToolCallParts( + content: readonly ThreadAssistantMessagePart[], + visit: (part: AssistantToolCallPart) => void, +): void { + for (const part of content) { + if (part.type !== 'tool-call') { + continue; + } + visit(part); + if (part.messages == null) { + continue; + } + for (const message of part.messages) { + if (message.role === 'assistant') { + walkAssistantToolCallParts(message.content, visit); + } + } + } +} + +function collectStagedResponsesFromContent(content: readonly ThreadAssistantMessagePart[]): Map { + const staged = new Map(); + walkAssistantToolCallParts(content, part => { + if (isStagedResponseAwaitingSdk(part)) { + staged.set(part.toolCallId, String(part.result)); + } + }); + return staged; +} + +function applyStagedResponsesToContentMap( + content: readonly ThreadAssistantMessagePart[], + staged: ReadonlyMap, +): ThreadAssistantMessagePart[] { + return content.map(part => { + if (part.type !== 'tool-call') { + return part; + } + + const contentValue = staged.get(part.toolCallId); + let nextPart: AssistantToolCallPart = part; + if (contentValue != null && hasPendingToolResponse(part)) { + nextPart = applyToolResponseToToolCall(part, contentValue); + } + + if (nextPart.messages == null) { + return nextPart; + } + + return { + ...nextPart, + messages: nextPart.messages.map(message => { + if (message.role !== 'assistant') { + return message; + } + return { + ...message, + content: applyStagedResponsesToContentMap(message.content, staged), + }; + }), + }; + }); +} + +export function mergeStagedResponsesIntoContent( + incoming: readonly ThreadAssistantMessagePart[], + existing: readonly ThreadAssistantMessagePart[], +): ThreadAssistantMessagePart[] { + const staged = collectStagedResponsesFromContent(existing); + if (staged.size === 0) { + return [...incoming]; + } + return applyStagedResponsesToContentMap(incoming, staged); +} + +export function extractToolResponsesFromTurnInput(input: Turn['input'] | undefined): UserToolResponseEvent[] { + const events: UserToolResponseEvent[] = []; + for (const item of input ?? []) { + if (item.type === 'user.tool_response') { + events.push(item); + } + } + return events; +} + +export function applyUserToolResponsesToFold(fold: PeerThreadFoldState, inputs: readonly TurnInputItem[]): void { + for (const item of inputs) { + if (item.type === 'user.tool_response') { + recordToolResponseInFold(fold, { + toolCallId: item.toolCallId, + content: item.content, + }); + } else if (item.type === 'user.tool_approval') { + recordToolApprovalInFold(fold, { + toolCallId: item.toolCallId, + approved: item.approval.status === 'allow', + ...(item.approval.status === 'deny' && item.approval.reason != null ? { reason: item.approval.reason } : {}), + }); + } + } +} + +export function collectSubsequentToolResponses( + turns: readonly Pick[], + fromIndex: number, +): Map { + const responses = new Map(); + + for (let index = fromIndex + 1; index < turns.length; index++) { + const input = turns[index]?.input ?? []; + if (input.some(item => item.type === 'user.message')) { + break; + } + + for (const event of extractToolResponsesFromTurnInput(input)) { + responses.set(event.toolCallId, { content: event.content }); + } + } + + return responses; +} + +export function collectToolResponsesFromTurnInput(input: Turn['input'] | undefined): Map { + const responses = new Map(); + for (const event of extractToolResponsesFromTurnInput(input)) { + responses.set(event.toolCallId, { content: event.content }); + } + return responses; +} + +export function applyStagedResponsesToContent( + content: readonly ThreadAssistantMessagePart[], + responses: ReadonlyMap, +): ThreadAssistantMessagePart[] { + const staged = new Map([...responses.entries()].map(([toolCallId, value]) => [toolCallId, value.content])); + return applyStagedResponsesToContentMap(content, staged); +} diff --git a/packages/assistant-ui-runtime/src/trueforgeExtras.ts b/packages/assistant-ui-runtime/src/trueforgeExtras.ts new file mode 100644 index 000000000..571c8445d --- /dev/null +++ b/packages/assistant-ui-runtime/src/trueforgeExtras.ts @@ -0,0 +1,223 @@ +'use client'; + +import type { AssistantClient } from '@assistant-ui/store'; +import { useAui } from '@assistant-ui/store'; +import { useCallback, useSyncExternalStore } from 'react'; +import type { McpAuthRequiredEvent } from './server/index.js'; + +import type { PendingApproval, PendingToolResponse } from './collectPending.js'; +import type { AgentSpecUpdate } from './draft/agentSpec.js'; +import type { AgentSpec } from './server/types.js'; +import type { RespondToToolApprovalOptions } from './toolApproval.js'; +import type { RespondToToolResponseOptions } from './toolResponse.js'; + +export type { PendingApproval, PendingToolResponse }; + +export interface TrueForgeDraftRuntimeExtras { + agentSpec: AgentSpec | null; + draftSessionId: string | undefined; + isSpecLoading: boolean; + isSpecSyncing: boolean; + specError: unknown; + updateAgentSpec: (update: AgentSpecUpdate) => void; + flushAgentSpec: () => Promise; + adoptAgentSpec: (request: { agentSpec: AgentSpec; updatedAt?: string }) => void; +} + +export interface TrueForgeRuntimeExtras { + pendingApprovals: PendingApproval[]; + pendingToolResponses: PendingToolResponse[]; + pendingMcpAuth: { mcpServers: McpAuthRequiredEvent['mcpServers'] } | null; + resumeUnavailable: boolean; + sandboxId: string | undefined; + respondToToolApproval: (response: RespondToToolApprovalOptions) => void; + respondToToolResponse: (response: RespondToToolResponseOptions) => void; + resumeMcpAuth: () => Promise; + downloadSandboxFile: (req: { turnId: string; path: string }) => Promise; + cancel: () => Promise; + resetFromTurn: (turnId: string) => Promise; + reload: () => void; + hasOlderHistory: boolean; + isLoadingOlderHistory: boolean; + loadOlderHistory: () => Promise; + draft: TrueForgeDraftRuntimeExtras | null; +} + +const extrasBrandSymbol = Symbol('useTrueForgeAgentRuntime extras'); + +const isTrueForgeExtras = (extras: unknown): extras is TrueForgeRuntimeExtras => + typeof extras === 'object' && extras !== null && extrasBrandSymbol in extras; + +const extrasBrand = { + provide: (value: TrueForgeRuntimeExtras): TrueForgeRuntimeExtras => { + Object.defineProperty(value, extrasBrandSymbol, { + value: true, + enumerable: false, + configurable: true, + }); + return value; + }, + is: isTrueForgeExtras, + tryGet: (extras: unknown): TrueForgeRuntimeExtras | undefined => (isTrueForgeExtras(extras) ? extras : undefined), +}; + +export const EMPTY_DRAFT_EXTRAS: TrueForgeDraftRuntimeExtras = { + agentSpec: null, + draftSessionId: undefined, + isSpecLoading: false, + isSpecSyncing: false, + specError: null, + updateAgentSpec: () => { + throw new Error('Draft agent extras are only available in draft mode.'); + }, + flushAgentSpec: () => Promise.reject(new Error('Draft agent extras are only available in draft mode.')), + adoptAgentSpec: () => { + throw new Error('Draft agent extras are only available in draft mode.'); + }, +}; + +type SubscribeFn = (onStoreChange: () => void) => () => void; + +function isThreadAccessor(value: unknown): value is (this: object) => unknown { + return typeof value === 'function'; +} + +function isGetState(value: unknown): value is (this: object) => unknown { + return typeof value === 'function'; +} + +function isSubscribe(value: unknown): value is (this: object, onStoreChange: () => void) => unknown { + return typeof value === 'function'; +} + +/** + * Walk `Object.create(parent)` AUI clients. Stops before leaving the chain. + * Callers must try/catch RootAssistantClient proxy gets (it throws on any + * missing accessor such as `subscribe` / `thread`). + */ +function walkAssistantClientAncestors(client: AssistantClient, visit: (current: object) => 'continue' | 'stop'): void { + let current: object | null = client; + const seen = new Set(); + while (!seen.has(current)) { + seen.add(current); + if (visit(current) === 'stop') { + return; + } + const parent = Reflect.getPrototypeOf(current); + if (parent == null || parent === Object.prototype) { + return; + } + current = parent; + } +} + +/** + * `PartPrimitive.Messages` wraps sub-agent threads in `ReadonlyThreadProvider`, + * which shadows `thread` (and therefore `thread.extras`) with a readonly client + * that has no TrueForge extras. Nested AUI clients are `Object.create(parent)`, + * so walk the prototype chain to reach the root runtime extras. + */ +export function tryGetTrueForgeExtras(client: AssistantClient): TrueForgeRuntimeExtras | undefined { + let found: TrueForgeRuntimeExtras | undefined; + walkAssistantClientAncestors(client, current => { + try { + const thread: unknown = Reflect.get(current, 'thread'); + if (isThreadAccessor(thread)) { + const threadClient: unknown = Reflect.apply(thread, current, []); + if (threadClient == null || typeof threadClient !== 'object') { + return 'continue'; + } + const getState: unknown = Reflect.get(threadClient, 'getState'); + if (!isGetState(getState)) { + return 'continue'; + } + const state: unknown = Reflect.apply(getState, threadClient, []); + const extras = extrasBrand.tryGet( + state != null && typeof state === 'object' ? Reflect.get(state, 'extras') : undefined, + ); + if (extras != null) { + found = extras; + return 'stop'; + } + } + } catch { + // Nested/readonly clients may lack thread; RootAssistantClient proxy + // throws on missing scope accessors ("thread" / "subscribe"). + } + return 'continue'; + }); + return found; +} + +export function getTrueForgeExtras(client: AssistantClient): TrueForgeRuntimeExtras { + const extras = tryGetTrueForgeExtras(client); + if (extras == null) { + throw new Error('The current thread is not backed by the useTrueForgeAgentRuntime runtime.'); + } + return extras; +} + +function subscribeClientChain(client: AssistantClient): SubscribeFn { + return onStoreChange => { + const unsubs: (() => void)[] = []; + walkAssistantClientAncestors(client, current => { + try { + const subscribe: unknown = Reflect.get(current, 'subscribe'); + if (isSubscribe(subscribe)) { + const unsubscribe: unknown = Reflect.apply(subscribe, current, [onStoreChange]); + if (typeof unsubscribe === 'function') { + unsubs.push(() => { + Reflect.apply(unsubscribe, undefined, []); + }); + } + } + return 'continue'; + } catch { + // RootAssistantClient proxy — no further usable ancestors. + return 'stop'; + } + }); + return () => { + for (const unsub of unsubs) { + unsub(); + } + }; + }; +} + +/** + * Resolves TrueForge extras from the nearest ancestor runtime, including + * inside nested readonly sub-agent renderers (`PartPrimitive.Messages`). + */ +export function useTrueForgeRuntimeExtras(): TrueForgeRuntimeExtras | undefined { + const aui = useAui(); + const subscribe = useCallback(subscribeClientChain(aui), [aui]); + const getSnapshot = useCallback(() => tryGetTrueForgeExtras(aui), [aui]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +function useTrueForgeExtrasApi(): TrueForgeRuntimeExtras; +function useTrueForgeExtrasApi(select: (extras: TrueForgeRuntimeExtras) => S, fallback?: S): S; +function useTrueForgeExtrasApi(select?: (extras: TrueForgeRuntimeExtras) => S, fallback?: S): unknown { + const extras = useTrueForgeRuntimeExtras(); + const hasFallback = arguments.length >= 2; + if (extras == null) { + if (hasFallback) { + return fallback; + } + throw new Error('The current thread is not backed by the useTrueForgeAgentRuntime runtime.'); + } + return select != null ? select(extras) : extras; +} + +/** + * Brand + provide/tryGet from assistant-ui; get/use walk ancestor AUI clients so + * nested readonly sub-agent threads still resolve root TrueForge extras. + */ +export const trueForgeExtras = { + provide: extrasBrand.provide, + is: extrasBrand.is, + tryGet: extrasBrand.tryGet, + get: getTrueForgeExtras, + use: useTrueForgeExtrasApi, +}; diff --git a/packages/assistant-ui-runtime/src/trueforgeOwnedSessionsThreadListAdapter.ts b/packages/assistant-ui-runtime/src/trueforgeOwnedSessionsThreadListAdapter.ts new file mode 100644 index 000000000..0fe2f6ed5 --- /dev/null +++ b/packages/assistant-ui-runtime/src/trueforgeOwnedSessionsThreadListAdapter.ts @@ -0,0 +1,71 @@ +import type { RemoteThreadListAdapter } from '@assistant-ui/core'; + +import type { AgentChatServer } from './server/types.js'; +import { sessionListStartTimestamp } from './sessionListStartTimestamp.js'; +import { sessionDisplayTitle, sessionToThreadMetadata } from './sessionThreadMetadata.js'; + +const THREAD_LIST_PAGE_SIZE = 20; + +/** + * Read-only thread-list adapter backed by `AgentChatServer.listSessions`. + */ +export function createTrueForgeOwnedSessionsThreadListAdapter(options: { + server: AgentChatServer; + /** When set, filters `listSessions` by this agent id. Omit for all chats. */ + listSessionsAgentId?: string; + /** Restrict history to sessions created by the authenticated subject. */ + listSessionsCreatedByMe?: boolean; +}): RemoteThreadListAdapter { + const { server, listSessionsAgentId, listSessionsCreatedByMe = false } = options; + + return { + async list({ after } = {}) { + const page = await server.listSessions({ + ...(listSessionsAgentId != null ? { agentId: listSessionsAgentId } : {}), + createdByMe: listSessionsCreatedByMe, + limit: THREAD_LIST_PAGE_SIZE, + ...(after == null ? {} : { pageToken: after }), + startTimestamp: sessionListStartTimestamp(), + }); + const threads = page.data.map(session => sessionToThreadMetadata(session, sessionDisplayTitle(session))); + return { + threads, + nextCursor: page.nextPageToken ?? undefined, + }; + }, + + initialize() { + return Promise.reject( + new Error('Owned sessions history adapter is read-only; create sessions via a named or draft runtime.'), + ); + }, + + async fetch(remoteId) { + const session = await server.getSession({ sessionId: remoteId }); + return sessionToThreadMetadata(session, sessionDisplayTitle(session)); + }, + + async rename(remoteId, newTitle) { + if (typeof server.renameSession !== 'function') { + return; + } + await server.renameSession({ sessionId: remoteId, title: newTitle }); + }, + archive() { + return Promise.resolve(); + }, + unarchive() { + return Promise.resolve(); + }, + async delete(remoteId) { + if (typeof server.deleteSession !== 'function') { + return; + } + await server.deleteSession({ sessionId: remoteId }); + }, + + generateTitle() { + return Promise.resolve(new ReadableStream()); + }, + }; +} diff --git a/packages/assistant-ui-runtime/src/trueforgeThreadListAdapter.ts b/packages/assistant-ui-runtime/src/trueforgeThreadListAdapter.ts new file mode 100644 index 000000000..33cea7851 --- /dev/null +++ b/packages/assistant-ui-runtime/src/trueforgeThreadListAdapter.ts @@ -0,0 +1,69 @@ +import type { RemoteThreadListAdapter } from '@assistant-ui/core'; + +import type { AgentChatServer } from './server/types.js'; +import { sessionListStartTimestamp } from './sessionListStartTimestamp.js'; +import { getSession } from './sessions.js'; +import { sessionToThreadMetadata } from './sessionThreadMetadata.js'; + +const THREAD_LIST_PAGE_SIZE = 20; + +export function createTrueForgeThreadListAdapter(options: { + server: AgentChatServer; + agentName: string; + /** When set, filters `listSessions` by this agent id. Omit for all chats. */ + listSessionsAgentId?: string; + /** Restrict history to sessions created by the authenticated subject. */ + listSessionsCreatedByMe?: boolean; +}): RemoteThreadListAdapter { + const { server, agentName, listSessionsAgentId, listSessionsCreatedByMe = false } = options; + + return { + async list({ after } = {}) { + const page = await server.listSessions({ + ...(listSessionsAgentId != null ? { agentId: listSessionsAgentId } : {}), + createdByMe: listSessionsCreatedByMe, + limit: THREAD_LIST_PAGE_SIZE, + ...(after == null ? {} : { pageToken: after }), + startTimestamp: sessionListStartTimestamp(), + }); + const threads = page.data.map(session => sessionToThreadMetadata(session, session.title ?? undefined)); + return { + threads, + nextCursor: page.nextPageToken ?? undefined, + }; + }, + + async initialize() { + const session = await server.createSession({ agentName }); + return { remoteId: session.id, externalId: undefined }; + }, + + async fetch(remoteId) { + const session = await getSession(server, remoteId); + return sessionToThreadMetadata(session, session.title ?? undefined); + }, + + async rename(remoteId, newTitle) { + if (typeof server.renameSession !== 'function') { + return; + } + await server.renameSession({ sessionId: remoteId, title: newTitle }); + }, + archive() { + return Promise.resolve(); + }, + unarchive() { + return Promise.resolve(); + }, + async delete(remoteId) { + if (typeof server.deleteSession !== 'function') { + return; + } + await server.deleteSession({ sessionId: remoteId }); + }, + + generateTitle() { + return Promise.resolve(new ReadableStream()); + }, + }; +} diff --git a/packages/assistant-ui-runtime/src/turnEventHelpers.ts b/packages/assistant-ui-runtime/src/turnEventHelpers.ts new file mode 100644 index 000000000..0c317b541 --- /dev/null +++ b/packages/assistant-ui-runtime/src/turnEventHelpers.ts @@ -0,0 +1,71 @@ +import type { Turn } from './server/index.js'; + +import { buildMcpAuthTextParts, findMcpAuthRequired, mcpAuthAssistantStatus, mcpAuthMessageCustom } from './mcpAuth.js'; +import type { AssistantContentPart } from './modelMessageContent.js'; +import { findApprovalRequiredInTurn, toolApprovalMessageCustom, toolApprovalStatus } from './toolApproval.js'; +import { findResponseRequiredInTurn, toolResponseMessageCustom, toolResponseStatus } from './toolResponse.js'; +import type { TurnStreamUpdate } from './turnStreamUpdate.js'; + +function buildToolApprovalUpdate(content: AssistantContentPart[], turn: Pick): TurnStreamUpdate { + const pendingApproval = findApprovalRequiredInTurn(turn); + if (pendingApproval == null) { + return { content }; + } + return { + content, + status: toolApprovalStatus(), + metadata: { custom: toolApprovalMessageCustom(pendingApproval.threadId) }, + }; +} + +function buildToolResponseUpdate(update: TurnStreamUpdate, turn: Pick): TurnStreamUpdate { + const pendingResponse = findResponseRequiredInTurn(turn); + if (pendingResponse == null) { + return update; + } + return { + ...update, + content: update.content, + status: toolResponseStatus(), + metadata: { + custom: { + ...update.metadata?.custom, + ...toolResponseMessageCustom(pendingResponse.threadId), + }, + }, + }; +} + +export function appendToolResponseToTurnContent(update: TurnStreamUpdate, turn: Pick): TurnStreamUpdate { + if (update.status?.type === 'requires-action') { + return update; + } + return buildToolResponseUpdate(update, turn); +} + +export function appendToolApprovalToTurnContent(update: TurnStreamUpdate, turn: Pick): TurnStreamUpdate { + if (update.status?.type === 'requires-action') { + return update; + } + const approvalUpdate = buildToolApprovalUpdate(update.content, turn); + return appendToolResponseToTurnContent(approvalUpdate, turn); +} + +export function appendMcpAuthToTurnContent( + content: AssistantContentPart[], + turn: Pick, +): TurnStreamUpdate { + const pendingMcpAuth = findMcpAuthRequired(turn.state.status === 'done' ? turn.state.requiredActions : undefined); + if (pendingMcpAuth == null) { + return appendToolApprovalToTurnContent({ content }, turn); + } + + return appendToolApprovalToTurnContent( + { + content: [...content, ...buildMcpAuthTextParts(pendingMcpAuth.mcpServers)], + status: mcpAuthAssistantStatus(), + metadata: { custom: mcpAuthMessageCustom(pendingMcpAuth.mcpServers) }, + }, + turn, + ); +} diff --git a/packages/assistant-ui-runtime/src/turnStreamUpdate.ts b/packages/assistant-ui-runtime/src/turnStreamUpdate.ts new file mode 100644 index 000000000..2a99a5615 --- /dev/null +++ b/packages/assistant-ui-runtime/src/turnStreamUpdate.ts @@ -0,0 +1,11 @@ +import type { MessageStatus } from '@assistant-ui/core'; + +import type { AssistantContentPart } from './modelMessageContent.js'; + +export interface TurnStreamUpdate { + content: AssistantContentPart[]; + status?: MessageStatus; + metadata?: { + custom?: Record; + }; +} diff --git a/packages/assistant-ui-runtime/src/types.ts b/packages/assistant-ui-runtime/src/types.ts new file mode 100644 index 000000000..883c48bc8 --- /dev/null +++ b/packages/assistant-ui-runtime/src/types.ts @@ -0,0 +1,84 @@ +import type { + AttachmentAdapter, + DictationAdapter, + ExternalStoreSharedOptions, + FeedbackAdapter, + RealtimeVoiceAdapter, + SpeechSynthesisAdapter, +} from '@assistant-ui/core'; + +import type { AgentChatServer, AgentSpec } from './server/types.js'; + +export interface NamedAgentConfig { + mode: 'named'; + agentName: string; +} + +export interface DraftAgentConfig { + mode: 'draft'; + defaultAgentSpec: AgentSpec; + onAgentSpecChange?: ((spec: AgentSpec) => void) | undefined; +} + +export type TrueForgeAgentConfig = NamedAgentConfig | DraftAgentConfig; + +type TrueForgeAgentRuntimeBaseOptions = ExternalStoreSharedOptions & { + server: AgentChatServer; + initialSessionId?: string | undefined; + threadId?: string | undefined; + onThreadIdChange?: ((threadId: string | undefined) => void) | undefined; + onError?: ((error: unknown) => void) | undefined; + /** + * Optional filter forwarded to `listSessions({ agentId })`. + * Omit for all chats; hosts that key agents by name pass that name as the id. + */ + listSessionsAgentId?: string | undefined; + /** Restrict thread history to sessions created by the authenticated subject. */ + listSessionsCreatedByMe?: boolean | undefined; + adapters?: + | { + attachments?: AttachmentAdapter | undefined; + speech?: SpeechSynthesisAdapter | undefined; + dictation?: DictationAdapter | undefined; + voice?: RealtimeVoiceAdapter | undefined; + feedback?: FeedbackAdapter | undefined; + } + | undefined; +}; + +export type UseTrueForgeAgentRuntimeOptions = TrueForgeAgentRuntimeBaseOptions & { + /** Discriminated agent source. Omit when using legacy `agentName`. */ + agent?: TrueForgeAgentConfig | undefined; + /** Legacy named-agent shorthand. Prefer `agent: { mode: "named", agentName }`. */ + agentName?: string | undefined; +}; + +export type ResolvedTrueForgeAgentRuntimeOptions = TrueForgeAgentRuntimeBaseOptions & { + agent: TrueForgeAgentConfig; +}; + +export function resolveTrueForgeAgentConfig( + options: Pick, +): TrueForgeAgentConfig { + if (options.agent != null) { + if (options.agent.mode === 'named' && options.agentName != null) { + return { mode: 'named', agentName: options.agentName }; + } + return options.agent; + } + if (options.agentName != null) { + return { mode: 'named', agentName: options.agentName }; + } + throw new Error('useTrueForgeAgentRuntime requires `agent` or legacy `agentName`.'); +} + +export function resolveTrueForgeAgentRuntimeOptions( + options: UseTrueForgeAgentRuntimeOptions, +): ResolvedTrueForgeAgentRuntimeOptions { + const agent = resolveTrueForgeAgentConfig(options); + + return { + ...options, + agent, + }; +} diff --git a/packages/assistant-ui-runtime/src/useTrueForgeAgentMessages.ts b/packages/assistant-ui-runtime/src/useTrueForgeAgentMessages.ts new file mode 100644 index 000000000..587bae9af --- /dev/null +++ b/packages/assistant-ui-runtime/src/useTrueForgeAgentMessages.ts @@ -0,0 +1,1138 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + McpAuthRequiredEvent, + ToolApprovalRequiredEvent, + ToolResponseRequiredEvent, + Turn, + TurnInputItem, + TurnStateDone, +} from './server/index.js'; +import type { AgentChatServer } from './server/types.js'; + +import { ROOT_THREAD_ID } from './constants.js'; +import { + buildEditedUserMessageContent, + buildSnapshotThroughTurn, + computeGroupRootBaseline, + extractTurnUserMessageContent, + prependOlderSessionHistory, + projectSessionMessages, + resolveGatewayBranchPreviousTurnIdForTurn, + rootModelMessageIdsSinceBaseline, + userMessageContentToText, + type UserMessageContent, +} from './convertTurnMessages.js'; +import { extractTurnUserText } from './extractTurnUserText.js'; +import { loadSessionSnapshot } from './loadSessionSnapshot.js'; +import { MCP_AUTH_RESUME_RUN_CUSTOM_KEY } from './mcpAuth.js'; +import { isMcpServerAuthInfoList } from './messageCustomMetadata.js'; +import { + collectRequiredActionInputs, + findPausedAssistantMessage, + messageHasPendingRequiredActions, + type RequiredActionInput, +} from './requiredActionInputs.js'; +import { + createEmptySessionSnapshot, + replaceSessionSnapshot, + type SessionSnapshot, + type SessionTurnRecord, +} from './sessionSnapshot.js'; +import { resumeTurnStream, streamTurnContent } from './streamTurn.js'; +import { TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY, type RespondToToolApprovalOptions } from './toolApproval.js'; +import { + applyUserToolResponsesToFold, + TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY, + type RespondToToolResponseOptions, +} from './toolResponse.js'; +import type { TurnStreamUpdate } from './turnStreamUpdate.js'; + +export interface UseTrueForgeAgentMessagesOptions { + server: AgentChatServer; + sessionId: string | undefined; + /** When true the thread is the currently selected (main) thread. */ + isMain?: boolean | undefined; + /** URL-selected session may load before the thread list marks it as main. */ + isInitialSession?: boolean | undefined; + onError?: ((error: unknown) => void) | undefined; + initializeSession?: () => Promise<{ + remoteId: string; + externalId: string | undefined; + }>; + /** Maps a thread `remoteId` to the gateway session id used for turns. */ + resolveConversationSessionId?: (remoteId: string) => Promise; + /** + * Optional per-turn headers for createTurn. Invoked once per `sendTurn` after + * the session is resolved; return value is forwarded to `turn.execute`. + */ + getTurnHeaders?: () => Promise | undefined>; +} + +export type SendTurnOptions = + | { + userMessage: UserMessageContent; + previousTurnId?: string | null; + /** Invoked only when the user turn fails before the gateway registers it. */ + onPreTurnFailure?: () => void; + /** + * When branching (edit/reset), the already-rewound history to send from. + * Applied atomically with `pendingUser` so a stale React snapshot cannot + * keep pre-branch turns while the new user message is appended. + */ + branchFromSnapshot?: SessionSnapshot; + /** Original history restored when a branch fails before turn.created. */ + branchRollbackSnapshot?: SessionSnapshot; + } + | { inputs: RequiredActionInput[] } + | { resumeMcpAuth: true }; + +function buildCompletedTurnState( + completedAt: string, + requiredActions: TurnStateDone['requiredActions'] = [], +): TurnStateDone { + return { + status: 'done', + requiredActions, + completedAt, + }; +} + +/** + * Reconstructs the pending required actions a paused in-flight update carried, + * so the pause survives `commitActiveStream`'s synthetic "done" state. + * + * `commitActiveStream` fabricates a `TurnStateDone` for the just-finished + * stream, and the projection derives an assistant message's `requires-action` + * status from `turn.state.requiredActions` (see `findApprovalRequiredInTurn` / + * `findResponseRequiredInTurn` / `findMcpAuthRequired`). If we returned an empty + * list here, the committed turn would look "complete", the projected message + * would lose its `requires-action` status, and `findPausedAssistantMessage` + * (used by `trySendCollectedRequiredActions`) would never see it — so answering + * a tool approval or an `ask_user_question` would never send the resume turn. + * + * The paused update already carries the pause state: `status` is + * `requires-action` and `metadata.custom` holds the pending thread id(s) (and, + * for MCP, the server list). Only the thread id is read downstream, so an empty + * `toolCalls` list is sufficient here — the resume inputs are collected from the + * message content, not from these reconstructed actions. + */ +export function requiredActionsFromActiveUpdate(update: TurnStreamUpdate): TurnStateDone['requiredActions'] { + const custom = update.metadata?.custom; + const requiredActions: TurnStateDone['requiredActions'] = []; + const createdAt = new Date().toISOString(); + + if (custom?.['pendingMcpAuth'] === true && isMcpServerAuthInfoList(custom['mcpServers'])) { + const mcpAuthRequired: McpAuthRequiredEvent = { + type: 'mcp.auth_required', + id: crypto.randomUUID(), + createdAt, + mcpServers: custom['mcpServers'], + }; + requiredActions.push(mcpAuthRequired); + } + + if (update.status?.type === 'requires-action') { + const approvalThreadId = custom?.[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]; + if (typeof approvalThreadId === 'string') { + const approvalRequired: ToolApprovalRequiredEvent = { + type: 'tool.approval_required', + id: crypto.randomUUID(), + createdAt, + threadId: approvalThreadId, + toolCalls: [], + }; + requiredActions.push(approvalRequired); + } + + const responseThreadId = custom?.[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]; + if (typeof responseThreadId === 'string') { + const responseRequired: ToolResponseRequiredEvent = { + type: 'tool.response_required', + id: crypto.randomUUID(), + createdAt, + threadId: responseThreadId, + toolCalls: [], + }; + requiredActions.push(responseRequired); + } + } + + return requiredActions; +} + +function buildUserTurnInput(content: UserMessageContent): TurnInputItem { + return { type: 'user.message', content }; +} + +function appendTurnInputs( + base: TurnInputItem[] | undefined, + continuationInputs?: RequiredActionInput[], +): TurnInputItem[] { + const existingInputs = base ?? []; + if (continuationInputs == null || continuationInputs.length === 0) { + return existingInputs; + } + return [...existingInputs, ...continuationInputs]; +} + +function cancelScheduledAnimationFrame(frame: number | null): void { + if (frame != null) { + cancelAnimationFrame(frame); + } +} + +function commitActiveStream(snapshot: SessionSnapshot, continuationInputs?: RequiredActionInput[]): SessionSnapshot { + const active = snapshot.activeStream; + if (active?.streamComplete !== true) { + return snapshot; + } + + const activeSandboxIdValue = active.update.metadata?.custom?.['sandboxId']; + const activeSandboxId = typeof activeSandboxIdValue === 'string' ? activeSandboxIdValue : undefined; + + const completedState = buildCompletedTurnState( + new Date().toISOString(), + requiredActionsFromActiveUpdate(active.update), + ); + const baseline = snapshot.groupRootBaseline ?? computeGroupRootBaseline(snapshot.turns); + const rootModelMessageIds = rootModelMessageIdsSinceBaseline(snapshot.fold, baseline); + + const lastTurn = snapshot.turns.at(-1); + if (lastTurn?.id === active.turnId) { + return replaceSessionSnapshot(snapshot, { + turns: snapshot.turns.map(turn => + turn.id === active.turnId + ? { + ...turn, + state: completedState, + input: appendTurnInputs(turn.input, continuationInputs), + rootModelMessageIds, + ...(activeSandboxId != null ? { sandboxId: activeSandboxId } : {}), + } + : turn, + ), + pendingUser: undefined, + // Custom stream adapters may yield projected content without fold events. + // Keep that completed projection until the next stream replaces it. + ...(rootModelMessageIds.length > 0 ? { activeStream: undefined } : {}), + }); + } + + const record: SessionTurnRecord = { + id: active.turnId, + createdAt: snapshot.pendingUser?.createdAt.toISOString() ?? new Date().toISOString(), + state: completedState, + input: appendTurnInputs( + snapshot.pendingUser ? [buildUserTurnInput(snapshot.pendingUser.content)] : [], + continuationInputs, + ), + ...(snapshot.pendingUser ? { userText: userMessageContentToText(snapshot.pendingUser.content) } : {}), + rootModelMessageIds, + ...(activeSandboxId != null ? { sandboxId: activeSandboxId } : {}), + }; + + return replaceSessionSnapshot(snapshot, { + turns: [...snapshot.turns, record], + pendingUser: undefined, + ...(rootModelMessageIds.length > 0 ? { activeStream: undefined } : {}), + }); +} + +/** Bounds the older-history page-ins a sandbox lookup may trigger. */ +const MAX_SANDBOX_HISTORY_PAGE_INS = 20; + +/** + * sandboxId in effect as of `turnId`. An artifact can only come from a sandbox + * created at or before its own turn, so scan records backward from that turn; + * an unknown turn (e.g. projected only from the active stream) scans the whole + * loaded window. + */ +function findSandboxIdInSnapshot(snapshot: SessionSnapshot, turnId: string): string | undefined { + const active = snapshot.activeStream; + if (active?.turnId === turnId) { + const sandboxId = active.update.metadata?.custom?.['sandboxId']; + if (typeof sandboxId === 'string') { + return sandboxId; + } + } + const turns = snapshot.turns; + const turnIndex = turns.findIndex(turn => turn.id === turnId); + for (let i = turnIndex === -1 ? turns.length - 1 : turnIndex; i >= 0; i--) { + const sandboxId = turns[i]?.sandboxId; + if (sandboxId != null) { + return sandboxId; + } + } + return undefined; +} + +async function resolveActiveSessionId( + remoteId: string, + resolveConversationSessionId?: (remoteId: string) => Promise, +): Promise { + if (resolveConversationSessionId != null) { + return resolveConversationSessionId(remoteId); + } + return remoteId; +} + +function resolveTurnInput(snapshot: SessionSnapshot, turnId: string): TurnInputItem[] | undefined { + const turnRecord = snapshot.turns.find(turn => turn.id === turnId); + if (turnRecord?.input != null) { + return turnRecord.input; + } + if (snapshot.pendingUser?.turnId === turnId) { + return [{ type: 'user.message', content: snapshot.pendingUser.content }]; + } + return undefined; +} + +export function useTrueForgeAgentMessages({ + server, + sessionId, + isMain, + isInitialSession, + onError, + initializeSession, + resolveConversationSessionId, + getTurnHeaders, +}: UseTrueForgeAgentMessagesOptions) { + const [snapshot, setSnapshot] = useState(createEmptySessionSnapshot); + const [isRunning, setIsRunning] = useState(false); + // Existing sessions have history pending from the first render. Starting at + // false causes consumers to briefly render an empty thread before the load + // effect runs and flips this flag to true. + const [isLoading, setIsLoading] = useState(sessionId != null && (isMain !== false || isInitialSession === true)); + const [isLoadingOlderHistory, setIsLoadingOlderHistory] = useState(false); + const [loadRetryTrigger, setLoadRetryTrigger] = useState(0); + const [resumeUnavailable, setResumeUnavailable] = useState(false); + + const snapshotRef = useRef(snapshot); + snapshotRef.current = snapshot; + // Live session id — stale loadOlderHistory / resolveSandboxIdForTurn + // closures compare against this so a post-switch iteration cannot merge + // session A's pages onto session B's snapshot (generation alone is not + // enough: a late call captures B's generation while still closed over A). + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; + const loadOlderInflightRef = useRef | null>(null); + + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + const resolveConversationSessionIdRef = useRef(resolveConversationSessionId); + resolveConversationSessionIdRef.current = resolveConversationSessionId; + const initializeSessionRef = useRef(initializeSession); + initializeSessionRef.current = initializeSession; + const getTurnHeadersRef = useRef(getTurnHeaders); + getTurnHeadersRef.current = getTurnHeaders; + + const createdAtByMessageIdRef = useRef(new Map()); + const abortControllerRef = useRef(null); + const activeRunRef = useRef | null>(null); + // Mirrors `resumeUnavailable` for `cancel`, which reads it outside render. + const resumeUnavailableRef = useRef(false); + const runningTurnRef = useRef(undefined); + const loadGenerationRef = useRef(0); + const streamGenerationRef = useRef(0); + const lazilyCreatedSessionIdRef = useRef(undefined); + const initialLoadStartedForRef = useRef(undefined); + const skipInitialPromotionLoadForRef = useRef(undefined); + + /** + * A turn is running that this server cannot stream. Nothing will deliver its + * result to this client, so the UI shows a waiting state until the run is + * cancelled or the session is reloaded. + */ + const markResumeUnavailable = useCallback((value: boolean) => { + resumeUnavailableRef.current = value; + setResumeUnavailable(value); + }, []); + + const projectOptions = useMemo( + () => ({ + getCreatedAt: (messageId: string, fallback: Date, replace = false) => { + const cache = createdAtByMessageIdRef.current; + const existing = cache.get(messageId); + if (existing != null && (!replace || existing.getTime() === fallback.getTime())) { + return existing; + } + cache.set(messageId, fallback); + return fallback; + }, + }), + [], + ); + + const messages = useMemo(() => projectSessionMessages(snapshot, projectOptions), [snapshot, projectOptions]); + + const runStream = useCallback( + ( + createStream: (signal: AbortSignal) => AsyncGenerator, + /** + * A mutable ref whose `.current` is the turn ID to use for + * `activeStream.turnId`. Callers that capture the gateway turn ID + * via `onTurnIdAvailable` update this ref in-place so that both the + * pending-update flush and `commitActiveStream` always see the real + * gateway ID rather than the locally-generated optimistic one. + */ + turnIdRef: { current: string }, + isContinuation: boolean, + ): Promise => { + const streamGeneration = ++streamGenerationRef.current; + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + setIsRunning(true); + // This stream's `finally` owns the running flag from here on. + markResumeUnavailable(false); + + const run = (async () => { + // Sub-agent turns can emit 100+ stream events per frame. Coalesce to one + // setSnapshot per animation frame so assistant-ui does not remount the whole + // message tree (UI hang). The buffer belongs to this stream only. + let pendingStreamUpdate: { + update: TurnStreamUpdate; + isContinuation: boolean; + } | null = null; + let streamUpdateRaf: number | null = null; + + const flushPendingStreamUpdate = () => { + streamUpdateRaf = null; + const pending = pendingStreamUpdate; + pendingStreamUpdate = null; + if (pending == null || streamGeneration !== streamGenerationRef.current) { + return; + } + const { update, isContinuation: pendingIsContinuation } = pending; + setSnapshot(prev => + replaceSessionSnapshot(prev, { + activeStream: { + // Read from the ref so we always use the latest ID, + // including any gateway ID that arrived after the RAf + // was scheduled. + turnId: turnIdRef.current, + update, + isContinuation: pendingIsContinuation, + }, + }), + ); + }; + + const applyStreamUpdate = (update: TurnStreamUpdate) => { + pendingStreamUpdate = { update, isContinuation }; + streamUpdateRaf ??= requestAnimationFrame(flushPendingStreamUpdate); + }; + + try { + for await (const update of createStream(abortController.signal)) { + if (abortController.signal.aborted) { + return; + } + applyStreamUpdate(update); + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + onErrorRef.current?.(error); + throw error; + } finally { + cancelScheduledAnimationFrame(streamUpdateRaf); + if (streamGeneration === streamGenerationRef.current) { + flushPendingStreamUpdate(); + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + setIsRunning(false); + setSnapshot(prev => { + if (prev.activeStream == null) { + return prev; + } + const marked = replaceSessionSnapshot(prev, { + activeStream: { + ...prev.activeStream, + streamComplete: true, + }, + requiredActions: { + approvals: new Map(), + toolResponses: new Map(), + }, + }); + return commitActiveStream(marked); + }); + } + } + })(); + + activeRunRef.current = run; + void run + .catch(() => undefined) + .finally(() => { + if (activeRunRef.current === run) { + activeRunRef.current = null; + } + }); + return run; + }, + [onError], + ); + + const load = useCallback(async () => { + // Reading the retry counter intentionally makes retryLoad recreate this callback. + void loadRetryTrigger; + if (sessionId == null) { + createdAtByMessageIdRef.current = new Map(); + setSnapshot(createEmptySessionSnapshot()); + return; + } + + // Allow the URL-selected session one early load before assistant-ui marks + // it main. Suppress only that first promotion; later selections still reload. + const isEarlyInitialLoad = + isMain === false && isInitialSession === true && initialLoadStartedForRef.current !== sessionId; + if (isMain === false) { + if (!isEarlyInitialLoad) { + return; + } + initialLoadStartedForRef.current = sessionId; + skipInitialPromotionLoadForRef.current = sessionId; + } else if (isMain === true && skipInitialPromotionLoadForRef.current === sessionId) { + skipInitialPromotionLoadForRef.current = undefined; + return; + } + if (isInitialSession === true) { + initialLoadStartedForRef.current = sessionId; + } + + // When we are loading a *different* session the user has navigated away + // from the lazily-created one — clear the guard so navigating back to it + // later triggers a proper reload instead of silently skipping. + if (lazilyCreatedSessionIdRef.current != null && sessionId !== lazilyCreatedSessionIdRef.current) { + lazilyCreatedSessionIdRef.current = undefined; + } + + if (sessionId === lazilyCreatedSessionIdRef.current) { + return; + } + + const generation = ++loadGenerationRef.current; + ++streamGenerationRef.current; + setIsRunning(false); + markResumeUnavailable(false); + abortControllerRef.current?.abort(); + loadOlderInflightRef.current = null; + createdAtByMessageIdRef.current = new Map(); + setSnapshot(createEmptySessionSnapshot()); + setIsLoading(true); + setIsLoadingOlderHistory(false); + + try { + const conversationSessionId = await resolveActiveSessionId(sessionId, resolveConversationSessionIdRef.current); + const loadedSnapshot = await loadSessionSnapshot(server, conversationSessionId, snap => { + if (generation === loadGenerationRef.current) { + setSnapshot(snap); + } + }); + if (generation !== loadGenerationRef.current) { + return; + } + + createdAtByMessageIdRef.current = new Map(); + setSnapshot(loadedSnapshot); + runningTurnRef.current = loadedSnapshot.runningTurn; + + // History (and any seeded pendingUser for the in-flight turn) is + // ready — clear loading before resuming. Awaiting the subscribe + // stream here previously kept isLoading true for the entire + // backend run, so reconnect UIs stayed on shimmers even though + // subscribe was already live. + setIsLoading(false); + + if (loadedSnapshot.runningTurn != null) { + const turn = loadedSnapshot.runningTurn; + + // subscribeToTurn is optional, so a server can leave us without + // a reconnect path. The turn still runs on the backend: show the + // loaded history as running and let the host explain the gap. + if (server.subscribeToTurn == null) { + setIsRunning(true); + markResumeUnavailable(true); + return; + } + + const isContinuation = extractTurnUserText(turn.input) === undefined; + // TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers. + // Use loadedSnapshot directly — snapshotRef.current still points at + // the empty snapshot cleared above until the setSnapshot(loadedSnapshot) + // call re-renders. + void runStream( + signal => + resumeTurnStream( + server, + conversationSessionId, + turn.id, + loadedSnapshot.fold, + signal, + undefined, + loadedSnapshot.groupRootBaseline, + ), + { current: turn.id }, + isContinuation, + ).catch(() => undefined); + } + } catch (error) { + if (generation === loadGenerationRef.current) { + if (isEarlyInitialLoad) { + // Allow retryLoad while still backgrounded (before isMain promotion). + initialLoadStartedForRef.current = undefined; + skipInitialPromotionLoadForRef.current = undefined; + } + onErrorRef.current?.(error); + } + throw error; + } finally { + if (generation === loadGenerationRef.current) { + setIsLoading(false); + } + } + }, [server, runStream, sessionId, loadRetryTrigger, isMain, isInitialSession]); + + useEffect(() => { + void load().catch(() => undefined); + }, [load]); + + const sendTurn = useCallback( + async (options: SendTurnOptions) => { + // A turn.created event means the gateway registered the user message. + // Errors after that point must keep the message in chat. + const gatewayTurnAccepted = { current: false }; + let pendingUserWasSet = false; + let runStreamStarted = false; + let pendingUserTurnId: string | undefined; + + try { + let activeSessionId = sessionId; + if (activeSessionId == null) { + if (initializeSessionRef.current == null) { + throw new Error('Cannot send a turn without an active session.'); + } + const { remoteId } = await initializeSessionRef.current(); + activeSessionId = remoteId; + lazilyCreatedSessionIdRef.current = remoteId; + } + + const conversationSessionId = await resolveActiveSessionId( + activeSessionId, + resolveConversationSessionIdRef.current, + ); + const turnHeaders = await getTurnHeadersRef.current?.(); + const streamHeaders = turnHeaders != null ? { headers: turnHeaders } : {}; + const isContinuation = 'inputs' in options || ('resumeMcpAuth' in options && options.resumeMcpAuth); + const continuationTurnId = snapshotRef.current.activeStream?.turnId; + const turnId = isContinuation + ? // A paused stream is usually already committed (commitActiveStream + // cleared activeStream), so continue under the committed turn's + // real id. Never mint a local id for a continuation — it leaks to + // the backend via `custom.turnId` (sandbox downloads, edit/retry) + // as a turn the gateway has never heard of. + (continuationTurnId ?? snapshotRef.current.turns.at(-1)?.id ?? crypto.randomUUID()) + : crypto.randomUUID(); + // First turns must send previousTurnId: "none". + const isFirstTurnInSession = + 'userMessage' in options && + options.previousTurnId === undefined && + snapshotRef.current.turns.length === 0 && + snapshotRef.current.pendingUser == null && + snapshotRef.current.activeStream == null; + + // Mutable ref so runStream always reads the latest ID. The local + // placeholder (optimistic `generateId()` or the previous turn's id + // for continuations) is replaced with the gateway-assigned ID once + // the first SSE event arrives. + const turnIdRef = { current: turnId }; + + // Renames the placeholder ID to the gateway turn ID so that + // edit/retry and sandbox downloads can resolve the turn via the + // gateway. Wired into every stream branch — continuation turns + // (approval / ask-user / MCP-auth resumes) are new gateway turns + // too, and committing them under a local id corrupts the record. + const handleGatewayTurnId = (gatewayTurnId: string) => { + const oldId = turnIdRef.current; + // turn.created proves the gateway registered the message. + gatewayTurnAccepted.current = true; + if (gatewayTurnId === oldId) { + return; + } + turnIdRef.current = gatewayTurnId; + // Rename in the ref immediately so any synchronous read + // (e.g. commitActiveStream) sees the correct ID. + const renamePendingUser = (prev: SessionSnapshot): SessionSnapshot => { + if (prev.pendingUser?.turnId !== oldId) { + return prev; + } + return replaceSessionSnapshot(prev, { + pendingUser: { + ...prev.pendingUser, + turnId: gatewayTurnId, + }, + }); + }; + snapshotRef.current = renamePendingUser(snapshotRef.current); + setSnapshot(renamePendingUser); + }; + + if ('inputs' in options) { + applyUserToolResponsesToFold(snapshotRef.current.fold, options.inputs); + } + + const branchBase = 'userMessage' in options ? options.branchFromSnapshot : undefined; + + let groupRootBaseline: readonly string[] | undefined; + + if (branchBase != null && 'userMessage' in options) { + // Atomic apply: never merge pendingUser onto a stale React `prev` + // that still holds pre-branch turns (edit would show old + new). + const rootBucket = branchBase.fold.threads.get(ROOT_THREAD_ID); + groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])]; + const nextSnapshot = replaceSessionSnapshot(branchBase, { + pendingUser: { + turnId, + content: options.userMessage, + createdAt: new Date(), + }, + activeStream: undefined, + groupRootBaseline, + }); + snapshotRef.current = nextSnapshot; + setSnapshot(nextSnapshot); + pendingUserWasSet = true; + pendingUserTurnId = turnId; + } else { + setSnapshot(prev => commitActiveStream(prev, 'inputs' in options ? options.inputs : undefined)); + + if ('userMessage' in options) { + const rootBucket = snapshotRef.current.fold.threads.get(ROOT_THREAD_ID); + groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])]; + setSnapshot(prev => { + const next = replaceSessionSnapshot(prev, { + pendingUser: { + turnId, + content: options.userMessage, + createdAt: new Date(), + }, + activeStream: undefined, + groupRootBaseline, + }); + snapshotRef.current = next; + return next; + }); + pendingUserWasSet = true; + pendingUserTurnId = turnId; + } else { + groupRootBaseline = + snapshotRef.current.groupRootBaseline ?? computeGroupRootBaseline(snapshotRef.current.turns); + } + } + + runStreamStarted = true; + await runStream( + signal => { + if ('inputs' in options) { + return streamTurnContent( + server, + conversationSessionId, + snapshotRef.current.fold, + { inputs: options.inputs, ...streamHeaders }, + signal, + groupRootBaseline, + handleGatewayTurnId, + ); + } + if ('resumeMcpAuth' in options) { + return streamTurnContent( + server, + conversationSessionId, + snapshotRef.current.fold, + { resumeMcpAuth: true, ...streamHeaders }, + signal, + groupRootBaseline, + handleGatewayTurnId, + ); + } + return streamTurnContent( + server, + conversationSessionId, + snapshotRef.current.fold, + { + userMessage: options.userMessage, + ...(options.previousTurnId !== undefined + ? { previousTurnId: options.previousTurnId ?? 'none' } + : isFirstTurnInSession + ? { previousTurnId: 'none' } + : {}), + ...streamHeaders, + }, + signal, + groupRootBaseline, + handleGatewayTurnId, + ); + }, + turnIdRef, + isContinuation, + ); + } catch (error) { + if ('userMessage' in options && !gatewayTurnAccepted.current) { + const branchRollbackSnapshot = options.branchRollbackSnapshot; + const canRestoreBranch = + branchRollbackSnapshot != null && + (snapshotRef.current === options.branchFromSnapshot || + snapshotRef.current.pendingUser?.turnId === pendingUserTurnId); + if (canRestoreBranch) { + snapshotRef.current = branchRollbackSnapshot; + setSnapshot(branchRollbackSnapshot); + } else if (pendingUserWasSet) { + const clearPendingUser = (previous: SessionSnapshot): SessionSnapshot => { + if (previous.pendingUser?.turnId !== pendingUserTurnId) { + return previous; + } + return replaceSessionSnapshot(previous, { + pendingUser: undefined, + }); + }; + snapshotRef.current = clearPendingUser(snapshotRef.current); + setSnapshot(clearPendingUser); + } + options.onPreTurnFailure?.(); + } + if (!runStreamStarted) { + onErrorRef.current?.(error); + } + throw error; + } + }, + [server, runStream, sessionId], + ); + + const cancel = useCallback(async () => { + // A turn can start before the thread list publishes `remoteId`, and that + // run still has a backend session to stop. + const activeSessionId = sessionId ?? lazilyCreatedSessionIdRef.current; + if (activeSessionId == null) { + abortControllerRef.current?.abort(); + return; + } + const conversationSessionId = await resolveActiveSessionId( + activeSessionId, + resolveConversationSessionIdRef.current, + ); + // Request cancellation but keep consuming the stream. After cancel(), + // the backend gracefully closes the SSE stream: it emits a terminal + // turn.done event and then ends the stream, which lets the active run + // drain to completion on its own instead of being torn down mid-flight. + await server.cancelSession({ sessionId: conversationSessionId }).catch(() => undefined); + // Wait for the in-flight stream to finish draining. No explicit + // reconcile is needed here — the cancelled turn is terminal and local + // state reconciles against the event log on the next session load. + await activeRunRef.current?.catch(() => undefined); + // Nothing drained when the load could not attach a stream, so clear the + // running flag here or the composer stays blocked until a reload. + if (resumeUnavailableRef.current) { + markResumeUnavailable(false); + setIsRunning(false); + } + }, [server, sessionId, markResumeUnavailable]); + + const isRunningRef = useRef(isRunning); + isRunningRef.current = isRunning; + + const trySendCollectedRequiredActions = useCallback( + (nextSnapshot: SessionSnapshot) => { + if (isRunningRef.current) { + return; + } + const projected = projectSessionMessages(nextSnapshot, projectOptions); + const paused = findPausedAssistantMessage(projected); + if (paused == null || messageHasPendingRequiredActions(paused)) { + return; + } + const inputs = collectRequiredActionInputs(paused); + if (inputs.length > 0) { + // sendTurn/runStream already report via onError; swallow to avoid duplicates. + void sendTurn({ inputs }).catch(() => undefined); + } + }, + [projectOptions, sendTurn], + ); + + const respondToToolApproval = useCallback( + (response: RespondToToolApprovalOptions) => { + const prev = snapshotRef.current; + const approvals = new Map(prev.requiredActions.approvals); + approvals.set(response.approvalId, { + approved: response.approved, + ...(response.reason != null ? { reason: response.reason } : {}), + }); + const nextSnapshot = replaceSessionSnapshot(prev, { + requiredActions: { + ...prev.requiredActions, + approvals, + }, + }); + setSnapshot(nextSnapshot); + trySendCollectedRequiredActions(nextSnapshot); + }, + [trySendCollectedRequiredActions], + ); + + const respondToToolResponse = useCallback( + (response: RespondToToolResponseOptions) => { + const prev = snapshotRef.current; + const toolResponses = new Map(prev.requiredActions.toolResponses); + toolResponses.set(response.toolCallId, { content: response.content }); + const nextSnapshot = replaceSessionSnapshot(prev, { + requiredActions: { + ...prev.requiredActions, + toolResponses, + }, + }); + setSnapshot(nextSnapshot); + trySendCollectedRequiredActions(nextSnapshot); + }, + [trySendCollectedRequiredActions], + ); + + const resumeRun = useCallback(async () => { + const turn = runningTurnRef.current; + if (turn == null) { + return; + } + if (server.subscribeToTurn == null) { + markResumeUnavailable(true); + return; + } + // TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers. + await runStream( + signal => + resumeTurnStream( + server, + turn.sessionId, + turn.id, + snapshotRef.current.fold, + signal, + undefined, + snapshotRef.current.groupRootBaseline, + ), + { current: turn.id }, + true, + ); + }, [runStream, server]); + + const branchFromTurn = useCallback( + async (turnId: string, userMessage: UserMessageContent) => { + let committed: SessionSnapshot; + let previousTurnId: string; + let rewound: SessionSnapshot; + try { + const activeSessionId = sessionId; + if (activeSessionId == null) { + throw new Error('Cannot branch from a turn without an active session.'); + } + + committed = commitActiveStream(snapshotRef.current); + setSnapshot(committed); + + await cancel(); + + const conversationSessionId = await resolveActiveSessionId( + activeSessionId, + resolveConversationSessionIdRef.current, + ); + previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(server, conversationSessionId, turnId); + // Rewind to the exact parent used for the new branch. Using the + // previous item from listTurns could select an abandoned branch. + rewound = await buildSnapshotThroughTurn( + server, + conversationSessionId, + previousTurnId === 'none' ? null : previousTurnId, + ); + createdAtByMessageIdRef.current = new Map(); + // Keep the ref aligned before awaiting sendTurn so any intermediate + // reads (and the atomic pendingUser apply) see the rewound history. + snapshotRef.current = rewound; + setSnapshot(rewound); + } catch (error) { + // Setup failures never reach sendTurn/runStream reporting. + onErrorRef.current?.(error); + throw error; + } + + // sendTurn/runStream own error reporting for the turn itself. + await sendTurn({ + userMessage, + previousTurnId, + branchFromSnapshot: rewound, + branchRollbackSnapshot: committed, + }); + }, + [cancel, server, sendTurn, sessionId], + ); + + const resetFromTurn = useCallback( + async (turnId: string) => { + const committed = commitActiveStream(snapshotRef.current); + const originalInput = resolveTurnInput(committed, turnId); + if (originalInput == null) { + const error = new Error(`Turn ${turnId} not found in session snapshot`); + onErrorRef.current?.(error); + throw error; + } + const userMessage = extractTurnUserMessageContent(originalInput); + await branchFromTurn(turnId, userMessage); + }, + [branchFromTurn], + ); + + const editFromTurn = useCallback( + async (turnId: string, editedText: string) => { + const committed = commitActiveStream(snapshotRef.current); + const originalInput = resolveTurnInput(committed, turnId); + if (originalInput == null) { + const error = new Error(`Turn ${turnId} not found in session snapshot`); + onErrorRef.current?.(error); + throw error; + } + const userMessage = buildEditedUserMessageContent(editedText, originalInput); + await branchFromTurn(turnId, userMessage); + }, + [branchFromTurn], + ); + + const retryLoad = useCallback(() => { + setLoadRetryTrigger(n => n + 1); + }, []); + + const hasOlderHistory = snapshot.historyPagination?.hasOlder === true; + + const loadOlderHistory = useCallback(async () => { + if (sessionId == null || isMain === false) { + return; + } + const requestedSessionId = sessionId; + // Stale closure from a prior session — do not touch the live snapshot. + if (sessionIdRef.current !== requestedSessionId) { + return; + } + if (loadOlderInflightRef.current != null) { + return loadOlderInflightRef.current; + } + + const current = snapshotRef.current; + if (current.historyPagination?.hasOlder !== true) { + return; + } + if (current.historyPagination.olderPageToken == null) { + return; + } + + const generation = loadGenerationRef.current; + setIsLoadingOlderHistory(true); + + const run = (async () => { + const stillCurrent = () => + generation === loadGenerationRef.current && sessionIdRef.current === requestedSessionId; + try { + const conversationSessionId = await resolveActiveSessionId( + requestedSessionId, + resolveConversationSessionIdRef.current, + ); + if (!stillCurrent()) { + return; + } + const next = await prependOlderSessionHistory(server, conversationSessionId, snapshotRef.current); + if (!stillCurrent()) { + return; + } + // Keep the ref in sync before the next render so awaiting + // callers (e.g. resolveSandboxIdForTurn) see the merged history. + snapshotRef.current = next; + setSnapshot(next); + } catch (error) { + if (stillCurrent()) { + onErrorRef.current?.(error); + } + throw error; + } finally { + if (stillCurrent()) { + setIsLoadingOlderHistory(false); + } + loadOlderInflightRef.current = null; + } + })(); + + loadOlderInflightRef.current = run; + return run; + }, [server, isMain, sessionId]); + + /** + * Resolves the sandbox that was current as of `turnId`, paging in older + * history when the `sandbox.created` reference predates the loaded window + * (deriving it from loaded messages alone caused spurious "No sandbox is + * available yet" failures on long sessions). + */ + const resolveSandboxIdForTurn = useCallback( + async (turnId: string): Promise => { + const generation = loadGenerationRef.current; + const requestedSessionId = sessionId; + const stillCurrent = () => + generation === loadGenerationRef.current && sessionIdRef.current === requestedSessionId; + + if (!stillCurrent()) { + return undefined; + } + + let sandboxId = findSandboxIdInSnapshot(snapshotRef.current, turnId); + // ponytail: bounded linear page-in — the gateway has no direct + // session→sandbox lookup; a backend lookup route is the upgrade path. + for ( + let i = 0; + sandboxId == null && + stillCurrent() && + snapshotRef.current.historyPagination?.hasOlder === true && + i < MAX_SANDBOX_HISTORY_PAGE_INS; + i++ + ) { + await loadOlderHistory(); + if (!stillCurrent()) { + return undefined; + } + sandboxId = findSandboxIdInSnapshot(snapshotRef.current, turnId); + } + return stillCurrent() ? sandboxId : undefined; + }, + [loadOlderHistory, sessionId], + ); + + return { + messages, + isRunning, + resumeUnavailable, + isLoading, + isLoadingOlderHistory, + hasOlderHistory, + loadOlderHistory, + resolveSandboxIdForTurn, + retryLoad, + sendTurn, + cancel, + respondToToolApproval, + respondToToolResponse, + resumeRun, + branchFromTurn, + resetFromTurn, + editFromTurn, + }; +} + +export { findPausedAssistantMessage, MCP_AUTH_RESUME_RUN_CUSTOM_KEY }; diff --git a/packages/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts b/packages/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts new file mode 100644 index 000000000..a7b10e71a --- /dev/null +++ b/packages/assistant-ui-runtime/src/useTrueForgeAgentRuntime.ts @@ -0,0 +1,308 @@ +'use client'; + +import { + pickExternalStoreSharedOptions, + type AppendMessage, + type RemoteThreadListAdapter, + type ToolExecutionStatus, +} from '@assistant-ui/core'; +import { useExternalStoreRuntime, useRemoteThreadListRuntime, useRuntimeAdapters } from '@assistant-ui/core/react'; +import { useAui, useAuiState } from '@assistant-ui/store'; +import type { RefObject } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + collectPendingApprovals, + collectPendingToolResponses, + derivePendingMcpAuth, + deriveSandboxId, +} from './collectPending.js'; +import { + buildUserMessageContent, + extractEditedText, + parseTurnIdFromMessageId, + userMessageContentToText, +} from './convertTurnMessages.js'; +import { createDraftSessionBridge, DRAFT_SESSION_LAST_UPDATED_AT_HEADER } from './draft/draftSessionBridge.js'; +import { createTrueForgeDraftThreadListAdapter } from './draft/trueforgeDraftThreadListAdapter.js'; +import { useDraftAgentSpec } from './draft/useDraftAgentSpec.js'; +import { MCP_AUTH_RESUME_RUN_CUSTOM_KEY } from './mcpAuth.js'; +import { buildSandboxDownloadRequest } from './sandboxDownload.js'; +import type { AgentSpec } from './server/types.js'; +import { trueForgeExtras } from './trueforgeExtras.js'; +import { createTrueForgeThreadListAdapter } from './trueforgeThreadListAdapter.js'; +import type { UseTrueForgeAgentRuntimeOptions } from './types.js'; +import { resolveTrueForgeAgentRuntimeOptions } from './types.js'; +import { useTrueForgeAgentMessages } from './useTrueForgeAgentMessages.js'; + +/** + * Wraps the mode-specific adapter behind a stable object so assistant-ui does + * not treat a draft/named mode switch as an adapter change (which would reset + * loaded thread-list pages). Each call reads the ref, so behavior always + * follows the current mode. + */ +function createDelegatingThreadListAdapter(adapterRef: RefObject): RemoteThreadListAdapter { + return { + list: params => adapterRef.current.list(params), + initialize: threadId => adapterRef.current.initialize(threadId), + fetch: threadId => adapterRef.current.fetch(threadId), + rename: (remoteId, newTitle) => adapterRef.current.rename(remoteId, newTitle), + archive: remoteId => adapterRef.current.archive(remoteId), + unarchive: remoteId => adapterRef.current.unarchive(remoteId), + delete: remoteId => adapterRef.current.delete(remoteId), + generateTitle: (remoteId, messages) => adapterRef.current.generateTitle(remoteId, messages), + }; +} + +function useTrueForgeAgentRuntimeImpl( + options: ReturnType, + pendingAgentSpecRef: RefObject, +) { + const { server, agent, adapters, onError, ...sharedOptions } = options; + + const draftBridgeRef = useRef(agent.mode === 'draft' ? createDraftSessionBridge(server) : null); + + const draftSessionId = useAuiState(state => + agent.mode === 'draft' ? (state.threadListItem.remoteId ?? undefined) : undefined, + ); + const sessionId = useAuiState(state => state.threadListItem.remoteId ?? undefined); + const isMain = useAuiState(state => state.threads.mainThreadId === state.threadListItem.id); + // On a hard refresh, the URL session runtime mounts before assistant-ui + // promotes it to the main thread. Allow that one session to hydrate early. + const isInitialSession = sessionId != null && sessionId === options.initialSessionId; + + const draftSpec = useDraftAgentSpec({ + draftSessionId, + draftBridge: draftBridgeRef.current, + defaultAgentSpec: agent.mode === 'draft' ? agent.defaultAgentSpec : { model: { name: '' } }, + onAgentSpecChange: agent.mode === 'draft' ? agent.onAgentSpecChange : undefined, + onError, + }); + + const takeTurnHeaderTimestampRef = useRef(draftSpec.takeTurnHeaderTimestamp); + takeTurnHeaderTimestampRef.current = draftSpec.takeTurnHeaderTimestamp; + + const getTurnHeaders = useCallback(async () => { + if (agent.mode !== 'draft') { + return undefined; + } + const updatedAt = await takeTurnHeaderTimestampRef.current(); + if (updatedAt == null) { + return undefined; + } + return { [DRAFT_SESSION_LAST_UPDATED_AT_HEADER]: updatedAt }; + }, [agent.mode]); + + const aui = useAui(); + const initializeSession = useCallback(() => aui.threadListItem().initialize(), [aui]); + const runtimeAdapters = useRuntimeAdapters(); + const [, setToolStatuses] = useState>({}); + + const { + messages, + isRunning, + resumeUnavailable, + isLoading, + isLoadingOlderHistory, + hasOlderHistory, + loadOlderHistory, + sendTurn, + cancel, + respondToToolApproval, + respondToToolResponse, + resumeRun, + editFromTurn, + resetFromTurn, + resolveSandboxIdForTurn, + retryLoad, + } = useTrueForgeAgentMessages({ + server, + sessionId, + isMain, + isInitialSession, + onError, + initializeSession, + ...(agent.mode === 'draft' ? { getTurnHeaders } : {}), + }); + + if (agent.mode === 'draft' && draftSpec.agentSpec != null) { + pendingAgentSpecRef.current = draftSpec.agentSpec; + } + + const pendingApprovals = useMemo(() => collectPendingApprovals(messages), [messages]); + const pendingToolResponses = useMemo(() => collectPendingToolResponses(messages), [messages]); + const pendingMcpAuth = useMemo(() => derivePendingMcpAuth(messages), [messages]); + const sandboxId = useMemo(() => deriveSandboxId(messages), [messages]); + + const resumeMcpAuth = useMemo(() => () => sendTurn({ resumeMcpAuth: true }), [sendTurn]); + + const downloadSandboxFile = useCallback( + async ({ turnId, path }: { turnId: string; path: string }) => { + if (server.downloadSandboxFile == null) { + throw new Error('Downloading a sandbox file requires AgentChatServer.downloadSandboxFile.'); + } + // Prefer the sandbox that was current as of this turn (paging in + // older history if its reference predates the loaded window); the + // session-wide latest covers turns projected without a record. + // sandboxId stays best-effort — turn-scoped hosts resolve the + // sandbox from turnId and need no sandboxId at all. + const turnSandboxId = (await resolveSandboxIdForTurn(turnId)) ?? sandboxId; + return await server.downloadSandboxFile( + buildSandboxDownloadRequest({ + sessionId, + turnId, + path, + ...(turnSandboxId != null ? { sandboxId: turnSandboxId } : {}), + }), + ); + }, + [server, sessionId, sandboxId, resolveSandboxIdForTurn], + ); + + const draftExtras = useMemo(() => { + if (agent.mode !== 'draft') { + return null; + } + return { + agentSpec: draftSpec.agentSpec, + draftSessionId: draftSpec.draftSessionId, + isSpecLoading: draftSpec.isSpecLoading, + isSpecSyncing: draftSpec.isSpecSyncing, + specError: draftSpec.specError, + updateAgentSpec: draftSpec.updateAgentSpec, + flushAgentSpec: draftSpec.flushAgentSpec, + adoptAgentSpec: draftSpec.adoptAgentSpec, + }; + }, [agent.mode, draftSpec]); + + return useExternalStoreRuntime({ + ...pickExternalStoreSharedOptions(sharedOptions), + messages, + isRunning, + isLoading, + extras: trueForgeExtras.provide({ + pendingApprovals, + pendingToolResponses, + pendingMcpAuth, + resumeUnavailable, + sandboxId, + respondToToolApproval, + respondToToolResponse, + resumeMcpAuth, + downloadSandboxFile, + cancel, + // resetFromTurn/branchFromTurn/sendTurn already report via onError. + resetFromTurn: (turnId: string) => resetFromTurn(turnId).catch(() => undefined), + reload: retryLoad, + hasOlderHistory, + isLoadingOlderHistory, + loadOlderHistory, + draft: draftExtras, + }), + unstable_enableToolInvocations: true, + setToolStatuses, + adapters: { + attachments: adapters?.attachments ?? runtimeAdapters?.attachments, + speech: adapters?.speech, + dictation: adapters?.dictation, + voice: adapters?.voice, + feedback: adapters?.feedback, + }, + onNew: async (message: AppendMessage) => { + if (!(message.startRun ?? message.role === 'user')) { + return; + } + + const resumeMcpAuthFlag = message.runConfig?.custom?.[MCP_AUTH_RESUME_RUN_CUSTOM_KEY] === true; + + if (resumeMcpAuthFlag) { + await sendTurn({ resumeMcpAuth: true }); + return; + } + + const userMessage = buildUserMessageContent(message); + await sendTurn({ + userMessage, + // The composer clears before onNew runs. Restore its text only when + // the turn failed before turn.created registered it in the backend. + onPreTurnFailure: () => { + const text = userMessageContentToText(userMessage); + const composer = aui.thread().composer(); + if (text && !composer.getState().text.trim()) { + composer.setText(text); + } + }, + }); + }, + onCancel: async () => { + await cancel(); + }, + onRespondToToolApproval: response => { + respondToToolApproval(response); + return Promise.resolve(); + }, + onResume: async () => { + await resumeRun(); + }, + onEdit: async (message: AppendMessage) => { + const sourceId = message.sourceId; + if (sourceId == null) { + throw new Error('Could not resolve edited user message.'); + } + const turnId = parseTurnIdFromMessageId(sourceId); + const editedText = extractEditedText(message); + // editFromTurn/branchFromTurn/sendTurn already report via onError. + await editFromTurn(turnId, editedText); + }, + }); +} + +export function useTrueForgeAgentRuntime(options: UseTrueForgeAgentRuntimeOptions) { + const resolved = resolveTrueForgeAgentRuntimeOptions(options); + const { server, agent } = resolved; + + const pendingAgentSpecRef = useRef( + agent.mode === 'draft' ? agent.defaultAgentSpec : undefined, + ); + + const listSessionsAgentId = resolved.listSessionsAgentId; + const listSessionsCreatedByMe = resolved.listSessionsCreatedByMe; + // Mode-specific adapter: rebuilt on draft/named switches, but never handed + // to assistant-ui directly — it is reached through the delegating adapter below. + const modeThreadListAdapter = useMemo(() => { + if (agent.mode === 'draft') { + return createTrueForgeDraftThreadListAdapter({ + server, + defaultAgentSpec: agent.defaultAgentSpec, + getAgentSpec: () => pendingAgentSpecRef.current ?? agent.defaultAgentSpec, + ...(listSessionsAgentId == null ? {} : { listSessionsAgentId }), + ...(listSessionsCreatedByMe == null ? {} : { listSessionsCreatedByMe }), + }); + } + return createTrueForgeThreadListAdapter({ + server, + agentName: agent.agentName, + ...(listSessionsAgentId == null ? {} : { listSessionsAgentId }), + ...(listSessionsCreatedByMe == null ? {} : { listSessionsCreatedByMe }), + }); + }, [agent, listSessionsAgentId, listSessionsCreatedByMe, server]); + const modeThreadListAdapterRef = useRef(modeThreadListAdapter); + useEffect(() => { + modeThreadListAdapterRef.current = modeThreadListAdapter; + }, [modeThreadListAdapter]); + // Identity stays stable across mode switches; a new server or session + // filter is a genuinely different list, so those do reset it. + const threadListAdapter = useMemo( + () => createDelegatingThreadListAdapter(modeThreadListAdapterRef), + [listSessionsAgentId, listSessionsCreatedByMe, server], + ); + + return useRemoteThreadListRuntime({ + allowNesting: true, + adapter: threadListAdapter, + initialThreadId: resolved.initialSessionId, + threadId: resolved.threadId, + onThreadIdChange: resolved.onThreadIdChange, + runtimeHook: () => useTrueForgeAgentRuntimeImpl(resolved, pendingAgentSpecRef), + }); +} diff --git a/packages/assistant-ui-runtime/test/attachmentAdapter.test.ts b/packages/assistant-ui-runtime/test/attachmentAdapter.test.ts new file mode 100644 index 000000000..0ec3bfc63 --- /dev/null +++ b/packages/assistant-ui-runtime/test/attachmentAdapter.test.ts @@ -0,0 +1,56 @@ +import type { PendingAttachment } from '@assistant-ui/core'; +import { describe, expect, it } from 'vitest'; + +import { trueForgeAttachmentAdapter } from '../src/attachmentAdapter.js'; + +async function addFile(file: File): Promise { + const result = await trueForgeAttachmentAdapter.add({ file }); + if (Symbol.asyncIterator in result) { + throw new Error('expected PendingAttachment'); + } + return result; +} + +describe('trueForgeAttachmentAdapter', () => { + it('accepts all file types', () => { + expect(trueForgeAttachmentAdapter.accept).toBe('*'); + }); + + it('add returns a pending attachment awaiting composer send', async () => { + const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); + const pending = await addFile(file); + + expect(pending).toMatchObject({ + type: 'file', + name: 'notes.txt', + contentType: 'text/plain', + file, + content: [], + status: { type: 'requires-action', reason: 'composer-send' }, + }); + expect(pending.id).toBeTruthy(); + }); + + it('send reads the file into a data URI file part', async () => { + const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); + const pending = await addFile(file); + const complete = await trueForgeAttachmentAdapter.send(pending); + + expect(complete.status).toEqual({ type: 'complete' }); + expect(complete.content).toHaveLength(1); + expect(complete.content[0]).toMatchObject({ + type: 'file', + mimeType: 'text/plain', + filename: 'notes.txt', + }); + expect(complete.content[0]).toMatchObject({ + data: expect.stringMatching(/^data:text\/plain;base64,/), + }); + }); + + it('remove is a no-op', async () => { + const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); + const pending = await addFile(file); + await expect(trueForgeAttachmentAdapter.remove(pending)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/assistant-ui-runtime/test/buildEditedUserMessageContent.test.ts b/packages/assistant-ui-runtime/test/buildEditedUserMessageContent.test.ts new file mode 100644 index 000000000..2409385f8 --- /dev/null +++ b/packages/assistant-ui-runtime/test/buildEditedUserMessageContent.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildEditedUserMessageContent, + parseTurnIdFromMessageId, + resolveBranchPreviousTurnId, +} from '../src/convertTurnMessages.js'; +import type { SessionTurnRecord } from '../src/sessionSnapshot.js'; + +describe('user message branch helpers', () => { + it('parseTurnIdFromMessageId strips the user suffix', () => { + expect(parseTurnIdFromMessageId('turn-abc-user')).toBe('turn-abc'); + }); + + it('resolveBranchPreviousTurnId returns null for the first turn', () => { + const turns: SessionTurnRecord[] = [ + { id: 'a', createdAt: '', state: { status: 'done', requiredActions: [], completedAt: '' } }, + ]; + expect(resolveBranchPreviousTurnId(turns, 'a')).toBeNull(); + }); + + it('resolveBranchPreviousTurnId returns the prior turn id', () => { + const turns: SessionTurnRecord[] = [ + { id: 'a', createdAt: '', state: { status: 'done', requiredActions: [], completedAt: '' } }, + { id: 'b', createdAt: '', state: { status: 'done', requiredActions: [], completedAt: '' } }, + ]; + expect(resolveBranchPreviousTurnId(turns, 'b')).toBe('a'); + }); +}); + +describe('buildEditedUserMessageContent', () => { + it('returns plain string when there are no attachments', () => { + expect(buildEditedUserMessageContent('updated', [{ type: 'user.message', content: 'original' }])).toBe('updated'); + }); + + it('preserves file parts from the original turn input', () => { + const filePart = { + type: 'file' as const, + name: 'report.pdf', + data: 'data:application/pdf;base64,AAAA', + }; + expect( + buildEditedUserMessageContent('updated question', [ + { + type: 'user.message', + content: [{ type: 'text', text: 'original question' }, filePart], + }, + ]), + ).toEqual([{ type: 'text', text: 'updated question' }, filePart]); + }); +}); diff --git a/packages/assistant-ui-runtime/test/collectPending.test.ts b/packages/assistant-ui-runtime/test/collectPending.test.ts new file mode 100644 index 000000000..7eea9e64e --- /dev/null +++ b/packages/assistant-ui-runtime/test/collectPending.test.ts @@ -0,0 +1,59 @@ +import type { ThreadMessage } from '@assistant-ui/core'; +import { describe, expect, it } from 'vitest'; + +import { deriveSandboxId } from '../src/collectPending.js'; + +function assistantMessage(id: string, custom: Record = {}): ThreadMessage { + return { + id, + role: 'assistant' as const, + content: [], + status: { type: 'complete' as const, reason: 'stop' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom, + }, + }; +} + +function userMessage(id: string): ThreadMessage { + return { + id, + role: 'user' as const, + content: [{ type: 'text', text: 'hi' }], + attachments: [], + createdAt: new Date(), + metadata: { custom: {} }, + }; +} + +describe('deriveSandboxId', () => { + it('returns undefined when no message ever carried a sandboxId', () => { + const messages = [userMessage('u1'), assistantMessage('a1')]; + expect(deriveSandboxId(messages)).toBeUndefined(); + }); + + it('returns the sandboxId from the most recent message that has one', () => { + const messages = [ + userMessage('u1'), + assistantMessage('a1', { sandboxId: 'sbx-1' }), + userMessage('u2'), + assistantMessage('a2'), + ]; + expect(deriveSandboxId(messages)).toBe('sbx-1'); + }); + + it('prefers a later sandboxId over an earlier one', () => { + const messages = [assistantMessage('a1', { sandboxId: 'sbx-1' }), assistantMessage('a2', { sandboxId: 'sbx-2' })]; + expect(deriveSandboxId(messages)).toBe('sbx-2'); + }); + + it('keeps returning the sandboxId from earlier turns even when later assistant messages lack it', () => { + const messages = [assistantMessage('a1', { sandboxId: 'sbx-1' }), assistantMessage('a2'), assistantMessage('a3')]; + expect(deriveSandboxId(messages)).toBe('sbx-1'); + }); +}); diff --git a/packages/assistant-ui-runtime/test/convertTurnMessages.test.ts b/packages/assistant-ui-runtime/test/convertTurnMessages.test.ts new file mode 100644 index 000000000..36da8e1b4 --- /dev/null +++ b/packages/assistant-ui-runtime/test/convertTurnMessages.test.ts @@ -0,0 +1,3141 @@ +import type { AppendMessage } from '@assistant-ui/core'; +import { describe, expect, it, vi } from 'vitest'; +import type { + AgentChatServer, + ModelMessageEvent, + SandboxCreatedEvent, + SessionEventItem, + ThreadCreatedEvent, + ToolApprovalRequiredEvent, + ToolResponseRequiredEvent, + Turn, + TurnDoneEvent, + TurnEvent, + TurnStreamData, +} from '../src/server/index.js'; + +import { collectPendingToolResponses } from '../src/collectPending.js'; +import { ROOT_THREAD_ID } from '../src/constants.js'; +import { + buildSnapshotFromSessionEvents, + buildSnapshotThroughTurn, + buildTurnAssistantContent, + buildUserMessageContent, + buildUserMessageFromTurnInput, + convertTurnsToThreadMessages, + getTurnMessageContent, + prependOlderSessionHistory, + projectSessionMessages, + repositoryItemsFromMessages, + resolveGatewayBranchPreviousTurnIdForTurn, + streamTurnEvents, + turnStreamUpdateToAssistantMessage, +} from '../src/convertTurnMessages.js'; +import { buildRootAssistantContent, ingestTurnEvent, PeerThreadFoldState } from '../src/foldPeerThreads.js'; +import { findPausedAssistantMessage } from '../src/requiredActionInputs.js'; +import { createEmptySessionSnapshot, replaceSessionSnapshot, turnToSessionRecord } from '../src/sessionSnapshot.js'; +import { TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY } from '../src/toolApproval.js'; +import { + applyUserToolResponsesToFold, + TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY, + toolResponseStatus, +} from '../src/toolResponse.js'; +import type { TurnStreamUpdate } from '../src/turnStreamUpdate.js'; + +const createdAt = new Date().toISOString(); +const SESSION_ID = 'session-1'; + +type TurnFixture = Turn & { events: TurnEvent[] }; + +function modelMessage(event: Omit): ModelMessageEvent { + return { type: 'model.message', createdAt, ...event }; +} + +function approvalRequired(event: Omit): ToolApprovalRequiredEvent { + return { type: 'tool.approval_required', createdAt, ...event }; +} + +function threadCreated(event: Omit): ThreadCreatedEvent { + return { type: 'thread.created', createdAt, ...event }; +} + +function sandboxCreated(event: { id: string; sandboxId: string; threadId?: string | null }): SandboxCreatedEvent { + return { + type: 'sandbox.created', + createdAt, + threadId: event.threadId ?? null, + id: event.id, + sandboxId: event.sandboxId, + }; +} + +function responseRequired(event: Omit): ToolResponseRequiredEvent { + return { type: 'tool.response_required', createdAt, ...event }; +} + +async function* streamFrom(events: TurnStreamData['event'][]): AsyncGenerator { + for (const [index, event] of events.entries()) { + yield { sequenceNumber: index + 1, event }; + } +} + +function appendUserMessage( + content: AppendMessage['content'], + attachments: AppendMessage['attachments'] = [], +): AppendMessage { + return { + role: 'user', + content, + attachments, + createdAt: new Date(), + metadata: { custom: {} }, + parentId: null, + sourceId: null, + runConfig: undefined, + }; +} + +function mockTurn( + overrides: Partial & + Pick & { + events?: TurnEvent[]; + }, +): TurnFixture { + const { + events = [ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'assistant reply', + }), + ], + ...rest + } = overrides; + return { + sessionId: SESSION_ID, + input: [{ type: 'user.message', content: 'hello' }], + state: { status: 'done', requiredActions: [], completedAt: createdAt }, + ...rest, + events, + }; +} + +/** Mirror runtime listTurns normalization: ASC stays, DESC (or tied) reverses. */ +function toOldestFirstTurns(turns: TurnFixture[]): TurnFixture[] { + const chain = turns.filter(turn => turn.state.status !== 'running'); + let newestFirst = chain.length > 1; + for (let i = 1; i < chain.length; i++) { + const newer = chain[i]; + const older = chain[i - 1]; + if (newer == null || older == null) { + continue; + } + const delta = Date.parse(newer.createdAt) - Date.parse(older.createdAt); + if (delta < 0) { + newestFirst = true; + break; + } + if (delta > 0) { + newestFirst = false; + break; + } + } + return newestFirst ? [...chain].reverse() : [...chain]; +} + +/** Mirrors the host's ancestor walk: parent pointers only, sibling branches excluded. */ +function ancestorChainFromTurns(turns: TurnFixture[], anchorTurnId: string): TurnFixture[] { + const turnsById = new Map(turns.map(turn => [turn.id, turn])); + const chain: TurnFixture[] = []; + const seen = new Set(); + let cursor: string | null | undefined = anchorTurnId; + while (cursor != null && !seen.has(cursor)) { + seen.add(cursor); + const turn = turnsById.get(cursor); + if (turn == null) { + break; + } + if (turn.state.status !== 'running') { + chain.unshift(turn); + } + cursor = turn.previousTurnId; + } + return chain; +} + +/** + * Builds session-level event items from per-turn mocks — accepts ASC or DESC + * listTurns order; running turns excluded (matches session.listEvents). An + * anchor selects that turn's ancestor chain, not a chronological prefix. + */ +function sessionEventItemsFromTurns(turns: TurnFixture[], lastTurnId?: string): SessionEventItem[] { + const chain = lastTurnId == null ? toOldestFirstTurns(turns) : ancestorChainFromTurns(turns, lastTurnId); + + const items: SessionEventItem[] = []; + for (const turn of chain) { + items.push({ + turnId: turn.id, + event: { + type: 'turn.created', + id: `created-${turn.id}`, + turnId: turn.id, + ...(turn.input === undefined ? {} : { input: turn.input }), + state: { status: 'running' }, + createdAt: turn.createdAt, + ...(turn.previousTurnId === undefined ? {} : { previousTurnId: turn.previousTurnId }), + }, + }); + for (const event of turn.events) { + items.push({ turnId: turn.id, event }); + } + items.push({ + turnId: turn.id, + event: { + type: 'turn.done', + id: `done-${turn.id}`, + state: turn.state as TurnDoneEvent['state'], + createdAt: turn.createdAt, + }, + }); + } + return items; +} + +function mockServerWithTurns(turns: TurnFixture[]): AgentChatServer { + const eventsByTurnId = new Map(turns.map(turn => [turn.id, turn.events])); + const turnsById = new Map(turns.map(turn => [turn.id, turn])); + return { + listTurns: async ({ limit }: { limit?: number } = {}) => ({ + data: limit != null ? turns.slice(0, limit) : turns, + }), + getTurn: async ({ turnId }: { turnId: string }) => { + const turn = turnsById.get(turnId); + if (turn == null) { + throw new Error(`Turn ${turnId} not found`); + } + return turn; + }, + listEvents: async (opts: { lastTurnId?: string } = {}) => { + const items = sessionEventItemsFromTurns(turns, opts.lastTurnId); + const newestFirst = [...items].reverse(); + return { data: newestFirst }; + }, + listTurnEvents: async ({ turnId }: { turnId: string }) => ({ + data: eventsByTurnId.get(turnId) ?? [], + }), + } as unknown as AgentChatServer; +} + +async function collectStream(stream: AsyncGenerator): Promise { + const items: T[] = []; + for await (const item of stream) { + items.push(item); + } + return items; +} + +describe('convertTurnMessages', () => { + describe('getTurnMessageContent', () => { + it('joins text parts from append message content', () => { + expect( + getTurnMessageContent( + appendUserMessage([ + { type: 'text', text: 'line one' }, + { type: 'text', text: 'line two' }, + ]), + ), + ).toBe('line one\nline two'); + }); + + it('throws when no text content is present', () => { + expect(() => getTurnMessageContent(appendUserMessage([]))).toThrow('User message must contain text content.'); + }); + }); + + describe('buildUserMessageContent', () => { + it('returns a bare string for text-only messages', () => { + expect(buildUserMessageContent(appendUserMessage([{ type: 'text', text: 'hello there' }]))).toBe('hello there'); + }); + + it('forwards file attachments as gateway file parts', () => { + expect( + buildUserMessageContent( + appendUserMessage( + [{ type: 'text', text: 'see attached' }], + [ + { + id: 'att-1', + type: 'file', + name: 'doc.pdf', + contentType: 'application/pdf', + status: { type: 'complete' }, + content: [ + { + type: 'file', + mimeType: 'application/pdf', + data: 'JVBERi0xLjQK', + }, + ], + }, + ], + ), + ), + ).toEqual([ + { type: 'text', text: 'see attached' }, + { + type: 'file', + name: 'doc.pdf', + data: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + ]); + }); + + it('forwards attachments without requiring text', () => { + expect( + buildUserMessageContent( + appendUserMessage( + [], + [ + { + id: 'att-1', + type: 'file', + name: 'doc.pdf', + contentType: 'application/pdf', + status: { type: 'complete' }, + content: [ + { + type: 'file', + mimeType: 'application/pdf', + data: 'JVBERi0xLjQK', + }, + ], + }, + ], + ), + ), + ).toEqual([ + { + type: 'file', + name: 'doc.pdf', + data: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + ]); + }); + + it('passes through data URIs unchanged', () => { + const dataUri = 'data:application/pdf;base64,JVBERi0xLjQK'; + expect( + buildUserMessageContent( + appendUserMessage( + [{ type: 'text', text: 'file' }], + [ + { + id: 'att-1', + type: 'file', + name: 'doc.pdf', + contentType: 'application/pdf', + status: { type: 'complete' }, + content: [ + { + type: 'file', + mimeType: 'application/pdf', + data: dataUri, + }, + ], + }, + ], + ), + ), + ).toEqual([ + { type: 'text', text: 'file' }, + { + type: 'file', + name: 'doc.pdf', + data: dataUri, + }, + ]); + }); + + it('maps image parts to gateway file parts', () => { + expect( + buildUserMessageContent( + appendUserMessage( + [{ type: 'text', text: 'image' }], + [ + { + id: 'att-1', + type: 'image', + name: 'photo.png', + contentType: 'image/png', + status: { type: 'complete' }, + content: [ + { + type: 'image', + image: 'data:image/png;base64,AAAA', + }, + ], + }, + ], + ), + ), + ).toEqual([ + { type: 'text', text: 'image' }, + { + type: 'file', + name: 'photo.png', + data: 'data:image/png;base64,AAAA', + }, + ]); + }); + + it('includes file parts from message content and attachments', () => { + expect( + buildUserMessageContent( + appendUserMessage( + [ + { type: 'text', text: 'inline' }, + { + type: 'file', + mimeType: 'application/pdf', + data: 'AAAA', + filename: 'inline.pdf', + }, + ], + [ + { + id: 'att-1', + type: 'file', + name: 'attached.pdf', + contentType: 'application/pdf', + status: { type: 'complete' }, + content: [ + { + type: 'file', + mimeType: 'application/pdf', + data: 'BBBB', + }, + ], + }, + ], + ), + ), + ).toEqual([ + { type: 'text', text: 'inline' }, + { + type: 'file', + name: 'inline.pdf', + data: 'data:application/pdf;base64,AAAA', + }, + { + type: 'file', + name: 'attached.pdf', + data: 'data:application/pdf;base64,BBBB', + }, + ]); + }); + }); + + describe('buildUserMessageFromTurnInput', () => { + it('maps gateway file parts to assistant-ui attachments', () => { + const message = buildUserMessageFromTurnInput( + 'turn-1', + [ + { + type: 'user.message', + content: [ + { type: 'text', text: 'see attached' }, + { + type: 'file', + name: 'doc.pdf', + data: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + ], + }, + ], + createdAt, + ); + + expect(message.role).toBe('user'); + expect(message.content).toEqual([{ type: 'text', text: 'see attached' }]); + if (message.role !== 'user') { + return; + } + expect(message.attachments).toHaveLength(1); + expect(message.attachments[0]?.name).toBe('doc.pdf'); + }); + + it('maps gateway image_url parts to assistant-ui image attachments', () => { + const message = buildUserMessageFromTurnInput( + 'turn-1', + [ + { + type: 'user.message', + content: [ + { type: 'text', text: 'see attached' }, + { + type: 'image_url', + image_url: { + url: 'data:image/jpeg;base64,/9j/4AAQ', + }, + }, + ] as never, + }, + ], + createdAt, + ); + + if (message.role !== 'user') { + throw new Error('expected user message'); + } + expect(message.attachments).toHaveLength(1); + expect(message.attachments[0]?.type).toBe('image'); + expect(message.attachments[0]?.content[0]).toMatchObject({ + type: 'image', + image: 'data:image/jpeg;base64,/9j/4AAQ', + }); + }); + }); + + describe('turnStreamUpdateToAssistantMessage', () => { + it('creates a new assistant message when none exists', () => { + const message = turnStreamUpdateToAssistantMessage('turn-1', { + content: [{ type: 'text', text: 'hi' }], + }); + expect(message.id).toBe('turn-1-assistant'); + expect(message.role).toBe('assistant'); + expect(message.status).toEqual({ type: 'running' }); + }); + + it('preserves the existing assistant message id on update', () => { + const existing = turnStreamUpdateToAssistantMessage('turn-1', { + content: [{ type: 'text', text: 'first' }], + }); + const updated = turnStreamUpdateToAssistantMessage( + 'turn-1', + { content: [{ type: 'text', text: 'second' }] }, + existing, + ); + expect(updated.id).toBe(existing.id); + expect(updated.content).toEqual([{ type: 'text', text: 'second' }]); + }); + + it('stamps the streaming turn id onto the message metadata', () => { + const message = turnStreamUpdateToAssistantMessage('turn-1', { + content: [{ type: 'text', text: 'hi' }], + }); + expect(message.metadata.custom['turnId']).toBe('turn-1'); + }); + + it('advances the turn id when a continuation reuses the existing message', () => { + const existing = turnStreamUpdateToAssistantMessage('turn-1', { + content: [{ type: 'text', text: 'first' }], + metadata: { custom: { sandboxId: 'sbx-123' } }, + }); + const updated = turnStreamUpdateToAssistantMessage( + 'turn-2', + { content: [{ type: 'text', text: 'second' }] }, + existing, + ); + expect(updated.id).toBe('turn-1-assistant'); + expect(updated.metadata.custom['turnId']).toBe('turn-2'); + expect(updated.metadata.custom['sandboxId']).toBe('sbx-123'); + }); + }); + + describe('repositoryItemsFromMessages', () => { + it('chains parent ids in order', () => { + const userMessage = { + id: 'u1', + role: 'user' as const, + content: [{ type: 'text' as const, text: 'hi' }], + attachments: [], + createdAt: new Date(), + metadata: { custom: {} }, + }; + const assistant = turnStreamUpdateToAssistantMessage('t1', { + content: [{ type: 'text', text: 'a' }], + }); + + const items = repositoryItemsFromMessages([userMessage, assistant]); + expect(items).toEqual([ + { parentId: null, message: userMessage }, + { parentId: 'u1', message: assistant }, + ]); + }); + }); + + describe('buildTurnAssistantContent', () => { + it('aggregates turn listEvents into root assistant content', async () => { + const turn = mockTurn({ + id: 'turn-1', + createdAt, + }); + const content = await buildTurnAssistantContent(mockServerWithTurns([turn]), SESSION_ID, turn); + expect(content).toEqual([{ type: 'text', text: 'assistant reply' }]); + }); + }); + + describe('convertTurnsToThreadMessages', () => { + it('builds user and assistant messages from completed turns', async () => { + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([ + mockTurn({ + id: 'turn-1', + createdAt, + input: [{ type: 'user.message', content: 'hello' }], + }), + ]), + SESSION_ID, + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0]).toMatchObject({ + id: 'turn-1-user', + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }); + expect(result.messages[1]).toMatchObject({ + id: 'turn-1-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'assistant reply' }], + status: { type: 'complete', reason: 'stop' }, + }); + expect(result.runningTurn).toBeUndefined(); + }); + + it('carries sandboxId from a historical sandbox.created event onto the assistant message', async () => { + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([ + mockTurn({ + id: 'turn-1', + createdAt, + input: [{ type: 'user.message', content: 'hello' }], + events: [ + sandboxCreated({ id: 'sandbox-evt', sandboxId: 'sbx-123' }), + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'assistant reply', + }), + ], + }), + ]), + SESSION_ID, + ); + + expect(result.messages[1]).toMatchObject({ + role: 'assistant', + metadata: { custom: { sandboxId: 'sbx-123', turnId: 'turn-1' } }, + }); + }); + + it('stamps turnId without sandboxId when history omits sandbox.created (reused sandbox outside window)', async () => { + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([ + mockTurn({ + id: 'turn-tip', + createdAt, + input: [{ type: 'user.message', content: 'download please' }], + events: [ + modelMessage({ + id: 'm-tip', + threadId: ROOT_THREAD_ID, + content: 'here is the file', + }), + ], + }), + ]), + SESSION_ID, + ); + + expect(result.messages[1]).toMatchObject({ + role: 'assistant', + metadata: { custom: { turnId: 'turn-tip' } }, + }); + expect((result.messages[1]?.metadata.custom as { sandboxId?: string }).sandboxId).toBeUndefined(); + }); + + it('propagates sandboxId onto later turns that reuse the sandbox without emitting sandbox.created', async () => { + const createTurn = mockTurn({ + id: 'turn-1', + createdAt: '2026-01-01T00:00:00.000Z', + previousTurnId: null, + input: [{ type: 'user.message', content: 'create file' }], + events: [ + sandboxCreated({ id: 'sandbox-evt', sandboxId: 'sbx-123' }), + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'created', + }), + ], + }); + const reuseTurn = mockTurn({ + id: 'turn-2', + createdAt: '2026-01-01T00:01:00.000Z', + previousTurnId: 'turn-1', + input: [{ type: 'user.message', content: 'download it' }], + events: [ + modelMessage({ + id: 'm2', + threadId: ROOT_THREAD_ID, + content: 'ready', + }), + ], + }); + + // Newest-first listTurns order (tip first). + const result = await convertTurnsToThreadMessages(mockServerWithTurns([reuseTurn, createTurn]), SESSION_ID); + + const tipAssistant = result.messages.find( + message => message.role === 'assistant' && (message.metadata.custom as { turnId?: string }).turnId === 'turn-2', + ); + expect(tipAssistant).toMatchObject({ + metadata: { custom: { sandboxId: 'sbx-123', turnId: 'turn-2' } }, + }); + }); + + it('returns runningTurn and unstable_resume for an in-flight turn', async () => { + const runningTurn = mockTurn({ + id: 'turn-running', + createdAt, + state: { status: 'running' }, + }); + const result = await convertTurnsToThreadMessages(mockServerWithTurns([runningTurn]), SESSION_ID); + + expect(result.runningTurn).toBe(runningTurn); + expect(result.unstable_resume).toBe(true); + expect(result.messages.at(-1)?.role).toBe('assistant'); + }); + + it('merges continuation turns without user input into the last assistant message', async () => { + const firstTurn = mockTurn({ + id: 'turn-1', + createdAt, + events: [ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'first chunk', + }), + ], + }); + const continuationTurn = mockTurn({ + id: 'turn-2', + createdAt, + input: [ + { type: 'user.tool_approval', threadId: ROOT_THREAD_ID, toolCallId: 'tc-1', approval: { status: 'allow' } }, + ], + events: [ + modelMessage({ + id: 'm2', + threadId: ROOT_THREAD_ID, + content: ' after approval', + }), + ], + }); + + const result = await convertTurnsToThreadMessages(mockServerWithTurns([continuationTurn, firstTurn]), SESSION_ID); + + expect(result.messages).toHaveLength(2); + const assistant = result.messages[1]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.content).toEqual([ + { type: 'text', text: 'first chunk' }, + { type: 'text', text: ' after approval' }, + ]); + // Artifacts on a merged message belong to the turn that wrote them last. + expect(assistant.metadata.custom['turnId']).toBe('turn-2'); + }); + + it('keeps the sandboxId when an ask-user continuation is merged', async () => { + const firstTurn = mockTurn({ + id: 'turn-1', + createdAt, + input: [{ type: 'user.message', content: 'create a file' }], + events: [ + sandboxCreated({ id: 'sandbox-evt', sandboxId: 'sbx-123' }), + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'Which format?', + }), + ], + }); + const continuationTurn = mockTurn({ + id: 'turn-2', + createdAt, + input: [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'ask-1', + content: 'Markdown', + }, + ], + events: [ + modelMessage({ + id: 'm2', + threadId: ROOT_THREAD_ID, + content: 'Done', + }), + ], + }); + + const result = await convertTurnsToThreadMessages(mockServerWithTurns([continuationTurn, firstTurn]), SESSION_ID); + + expect(result.messages[1]).toMatchObject({ + role: 'assistant', + metadata: { + custom: { + sandboxId: 'sbx-123', + turnId: 'turn-2', + }, + }, + }); + }); + + it('clears pendingMcpAuth when an MCP auth continuation is merged', async () => { + const mcpServers = [ + { + id: 'github-auth', + name: 'github', + authUrl: 'https://example.com/auth', + }, + ]; + const pausedTurn = mockTurn({ + id: 'turn-1', + createdAt, + input: [{ type: 'user.message', content: 'use github' }], + state: { + status: 'done', + output: undefined, + requiredActions: [ + { + type: 'mcp.auth_required', + id: 'mcp-auth', + createdAt, + mcpServers, + }, + ] as never, + completedAt: createdAt, + }, + events: [ + sandboxCreated({ id: 'sandbox-evt', sandboxId: 'sbx-123' }), + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'Need auth', + }), + { + type: 'mcp.auth_required', + id: 'mcp-auth', + createdAt, + mcpServers, + }, + ], + }); + const continuationTurn = mockTurn({ + id: 'turn-2', + createdAt, + input: [], + events: [ + modelMessage({ + id: 'm2', + threadId: ROOT_THREAD_ID, + content: 'Authorized', + }), + ], + }); + + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([continuationTurn, pausedTurn]), + SESSION_ID, + ); + + const assistant = result.messages[1]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ type: 'complete', reason: 'stop' }); + expect(assistant.metadata.custom).toEqual({ + sandboxId: 'sbx-123', + turnId: 'turn-2', + }); + expect(assistant.metadata.custom['pendingMcpAuth']).toBeUndefined(); + }); + + it('preserves requires-action on reload when a turn ends awaiting tool approval', async () => { + const pausedTurn = mockTurn({ + id: 'turn-approval', + createdAt, + input: [{ type: 'user.message', content: 'run tool' }], + state: { + status: 'done', + output: undefined, + requiredActions: [ + approvalRequired({ + id: 'approval-event', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'approval-1', sourceEventId: 'm1' }], + }), + ] as never, + completedAt: createdAt, + }, + events: [ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'run tool', + toolCalls: [ + { + id: 'approval-1', + type: 'function', + function: { name: 'bash', arguments: '{}' }, + toolInfo: { + type: 'mcp', + name: 'bash', + serverId: 'bash-server', + serverName: 'bash', + }, + }, + ], + }), + approvalRequired({ + id: 'approval-event', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'approval-1', sourceEventId: 'm1' }], + }), + ], + }); + + const result = await convertTurnsToThreadMessages(mockServerWithTurns([pausedTurn]), SESSION_ID); + + expect(result.messages).toHaveLength(2); + const assistant = result.messages[1]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ + type: 'requires-action', + reason: 'tool-calls', + }); + expect(assistant.metadata.custom[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]).toBe(ROOT_THREAD_ID); + expect(assistant.content.find(part => part.type === 'tool-call')).toMatchObject({ + type: 'tool-call', + toolCallId: 'approval-1', + approval: { id: 'approval-1' }, + }); + expect(findPausedAssistantMessage(result.messages)).toBe(assistant); + }); + + it('downgrades to complete after a later turn submits user.tool_approval', async () => { + const pausedTurn = mockTurn({ + id: 'turn-approval', + createdAt, + input: [{ type: 'user.message', content: 'run tool' }], + state: { + status: 'done', + output: undefined, + requiredActions: [ + approvalRequired({ + id: 'approval-event', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'approval-1', sourceEventId: 'm1' }], + }), + ] as never, + completedAt: createdAt, + }, + events: [ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'run tool', + toolCalls: [ + { + id: 'approval-1', + type: 'function', + function: { name: 'bash', arguments: '{}' }, + toolInfo: { + type: 'mcp', + name: 'bash', + serverId: 'bash-server', + serverName: 'bash', + }, + }, + ], + }), + approvalRequired({ + id: 'approval-event', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'approval-1', sourceEventId: 'm1' }], + }), + ], + }); + const continuationTurn = mockTurn({ + id: 'turn-approval-resume', + createdAt, + input: [ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + ], + events: [ + modelMessage({ + id: 'm2', + threadId: ROOT_THREAD_ID, + content: 'done', + }), + ], + }); + + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([continuationTurn, pausedTurn]), + SESSION_ID, + ); + + const assistant = result.messages[1]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ type: 'complete', reason: 'stop' }); + expect(assistant.metadata.custom[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]).toBeUndefined(); + const toolCall = assistant.content.find(part => part.type === 'tool-call'); + expect(toolCall?.type).toBe('tool-call'); + if (toolCall?.type !== 'tool-call') { + return; + } + expect(toolCall.approval?.approved).toBe(true); + }); + + it('preserves requires-action on reload when a turn ends awaiting tool response', async () => { + const pausedTurn = mockTurn({ + id: 'turn-response', + createdAt, + input: [{ type: 'user.message', content: 'ask me' }], + state: { + status: 'done', + output: undefined, + requiredActions: [ + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ] as never, + completedAt: createdAt, + }, + events: [ + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ], + }); + + const result = await convertTurnsToThreadMessages(mockServerWithTurns([pausedTurn]), SESSION_ID); + + const assistant = result.messages[1]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ + type: 'requires-action', + reason: 'tool-calls', + }); + expect(assistant.metadata.custom[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]).toBe(ROOT_THREAD_ID); + expect(assistant.content[0]).toMatchObject({ + type: 'tool-call', + toolCallId: 'question-1', + interrupt: { + type: 'human', + payload: { question: 'Pick one', options: ['A', 'B'] }, + }, + }); + expect(findPausedAssistantMessage(result.messages)).toBe(assistant); + }); + + it('downgrades to complete after a later turn submits user.tool_response', async () => { + const pausedTurn = mockTurn({ + id: 'turn-response', + createdAt, + input: [{ type: 'user.message', content: 'ask me' }], + state: { + status: 'done', + output: undefined, + requiredActions: [ + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ] as never, + completedAt: createdAt, + }, + events: [ + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ], + }); + const continuationTurn = mockTurn({ + id: 'turn-response-resume', + createdAt, + input: [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'A', + }, + ], + events: [ + modelMessage({ + id: 'model-2', + threadId: ROOT_THREAD_ID, + content: 'Thanks', + }), + ], + }); + + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([continuationTurn, pausedTurn]), + SESSION_ID, + ); + + const assistant = result.messages[1]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ type: 'complete', reason: 'stop' }); + expect(assistant.metadata.custom[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]).toBeUndefined(); + const toolCall = assistant.content[0]; + expect(toolCall?.type).toBe('tool-call'); + if (toolCall?.type !== 'tool-call') { + return; + } + expect(toolCall.result).toBe('A'); + }); + + it('scopes assistant content to each user turn group', async () => { + const helloTurn = mockTurn({ + id: 'turn-hello', + createdAt, + input: [{ type: 'user.message', content: 'hello' }], + events: [ + modelMessage({ + id: 'm-hello', + threadId: ROOT_THREAD_ID, + content: 'Hello! How can I help?', + }), + ], + }); + const analyzeTurn = mockTurn({ + id: 'turn-analyze', + createdAt, + input: [{ type: 'user.message', content: 'analyze' }], + events: [ + modelMessage({ + id: 'm-analyze', + threadId: ROOT_THREAD_ID, + content: 'Let me look at the file.', + }), + ], + }); + const ticketsTurn = mockTurn({ + id: 'turn-tickets', + createdAt, + input: [ + { + type: 'user.message', + content: 'create linear tickets', + }, + ], + events: [ + modelMessage({ + id: 'm-tickets', + threadId: ROOT_THREAD_ID, + content: 'Creating tickets now.', + }), + ], + }); + + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([ticketsTurn, analyzeTurn, helloTurn]), + SESSION_ID, + ); + + expect(result.messages).toHaveLength(6); + expect(result.messages[1]).toMatchObject({ + id: 'turn-hello-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How can I help?' }], + }); + expect(result.messages[3]).toMatchObject({ + id: 'turn-analyze-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Let me look at the file.' }], + }); + expect(result.messages[5]).toMatchObject({ + id: 'turn-tickets-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Creating tickets now.' }], + }); + }); + + it('projects file attachments on user messages from turn input', async () => { + const fileData = 'data:text/x-python;base64,cHJpbnQoJ2hlbGxvJyk='; + const analyzeTurn = mockTurn({ + id: 'turn-analyze', + createdAt, + input: [ + { + type: 'user.message', + content: [ + { type: 'text', text: 'analyze' }, + { type: 'file', name: 'Untitled.py', data: fileData }, + ], + }, + ], + events: [ + modelMessage({ + id: 'm-analyze', + threadId: ROOT_THREAD_ID, + content: 'File analyzed.', + }), + ], + }); + + const result = await convertTurnsToThreadMessages(mockServerWithTurns([analyzeTurn]), SESSION_ID); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0]).toMatchObject({ + id: 'turn-analyze-user', + role: 'user', + content: [{ type: 'text', text: 'analyze' }], + }); + if (result.messages[0]?.role !== 'user') { + return; + } + expect(result.messages[0].attachments).toHaveLength(1); + expect(result.messages[0].attachments[0]).toMatchObject({ + type: 'file', + name: 'Untitled.py', + contentType: 'text/x-python', + status: { type: 'complete' }, + content: [ + { + type: 'file', + mimeType: 'text/x-python', + filename: 'Untitled.py', + data: fileData, + }, + ], + }); + }); + + it('projects file-only turns and their assistant response', async () => { + const fileData = 'data:image/png;base64,AAAA'; + const turn = mockTurn({ + id: 'turn-image', + createdAt, + input: [ + { + type: 'user.message', + content: [{ type: 'file', name: 'image.png', data: fileData }], + }, + ], + events: [ + modelMessage({ + id: 'm-image', + threadId: ROOT_THREAD_ID, + content: 'Image received.', + }), + ], + }); + + const result = await convertTurnsToThreadMessages(mockServerWithTurns([turn]), SESSION_ID); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0]).toMatchObject({ + id: 'turn-image-user', + role: 'user', + content: [{ type: 'text', text: '' }], + attachments: [{ name: 'image.png', status: { type: 'complete' } }], + }); + expect(result.messages[1]).toMatchObject({ + id: 'turn-image-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Image received.' }], + }); + expect(turnToSessionRecord(turn)).toHaveProperty('userText', ''); + }); + + it('nests sub-agents within a turn group and isolates the next user turn', async () => { + const spawnTurn = mockTurn({ + id: 'turn-spawn', + createdAt, + input: [{ type: 'user.message', content: 'spawn agent' }], + events: [ + modelMessage({ + id: 'root-spawn', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'spawn-1', + type: 'function', + function: { name: 'create_sub_agent', arguments: '{}' }, + toolInfo: { + type: 'trueforge-system', + name: 'create_sub_agent', + }, + }, + ], + }), + threadCreated({ + id: 't-created', + threadId: 'child-1', + title: 'Research agent', + agentInfo: { + type: 'dynamic', + name: 'researcher', + input: 'do research', + }, + parent: { threadId: ROOT_THREAD_ID, toolCallId: 'spawn-1' }, + }), + modelMessage({ + id: 'child-msg-1', + threadId: 'child-1', + content: 'initial child work', + }), + ], + }); + const continuationTurn = mockTurn({ + id: 'turn-continuation', + createdAt, + input: [ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'spawn-1', + approval: { status: 'allow' }, + }, + ], + events: [ + modelMessage({ + id: 'child-msg-2', + threadId: 'child-1', + content: 'more child work', + }), + ], + }); + const nextUserTurn = mockTurn({ + id: 'turn-next', + createdAt, + input: [{ type: 'user.message', content: 'thanks' }], + events: [ + modelMessage({ + id: 'm-thanks', + threadId: ROOT_THREAD_ID, + content: "You're welcome!", + }), + ], + }); + + const result = await convertTurnsToThreadMessages( + mockServerWithTurns([nextUserTurn, continuationTurn, spawnTurn]), + SESSION_ID, + ); + + expect(result.messages).toHaveLength(4); + const groupAssistant = result.messages[1]; + expect(groupAssistant?.role).toBe('assistant'); + if (groupAssistant?.role !== 'assistant') { + return; + } + const spawnPart = groupAssistant.content.find(part => part.type === 'tool-call'); + expect(spawnPart?.type).toBe('tool-call'); + if (spawnPart?.type !== 'tool-call') { + return; + } + expect(spawnPart.artifact).toEqual({ + subAgents: [ + { + threadId: 'child-1', + title: 'Research agent', + agentInfo: { + type: 'dynamic', + name: 'researcher', + input: 'do research', + }, + }, + ], + }); + expect(spawnPart.messages?.[0]?.metadata.custom['subAgent']).toEqual({ + threadId: 'child-1', + title: 'Research agent', + name: 'researcher', + input: 'do research', + }); + + const nextAssistant = result.messages[3]; + expect(nextAssistant?.role).toBe('assistant'); + if (nextAssistant?.role !== 'assistant') { + return; + } + expect(nextAssistant.content).toEqual([{ type: 'text', text: "You're welcome!" }]); + expect(nextAssistant.content.some(part => part.type === 'tool-call')).toBe(false); + }); + }); + + describe('streamTurnEvents', () => { + it('yields folded root content as model messages arrive', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'streaming', + }), + ]), + foldState, + ), + ); + + expect(updates).toEqual([{ content: [{ type: 'text', text: 'streaming' }] }]); + }); + + it('yields folded content after each ingested stream event', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'first', + }), + modelMessage({ + id: 'm2', + threadId: ROOT_THREAD_ID, + content: 'second', + }), + modelMessage({ + id: 'm3', + threadId: ROOT_THREAD_ID, + content: 'third', + }), + ]), + foldState, + ), + ); + + expect(updates).toHaveLength(3); + expect(updates[0]?.content).toEqual([{ type: 'text', text: 'first' }]); + expect(updates[1]?.content).toEqual([ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + ]); + expect(updates[2]?.content).toEqual([ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + { type: 'text', text: 'third' }, + ]); + }); + + it('scopes streamed content to ids after the group baseline', async () => { + const foldState = new PeerThreadFoldState(); + foldState.getOrCreateBucket(ROOT_THREAD_ID); + foldState.threads.get(ROOT_THREAD_ID)!.modelMessageIds.push('prior'); + foldState.threads.get(ROOT_THREAD_ID)!.events.set( + 'prior', + modelMessage({ + id: 'prior', + threadId: ROOT_THREAD_ID, + content: 'old turn', + }), + ); + + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm-new', + threadId: ROOT_THREAD_ID, + content: 'new turn only', + }), + ]), + foldState, + ['prior'], + ), + ); + + expect(updates).toEqual([{ content: [{ type: 'text', text: 'new turn only' }] }]); + }); + + it('throws when the turn ends in error', async () => { + const foldState = new PeerThreadFoldState(); + await expect( + collectStream( + streamTurnEvents( + streamFrom([ + { + type: 'turn.done', + id: 'turn-done', + createdAt, + state: { + status: 'error', + message: 'boom', + completedAt: createdAt, + }, + }, + ]), + foldState, + ), + ), + ).rejects.toThrow('boom'); + }); + + it('emits tool approval metadata after the stream when approvals remain pending', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'run tool', + toolCalls: [ + { + id: 'approval-1', + type: 'function', + function: { name: 'bash', arguments: '{}' }, + toolInfo: { + type: 'mcp', + name: 'bash', + serverId: 'bash-server', + serverName: 'bash', + }, + }, + ], + }), + approvalRequired({ + id: 'approval-event', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'approval-1', sourceEventId: 'm1' }], + }), + ]), + foldState, + ), + ); + + const final = updates.at(-1); + expect(final?.status).toEqual({ + type: 'requires-action', + reason: 'tool-calls', + }); + expect(final?.metadata?.custom?.[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]).toBe(ROOT_THREAD_ID); + }); + + it('defers mcp auth until stream end and appends auth prompt', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'before auth', + }), + { + type: 'mcp.auth_required', + id: 'mcp-auth', + createdAt, + mcpServers: [ + { + id: 'github-auth', + name: 'github', + authUrl: 'https://example.com/auth', + }, + ], + }, + ]), + foldState, + ), + ); + + expect(updates).toHaveLength(2); + expect(updates[0]).toEqual({ + content: [{ type: 'text', text: 'before auth' }], + }); + expect(updates[1]?.status).toEqual({ + type: 'requires-action', + reason: 'interrupt', + }); + expect(updates[1]?.content.at(-1)).toMatchObject({ + type: 'text', + text: expect.stringContaining('Connect'), + }); + }); + + it('stamps sandboxId onto every content yield after sandbox.created is seen', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'before sandbox', + }), + sandboxCreated({ id: 'sandbox-evt', sandboxId: 'sbx-123' }), + modelMessage({ + id: 'm2', + threadId: ROOT_THREAD_ID, + content: 'after sandbox', + }), + ]), + foldState, + ), + ); + + expect(updates).toHaveLength(2); + expect(updates[0]?.metadata?.custom?.['sandboxId']).toBeUndefined(); + expect(updates[1]?.metadata?.custom?.['sandboxId']).toBe('sbx-123'); + }); + + it('yields a trailing update carrying sandboxId when sandbox.created is the last event', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'some content', + }), + sandboxCreated({ id: 'sandbox-evt', sandboxId: 'sbx-123' }), + ]), + foldState, + ), + ); + + const final = updates.at(-1); + expect(final?.metadata?.custom?.['sandboxId']).toBe('sbx-123'); + }); + + it('keeps sandboxId on the final mcp auth yield when both occur in the same stream', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + sandboxCreated({ id: 'sandbox-evt', sandboxId: 'sbx-123' }), + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'before auth', + }), + { + type: 'mcp.auth_required', + id: 'mcp-auth', + createdAt, + mcpServers: [ + { + id: 'github-auth', + name: 'github', + authUrl: 'https://example.com/auth', + }, + ], + }, + ]), + foldState, + ), + ); + + const final = updates.at(-1); + expect(final?.metadata?.custom?.['sandboxId']).toBe('sbx-123'); + expect(final?.status).toEqual({ type: 'requires-action', reason: 'interrupt' }); + }); + }); + + describe('projectSessionMessages streamComplete', () => { + const turnId = 'turn-live'; + + function snapshotWithCompletedStream(update: TurnStreamUpdate, foldState: PeerThreadFoldState) { + return { + ...replaceSessionSnapshot(createEmptySessionSnapshot(), { + pendingUser: { + turnId, + content: 'hello', + createdAt: new Date(createdAt), + }, + activeStream: { + turnId, + update, + isContinuation: false, + streamComplete: true, + }, + }), + fold: foldState, + }; + } + + it('keeps streamed images on the assistant message for text-only user prompts', () => { + const imageDataUri = 'data:image/jpeg;base64,/9j/4AAQ'; + const messages = projectSessionMessages( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + pendingUser: { + turnId, + content: 'Create image of dog', + createdAt: new Date(createdAt), + }, + activeStream: { + turnId, + update: { + content: [ + { + type: 'image', + image: imageDataUri, + filename: 'image-1.jpeg', + }, + ], + }, + isContinuation: false, + streamComplete: true, + }, + }), + ); + + expect(messages).toHaveLength(2); + const user = messages[0]; + const assistant = messages[1]; + expect(user?.role).toBe('user'); + if (user?.role !== 'user') { + return; + } + expect(user.attachments).toEqual([]); + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.content).toEqual([ + { + type: 'image', + image: imageDataUri, + filename: 'image-1.jpeg', + }, + ]); + }); + + it('preserves requires-action when streamComplete and update has approval status', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'run tool', + toolCalls: [ + { + id: 'approval-1', + type: 'function', + function: { name: 'bash', arguments: '{}' }, + toolInfo: { + type: 'mcp', + name: 'bash', + serverId: 'bash-server', + serverName: 'bash', + }, + }, + ], + }), + approvalRequired({ + id: 'approval-event', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'approval-1', sourceEventId: 'm1' }], + }), + ]), + foldState, + ), + ); + + const finalUpdate = updates.at(-1)!; + const messages = projectSessionMessages(snapshotWithCompletedStream(finalUpdate, foldState)); + + const assistant = messages.at(-1); + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ + type: 'requires-action', + reason: 'tool-calls', + }); + expect(assistant.content.find(part => part.type === 'tool-call')).toMatchObject({ + type: 'tool-call', + toolCallId: 'approval-1', + approval: { id: 'approval-1' }, + }); + expect(findPausedAssistantMessage(messages)).toBe(assistant); + }); + + it('preserves requires-action when streamComplete and update has ask-user status', async () => { + const foldState = new PeerThreadFoldState(); + const updates = await collectStream( + streamTurnEvents( + streamFrom([ + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ]), + foldState, + ), + ); + + const finalUpdate = updates.at(-1)!; + const messages = projectSessionMessages(snapshotWithCompletedStream(finalUpdate, foldState)); + + const assistant = messages.at(-1); + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ + type: 'requires-action', + reason: 'tool-calls', + }); + expect(assistant.content[0]).toMatchObject({ + type: 'tool-call', + toolCallId: 'question-1', + interrupt: { + type: 'human', + payload: { question: 'Pick one', options: ['A', 'B'] }, + }, + }); + expect(findPausedAssistantMessage(messages)).toBe(assistant); + }); + + it('forces complete when streamComplete and update has no explicit status', () => { + const foldState = new PeerThreadFoldState(); + const messages = projectSessionMessages( + snapshotWithCompletedStream({ content: [{ type: 'text', text: 'done' }] }, foldState), + ); + + const assistant = messages.at(-1); + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(assistant.status).toEqual({ + type: 'complete', + reason: 'stop', + }); + }); + }); + + describe('ask-user reload projection', () => { + it('does not resurface answered ask-user prompts after a continuation turn', () => { + const fold = new PeerThreadFoldState(); + const turn1Id = 'turn-1'; + const turn2Id = 'turn-2'; + const initialCompletedAt = '2026-09-15T09:00:12.000Z'; + const continuationCompletedAt = '2026-09-15T09:01:08.000Z'; + + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'What would you like help with today?', + options: ['Help with a coding or technical task'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + ); + ingestTurnEvent( + fold, + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ); + applyUserToolResponsesToFold(fold, []); + + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-2', + threadId: ROOT_THREAD_ID, + content: "Great, you'd like help with a coding task!", + }), + ); + applyUserToolResponsesToFold(fold, [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'Help with a coding or technical task', + }, + ]); + + const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), { + fold, + turns: [ + { + id: turn1Id, + createdAt, + userText: 'hello', + state: { + status: 'done', + requiredActions: [], + completedAt: initialCompletedAt, + }, + input: [{ type: 'user.message', content: 'hello' }], + rootModelMessageIds: ['model-1'], + }, + { + id: turn2Id, + createdAt, + state: { + status: 'done', + requiredActions: [], + completedAt: continuationCompletedAt, + }, + input: [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'Help with a coding or technical task', + }, + ], + rootModelMessageIds: ['model-2'], + }, + ], + }); + + const messages = projectSessionMessages(snapshot); + expect(collectPendingToolResponses(messages)).toHaveLength(0); + + const assistant = messages.find(message => message.role === 'assistant'); + expect(assistant).toBeDefined(); + expect(assistant?.createdAt).toEqual(new Date(continuationCompletedAt)); + const toolCall = assistant?.content.find(part => part.type === 'tool-call'); + expect(toolCall).toMatchObject({ + type: 'tool-call', + toolCallId: 'question-1', + result: 'Help with a coding or technical task', + }); + if (toolCall?.type === 'tool-call') { + expect(toolCall.interrupt).toBeUndefined(); + } + }); + + it('keeps a committed ask-user pause as requires-action so it can be resumed', () => { + // Reproduces the live-stream pause path: once the SSE stream ends, + // `commitActiveStream` moves the paused turn into `turns` with a + // fabricated `TurnStateDone`. The pause must survive as a + // `tool.response_required` required action, otherwise the projected + // assistant message is not `requires-action` and + // `findPausedAssistantMessage` (the gate that fires the resume turn) + // never sees it. + const fold = new PeerThreadFoldState(); + const turnId = 'turn-ask'; + + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + ); + ingestTurnEvent( + fold, + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ); + + const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), { + fold, + turns: [ + { + id: turnId, + createdAt, + userText: 'ask me a question', + state: { + status: 'done', + requiredActions: [ + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ] as never, + completedAt: createdAt, + }, + input: [{ type: 'user.message', content: 'ask me a question' }], + rootModelMessageIds: ['model-1'], + }, + ], + }); + + const messages = projectSessionMessages(snapshot); + const paused = findPausedAssistantMessage(messages); + expect(paused).toBeDefined(); + expect(paused?.status).toMatchObject({ type: 'requires-action' }); + }); + + it('rebuilds paused activeStream content from fold after an ask-user answer is recorded', () => { + const fold = new PeerThreadFoldState(); + const turnId = 'turn-ask'; + + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + ); + ingestTurnEvent( + fold, + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ); + + const pausedContent = buildRootAssistantContent(fold); + applyUserToolResponsesToFold(fold, [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'A', + }, + ]); + + const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), { + fold, + pendingUser: { + turnId, + content: 'ask me a question', + createdAt: new Date(createdAt), + }, + activeStream: { + turnId, + isContinuation: false, + streamComplete: true, + update: { + content: pausedContent, + status: toolResponseStatus(), + }, + }, + groupRootBaseline: [], + }); + + const messages = projectSessionMessages(snapshot); + expect(collectPendingToolResponses(messages)).toHaveLength(0); + }); + }); + + describe('ask-user live continuation projection', () => { + it('scopes activeStream to the current group when a continuation turn completes', () => { + const fold = new PeerThreadFoldState(); + + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'What would you like help with?', + options: ['Help with a coding task'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + ); + ingestTurnEvent( + fold, + responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + ); + applyUserToolResponsesToFold(fold, [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'Help with a coding task', + }, + ]); + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-2', + threadId: ROOT_THREAD_ID, + content: 'Group 1 follow-up', + }), + ); + + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-3', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-2', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'What kind of code?', + options: ['Write new code'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + ); + ingestTurnEvent( + fold, + responseRequired({ + id: 'resp-req-2', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-2', sourceEventId: 'model-3' }], + }), + ); + applyUserToolResponsesToFold(fold, [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-2', + content: 'Write new code', + }, + ]); + ingestTurnEvent( + fold, + modelMessage({ + id: 'model-4', + threadId: ROOT_THREAD_ID, + content: 'Group 2 follow-up', + }), + ); + + const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), { + fold, + groupRootBaseline: ['model-1', 'model-2'], + turns: [ + { + id: 'turn-1', + createdAt, + userText: 'ask me a question', + state: { + status: 'done', + requiredActions: [], + completedAt: createdAt, + }, + input: [{ type: 'user.message', content: 'ask me a question' }], + rootModelMessageIds: ['model-1'], + }, + { + id: 'turn-2', + createdAt, + state: { + status: 'done', + requiredActions: [], + completedAt: createdAt, + }, + input: [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'Help with a coding task', + }, + ], + rootModelMessageIds: ['model-2'], + }, + { + id: 'turn-3', + createdAt, + userText: 'ask again', + state: { + status: 'done', + requiredActions: [], + completedAt: createdAt, + }, + input: [{ type: 'user.message', content: 'ask again' }], + rootModelMessageIds: ['model-3'], + }, + ], + activeStream: { + turnId: 'turn-4', + isContinuation: true, + streamComplete: true, + update: { + content: [{ type: 'text', text: 'Group 2 follow-up' }], + status: { type: 'complete', reason: 'stop' }, + }, + }, + }); + + const messages = projectSessionMessages(snapshot); + expect(messages).toHaveLength(4); + + const group1Assistant = messages[1]; + const group2Assistant = messages[3]; + expect(group1Assistant?.role).toBe('assistant'); + expect(group2Assistant?.role).toBe('assistant'); + if (group1Assistant?.role !== 'assistant' || group2Assistant?.role !== 'assistant') { + return; + } + + const group1Texts = group1Assistant.content.filter(part => part.type === 'text').map(part => part.text); + const group2Texts = group2Assistant.content.filter(part => part.type === 'text').map(part => part.text); + + expect(group1Texts).toContain('Group 1 follow-up'); + expect(group1Texts).not.toContain('Group 2 follow-up'); + expect(group2Texts).toContain('Group 2 follow-up'); + expect(group2Texts).not.toContain('Group 1 follow-up'); + + const group2ToolCalls = group2Assistant.content.filter(part => part.type === 'tool-call'); + expect(group2ToolCalls).toHaveLength(1); + expect(group2ToolCalls[0]).toMatchObject({ + toolCallId: 'question-2', + result: 'Write new code', + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildSnapshotFromSessionEvents +// --------------------------------------------------------------------------- + +/** Simulates session.listEvents — returns items in desc order (newest first). */ +function sessionEventsPage(items: SessionEventItem[], options?: { pageSize?: number }) { + return async (opts?: { sessionId?: string; lastTurnId?: string; pageToken?: string; limit?: number }) => { + let filtered = items; + if (opts?.lastTurnId != null) { + // Chronological items: keep through lastTurnId, drop later turns. + const lastIndex = filtered.findLastIndex(item => item.turnId === opts.lastTurnId); + filtered = lastIndex === -1 ? [] : filtered.slice(0, lastIndex + 1); + } + const newestFirst = [...filtered].reverse(); + // Prefer fixture pageSize so pagination tests are not forced to the API default limit. + const pageSize = options?.pageSize ?? opts?.limit ?? (newestFirst.length || 1); + const pageIndex = opts?.pageToken != null ? Number(opts.pageToken) : 0; + const start = pageIndex * pageSize; + const slice = newestFirst.slice(start, start + pageSize); + const hasMore = start + pageSize < newestFirst.length; + const nextPageToken = hasMore ? String(pageIndex + 1) : undefined; + return { + data: slice, + ...(nextPageToken != null ? { nextPageToken } : {}), + }; + }; +} + +/** Builds a mock AgentChatServer with listTurns and listEvents. */ +function mockServerWithEvents(turns: Turn[], eventItems: SessionEventItem[]): AgentChatServer { + return { + listTurns: async ({ limit }: { limit?: number } = {}) => ({ + data: limit != null ? turns.slice(0, limit) : turns, + }), + listEvents: sessionEventsPage(eventItems), + listTurnEvents: async () => ({ data: [] }), + } as unknown as AgentChatServer; +} + +describe('buildSnapshotFromSessionEvents', () => { + it('builds a snapshot from a single complete turn', async () => { + const turnStartedAt = '2026-09-15T09:00:00.000Z'; + const turnCompletedAt = '2026-09-15T09:00:12.000Z'; + const items: SessionEventItem[] = [ + { + turnId: 't1', + event: { + type: 'turn.created', + id: 'evt-c1', + turnId: 't1', + input: [{ type: 'user.message', content: 'hello' }], + state: { status: 'running' }, + createdAt: turnStartedAt, + }, + }, + { + turnId: 't1', + event: modelMessage({ id: 'm1', threadId: ROOT_THREAD_ID, content: 'hi there' }), + }, + { + turnId: 't1', + event: { + type: 'turn.done', + id: 'evt-d1', + state: { status: 'done', requiredActions: [], completedAt: turnCompletedAt }, + createdAt: turnCompletedAt, + } as TurnDoneEvent, + }, + ]; + + const snapshot = await buildSnapshotFromSessionEvents(mockServerWithEvents([], items), SESSION_ID); + + expect(snapshot.turns).toHaveLength(1); + expect(snapshot.turns[0]?.id).toBe('t1'); + expect(snapshot.turns[0]?.userText).toBe('hello'); + expect(snapshot.turns[0]?.state).toEqual({ + status: 'done', + requiredActions: [], + completedAt: turnCompletedAt, + }); + expect(snapshot.turns[0]?.rootModelMessageIds).toEqual(['m1']); + expect(snapshot.runningTurn).toBeUndefined(); + + const messages = projectSessionMessages(snapshot); + expect(messages).toHaveLength(2); + expect(messages[0]?.role).toBe('user'); + expect(messages[0]?.createdAt).toEqual(new Date(turnStartedAt)); + expect(messages[1]?.role).toBe('assistant'); + expect(messages[1]?.createdAt).toEqual(new Date(turnCompletedAt)); + }); + + it('detects and attaches the running turn without events', async () => { + const runningTurn = { + id: 't2', + state: { status: 'running' }, + input: [{ type: 'user.message', content: 'in progress' }], + createdAt, + } as unknown as Turn; + + const items: SessionEventItem[] = [ + { + turnId: 't1', + event: { + type: 'turn.created', + id: 'evt-c1', + turnId: 't1', + input: [{ type: 'user.message', content: 'first' }], + state: { status: 'running' }, + createdAt, + }, + }, + { + turnId: 't1', + event: modelMessage({ id: 'm1', threadId: ROOT_THREAD_ID, content: 'reply 1' }), + }, + { + turnId: 't1', + event: { + type: 'turn.done', + id: 'evt-d1', + state: { status: 'done', requiredActions: [], completedAt: createdAt }, + createdAt, + } as TurnDoneEvent, + }, + // t2 (running) has no events — it is excluded from session-level listEvents + ]; + + const snapshot = await buildSnapshotFromSessionEvents(mockServerWithEvents([runningTurn], items), SESSION_ID); + + expect(snapshot.turns).toHaveLength(1); + expect(snapshot.turns[0]?.id).toBe('t1'); + expect(snapshot.runningTurn).toBe(runningTurn); + expect(snapshot.unstable_resume).toBe(true); + expect(snapshot.groupRootBaseline).toBeDefined(); + expect(snapshot.pendingUser).toMatchObject({ + turnId: 't2', + content: 'in progress', + }); + }); + + it('calls onProgress after each completed turn', async () => { + const items: SessionEventItem[] = [ + { + turnId: 't1', + event: { + type: 'turn.created', + id: 'evt-c1', + turnId: 't1', + input: [{ type: 'user.message', content: 'first' }], + state: { status: 'running' }, + createdAt, + }, + }, + { + turnId: 't1', + event: modelMessage({ id: 'm1', threadId: ROOT_THREAD_ID, content: 'reply 1' }), + }, + { + turnId: 't1', + event: { + type: 'turn.done', + id: 'evt-d1', + state: { status: 'done', requiredActions: [], completedAt: createdAt }, + createdAt, + } as TurnDoneEvent, + }, + { + turnId: 't2', + event: { + type: 'turn.created', + id: 'evt-c2', + turnId: 't2', + input: [{ type: 'user.message', content: 'second' }], + state: { status: 'running' }, + createdAt, + }, + }, + { + turnId: 't2', + event: modelMessage({ id: 'm2', threadId: ROOT_THREAD_ID, content: 'reply 2' }), + }, + { + turnId: 't2', + event: { + type: 'turn.done', + id: 'evt-d2', + state: { status: 'done', requiredActions: [], completedAt: createdAt }, + createdAt, + } as TurnDoneEvent, + }, + ]; + + const progressSnapshots: number[] = []; + await buildSnapshotFromSessionEvents(mockServerWithEvents([], items), SESSION_ID, snap => + progressSnapshots.push(snap.turns.length), + ); + + expect(progressSnapshots).toEqual([1, 2]); + }); + + it('falls back to listTurns({ limit: 1 }) when listEvents omits the running tip', async () => { + const listTurns = vi.fn(async () => ({ + data: [ + { + id: 't-running', + sessionId: SESSION_ID, + state: { status: 'running' }, + input: [{ type: 'user.message', content: 'now' }], + createdAt, + }, + ], + nextPageToken: 'more', + })); + + const server = { + listTurns, + listEvents: sessionEventsPage([]), + listTurnEvents: async () => ({ data: [] }), + } as unknown as AgentChatServer; + + const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID); + expect(listTurns).toHaveBeenCalledWith({ sessionId: SESSION_ID, limit: 1 }); + expect(snapshot.runningTurn?.id).toBe('t-running'); + expect(snapshot.pendingUser?.content).toBe('now'); + }); + + it('attaches an open tip from events when listTurns is oldest-first and clears answered ask-user', async () => { + // Trueforge listTurns is ASC: limit:1 returns the oldest done turn even + // while a later tip is still running. Tip detection must use the open + // turn.created in listEvents (which includes running turns). + const oldestDoneTurn = { + id: 't-pause', + sessionId: SESSION_ID, + state: { + status: 'done', + requiredActions: [ + { + id: 'resp-req-1', + type: 'tool.response_required', + threadId: ROOT_THREAD_ID, + createdAt, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }, + ], + completedAt: createdAt, + }, + input: [{ type: 'user.message', content: 'please research' }], + createdAt, + } as unknown as Turn; + + const runningTip = { + id: 't-running', + sessionId: SESSION_ID, + state: { status: 'running' }, + input: [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'AI-native / LLM-powered SaaS', + }, + ], + createdAt, + } as unknown as Turn; + + const items: SessionEventItem[] = [ + { + turnId: 't-pause', + event: { + type: 'turn.created', + id: 'evt-c-pause', + turnId: 't-pause', + ...(oldestDoneTurn.input === undefined ? {} : { input: oldestDoneTurn.input }), + state: { status: 'running' }, + createdAt, + }, + }, + { + turnId: 't-pause', + event: modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + content: 'One calibration question:', + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Which direction should I focus on?', + options: ['General B2B SaaS', 'AI-native / LLM-powered SaaS'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + }, + { + turnId: 't-pause', + event: responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + }, + { + turnId: 't-pause', + event: { + type: 'turn.done', + id: 'evt-d-pause', + state: oldestDoneTurn.state as TurnDoneEvent['state'], + createdAt, + } as TurnDoneEvent, + }, + { + turnId: 't-running', + event: { + type: 'turn.created', + id: 'evt-c-running', + turnId: 't-running', + previousTurnId: 't-pause', + ...(runningTip.input === undefined ? {} : { input: runningTip.input }), + state: { status: 'running' }, + createdAt, + }, + }, + { + turnId: 't-running', + event: modelMessage({ + id: 'model-2', + threadId: ROOT_THREAD_ID, + content: 'Locked in — starting deep research.', + }), + }, + ]; + + const listTurns = vi.fn(async () => ({ + data: [oldestDoneTurn], + nextPageToken: 'eyJvZmZzZXQiOjF9', + })); + const getTurn = vi.fn(async () => runningTip); + const server = { + listTurns, + getTurn, + listEvents: sessionEventsPage(items), + listTurnEvents: async () => ({ data: [] }), + } as unknown as AgentChatServer; + + const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID); + + expect(getTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + turnId: 't-running', + }); + expect(listTurns).not.toHaveBeenCalled(); + expect(snapshot.runningTurn?.id).toBe('t-running'); + expect(snapshot.unstable_resume).toBe(true); + + const messages = projectSessionMessages(snapshot); + expect(collectPendingToolResponses(messages)).toHaveLength(0); + + const assistant = messages.find(message => message.role === 'assistant'); + expect(assistant).toBeDefined(); + const toolCall = assistant?.content.find(part => part.type === 'tool-call' && part.toolCallId === 'question-1'); + expect(toolCall).toMatchObject({ + type: 'tool-call', + toolCallId: 'question-1', + result: 'AI-native / LLM-powered SaaS', + }); + if (toolCall?.type === 'tool-call') { + expect(toolCall.interrupt).toBeUndefined(); + } + }); + + it('keeps an answered ask-user answered when the open tip finished before getTurn', async () => { + // The tip can complete between listEvents and getTurn. There is nothing + // left to resume, but its user.tool_response input never reached the + // fold via ingestion (no turn.done in the window), so it must still be + // applied — otherwise the answered prompt resurfaces as pending. + const finishedTip = { + id: 't-tip', + sessionId: SESSION_ID, + state: { status: 'done', requiredActions: [], completedAt: createdAt }, + input: [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'AI-native / LLM-powered SaaS', + }, + ], + createdAt, + } as unknown as Turn; + + const items: SessionEventItem[] = [ + { + turnId: 't-pause', + event: { + type: 'turn.created', + id: 'evt-c-pause', + turnId: 't-pause', + input: [{ type: 'user.message', content: 'please research' }], + state: { status: 'running' }, + createdAt, + }, + }, + { + turnId: 't-pause', + event: modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + content: 'One calibration question:', + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Which direction should I focus on?', + options: ['General B2B SaaS', 'AI-native / LLM-powered SaaS'], + }), + }, + toolInfo: { + type: 'trueforge-system', + name: 'ask_user_question', + }, + }, + ], + }), + }, + { + turnId: 't-pause', + event: responseRequired({ + id: 'resp-req-1', + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }), + }, + { + turnId: 't-pause', + event: { + type: 'turn.done', + id: 'evt-d-pause', + state: { + status: 'done', + requiredActions: [ + { + id: 'resp-req-1', + type: 'tool.response_required', + threadId: ROOT_THREAD_ID, + createdAt, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }, + ], + completedAt: createdAt, + }, + createdAt, + } as unknown as TurnDoneEvent, + }, + { + turnId: 't-tip', + event: { + type: 'turn.created', + id: 'evt-c-tip', + turnId: 't-tip', + previousTurnId: 't-pause', + ...(finishedTip.input === undefined ? {} : { input: finishedTip.input }), + state: { status: 'running' }, + createdAt, + }, + }, + ]; + + const listTurns = vi.fn(async () => ({ data: [], nextPageToken: undefined })); + const getTurn = vi.fn(async () => finishedTip); + const server = { + listTurns, + getTurn, + listEvents: sessionEventsPage(items), + listTurnEvents: async () => ({ data: [] }), + } as unknown as AgentChatServer; + + const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID); + + expect(snapshot.runningTurn).toBeUndefined(); + expect(snapshot.unstable_resume).toBeFalsy(); + + const messages = projectSessionMessages(snapshot); + expect(collectPendingToolResponses(messages)).toHaveLength(0); + + const assistant = messages.find(message => message.role === 'assistant'); + const toolCall = assistant?.content.find(part => part.type === 'tool-call' && part.toolCallId === 'question-1'); + expect(toolCall).toMatchObject({ + type: 'tool-call', + toolCallId: 'question-1', + result: 'AI-native / LLM-powered SaaS', + }); + if (toolCall?.type === 'tool-call') { + expect(toolCall.interrupt).toBeUndefined(); + } + }); + + it('loads only the newest event page and exposes an older-history cursor', async () => { + const makeTurnItems = (id: string, text: string): SessionEventItem[] => [ + { + turnId: id, + event: { + type: 'turn.created', + id: `evt-c-${id}`, + turnId: id, + input: [{ type: 'user.message', content: text }], + state: { status: 'running' }, + createdAt, + }, + }, + { + turnId: id, + event: modelMessage({ + id: `m-${id}`, + threadId: ROOT_THREAD_ID, + content: `reply ${text}`, + }), + }, + { + turnId: id, + event: { + type: 'turn.done', + id: `evt-d-${id}`, + state: { status: 'done', requiredActions: [], completedAt: createdAt }, + createdAt, + } as TurnDoneEvent, + }, + ]; + + // 3 events per turn; pageSize 3 → one turn per page (newest first). + const items = [...makeTurnItems('t1', 'first'), ...makeTurnItems('t2', 'second'), ...makeTurnItems('t3', 'third')]; + const listEvents = sessionEventsPage(items, { pageSize: 3 }); + const listEventsSpy = vi.fn(listEvents); + const server = { + listTurns: async () => ({ data: [] }), + listEvents: listEventsSpy, + listTurnEvents: async () => ({ data: [] }), + } as unknown as AgentChatServer; + + const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID); + expect(listEventsSpy).toHaveBeenCalledTimes(1); + expect(snapshot.turns.map(t => t.id)).toEqual(['t3']); + expect(snapshot.historyPagination).toEqual({ + hasOlder: true, + olderPageToken: '1', + }); + + const withOlder = await prependOlderSessionHistory(server, SESSION_ID, snapshot); + expect(withOlder.turns.map(t => t.id)).toEqual(['t2', 't3']); + expect(projectSessionMessages(withOlder).map(m => m.role)).toEqual(['user', 'assistant', 'user', 'assistant']); + expect(withOlder.historyPagination?.hasOlder).toBe(true); + + const withAll = await prependOlderSessionHistory(server, SESSION_ID, withOlder); + expect(withAll.turns.map(t => t.id)).toEqual(['t1', 't2', 't3']); + expect(withAll.historyPagination?.hasOlder).toBe(false); + }); + + it('backfills sandboxId onto tip turns after older history reveals sandbox.created', async () => { + const makeTurnItems = (id: string, text: string, options?: { sandboxId?: string }): SessionEventItem[] => [ + { + turnId: id, + event: { + type: 'turn.created', + id: `evt-c-${id}`, + turnId: id, + input: [{ type: 'user.message', content: text }], + state: { status: 'running' }, + createdAt, + }, + }, + ...(options?.sandboxId != null + ? [ + { + turnId: id, + event: sandboxCreated({ + id: `sandbox-${id}`, + sandboxId: options.sandboxId, + }), + } satisfies SessionEventItem, + ] + : [ + { + turnId: id, + event: modelMessage({ + id: `m-${id}-pad`, + threadId: ROOT_THREAD_ID, + content: '…', + }), + } satisfies SessionEventItem, + ]), + { + turnId: id, + event: modelMessage({ + id: `m-${id}`, + threadId: ROOT_THREAD_ID, + content: `reply ${text}`, + }), + }, + { + turnId: id, + event: { + type: 'turn.done', + id: `evt-d-${id}`, + state: { status: 'done', requiredActions: [], completedAt: createdAt }, + createdAt, + } as TurnDoneEvent, + }, + ]; + + // 4 events per turn; pageSize 4 → tip-only first page (no sandbox.created). + const items = [...makeTurnItems('t1', 'create', { sandboxId: 'sbx-123' }), ...makeTurnItems('t2', 'reuse')]; + const listEvents = sessionEventsPage(items, { pageSize: 4 }); + const server = { + listTurns: async () => ({ data: [] }), + listEvents, + listTurnEvents: async () => ({ data: [] }), + } as unknown as AgentChatServer; + + const tipOnly = await buildSnapshotFromSessionEvents(server, SESSION_ID); + expect(tipOnly.turns.map(t => t.id)).toEqual(['t2']); + expect(tipOnly.turns[0]?.sandboxId).toBeUndefined(); + const tipMessages = projectSessionMessages(tipOnly); + expect(tipMessages.at(-1)).toMatchObject({ + role: 'assistant', + metadata: { custom: { turnId: 't2' } }, + }); + expect((tipMessages.at(-1)?.metadata.custom as { sandboxId?: string }).sandboxId).toBeUndefined(); + + const withCreate = await prependOlderSessionHistory(server, SESSION_ID, tipOnly); + expect(withCreate.turns.map(t => t.id)).toEqual(['t1', 't2']); + expect(withCreate.turns.map(t => t.sandboxId)).toEqual(['sbx-123', 'sbx-123']); + expect(projectSessionMessages(withCreate).at(-1)).toMatchObject({ + role: 'assistant', + metadata: { custom: { sandboxId: 'sbx-123', turnId: 't2' } }, + }); + }); +}); + +describe('buildSnapshotThroughTurn', () => { + /** Linear session t1 -> t2 -> t3, oldest-first. */ + function linearTurns(): TurnFixture[] { + return [ + mockTurn({ + id: 't1', + createdAt: '2026-01-01T00:00:00.000Z', + previousTurnId: null, + input: [{ type: 'user.message', content: 'first' }], + events: [modelMessage({ id: 'm1', threadId: ROOT_THREAD_ID, content: 'reply 1' })], + }), + mockTurn({ + id: 't2', + createdAt: '2026-01-01T00:01:00.000Z', + previousTurnId: 't1', + input: [{ type: 'user.message', content: 'second' }], + events: [modelMessage({ id: 'm2', threadId: ROOT_THREAD_ID, content: 'reply 2' })], + }), + mockTurn({ + id: 't3', + createdAt: '2026-01-01T00:02:00.000Z', + previousTurnId: 't2', + input: [{ type: 'user.message', content: 'third' }], + events: [modelMessage({ id: 'm3', threadId: ROOT_THREAD_ID, content: 'reply 3' })], + }), + ]; + } + + it("rewinds to the anchor's ancestor chain, inclusive", async () => { + const server = mockServerWithTurns(linearTurns()); + const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, 't2'); + + expect(snapshot.turns.map(turn => turn.id)).toEqual(['t1', 't2']); + expect(projectSessionMessages(snapshot).map(message => message.id)).toEqual([ + 't1-user', + 't1-assistant', + 't2-user', + 't2-assistant', + ]); + }); + + it('returns an empty snapshot for a null anchor (branching from a root turn)', async () => { + const server = mockServerWithTurns(linearTurns()); + const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, null); + expect(snapshot.turns).toHaveLength(0); + }); + + it('ignores listTurns page order', async () => { + const asc = linearTurns(); + const ascSnapshot = await buildSnapshotThroughTurn(mockServerWithTurns(asc), SESSION_ID, 't2'); + const descSnapshot = await buildSnapshotThroughTurn(mockServerWithTurns([...asc].reverse()), SESSION_ID, 't2'); + + expect(descSnapshot.turns.map(turn => turn.id)).toEqual(ascSnapshot.turns.map(turn => turn.id)); + expect(descSnapshot.turns.map(turn => turn.id)).toEqual(['t1', 't2']); + }); + + it('drops sibling turns left behind by an earlier rerun', async () => { + // m2/m3 were rerun once already: t2/t3 are orphans, t2p/t3p are live. + const [t1, t2, t3] = linearTurns(); + const t2p = mockTurn({ + id: 't2p', + createdAt: '2026-01-01T00:03:00.000Z', + previousTurnId: 't1', + input: [{ type: 'user.message', content: 'second again' }], + events: [modelMessage({ id: 'm2p', threadId: ROOT_THREAD_ID, content: 'reply 2p' })], + }); + const t3p = mockTurn({ + id: 't3p', + createdAt: '2026-01-01T00:04:00.000Z', + previousTurnId: 't2p', + input: [{ type: 'user.message', content: 'third again' }], + events: [modelMessage({ id: 'm3p', threadId: ROOT_THREAD_ID, content: 'reply 3p' })], + }); + const server = mockServerWithTurns([t1!, t2!, t3!, t2p, t3p]); + + // Rerunning the middle live turn rewinds to its parent, not to the + // chronologically preceding turn (t3), whose chain still holds t2/t3. + const previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, 't2p'); + expect(previousTurnId).toBe('t1'); + + const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, previousTurnId); + expect(snapshot.turns.map(turn => turn.id)).toEqual(['t1']); + + // The orphan chain is still reachable, so the assertion above is not + // passing by accident. + const orphanSnapshot = await buildSnapshotThroughTurn(server, SESSION_ID, 't3'); + expect(orphanSnapshot.turns.map(turn => turn.id)).toEqual(['t1', 't2', 't3']); + }); +}); + +describe('resolveGatewayBranchPreviousTurnIdForTurn', () => { + it('uses the turn parent for subsequent turns and none for roots (ASC listTurns)', async () => { + const t1 = mockTurn({ + id: 't1', + createdAt: '2026-01-01T00:00:00.000Z', + previousTurnId: null, + }); + const t2 = mockTurn({ + id: 't2', + createdAt: '2026-01-01T00:01:00.000Z', + previousTurnId: 't1', + }); + const t3 = mockTurn({ + id: 't3', + createdAt: '2026-01-01T00:02:00.000Z', + previousTurnId: 't2', + }); + const server = mockServerWithTurns([t1, t2, t3]); + + await expect(resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, 't1')).resolves.toBe('none'); + await expect(resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, 't3')).resolves.toBe('t2'); + }); + + it('uses the turn parent when listTurns is newest-first', async () => { + const t1 = mockTurn({ + id: 't1', + createdAt: '2026-01-01T00:00:00.000Z', + previousTurnId: null, + }); + const t2 = mockTurn({ + id: 't2', + createdAt: '2026-01-01T00:01:00.000Z', + previousTurnId: 't1', + }); + const server = mockServerWithTurns([t2, t1]); + + await expect(resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, 't2')).resolves.toBe('t1'); + }); +}); diff --git a/packages/assistant-ui-runtime/test/draft/trueforgeDraftThreadListAdapter.test.ts b/packages/assistant-ui-runtime/test/draft/trueforgeDraftThreadListAdapter.test.ts new file mode 100644 index 000000000..f5d4a05d2 --- /dev/null +++ b/packages/assistant-ui-runtime/test/draft/trueforgeDraftThreadListAdapter.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentChatServer, Session } from '../../src/server/index.js'; + +import { createTrueForgeDraftThreadListAdapter } from '../../src/draft/trueforgeDraftThreadListAdapter.js'; +import type { AgentSpec } from '../../src/server/types.js'; + +const defaultAgentSpec: AgentSpec = { + model: { name: 'anthropic/claude-sonnet-4-6' }, + instructions: 'You are helpful.', +}; + +function mockDraft(id: string, title: string | undefined, updatedAt: string, extras?: Partial): Session { + return { + id, + agentSpec: defaultAgentSpec, + ...(title === undefined ? {} : { title }), + createdAt: updatedAt, + updatedAt, + isMutable: true, + ...extras, + }; +} + +function mockNamed(id: string, title: string | undefined, updatedAt: string, agentName: string): Session { + return { + id, + ...(title === undefined ? {} : { title }), + agentName, + createdAt: updatedAt, + updatedAt, + isMutable: false, + }; +} + +function mockDraftListPage(drafts: Session[], nextPageToken?: string) { + return { + data: drafts, + ...(nextPageToken != null ? { nextPageToken } : {}), + }; +} + +function mockServer(partial: Partial): AgentChatServer { + return partial as AgentChatServer; +} + +describe('createTrueForgeDraftThreadListAdapter', () => { + it('lists sessions with pagination cursor and does not filter isMutable client-side', async () => { + const listSessions = vi + .fn() + .mockResolvedValue( + mockDraftListPage( + [ + mockDraft('d1', 'My draft', '2026-06-30T10:00:00.000Z'), + mockNamed('s1', 'Named', '2026-06-30T09:00:00.000Z', 'from-sdk'), + mockNamed('s2', undefined, '2026-06-30T08:00:00.000Z', 'untitled-agent'), + ], + 'page-2', + ), + ); + const server = mockServer({ + listSessions, + createSession: vi.fn(), + getSession: vi.fn(), + }); + + const adapter = createTrueForgeDraftThreadListAdapter({ + server, + defaultAgentSpec, + }); + + const result = await adapter.list(); + + expect(listSessions).toHaveBeenCalledWith( + expect.objectContaining({ + createdByMe: false, + limit: 20, + startTimestamp: expect.any(String), + }), + ); + expect(listSessions).toHaveBeenCalledWith(expect.not.objectContaining({ agentId: expect.anything() })); + expect(result.threads).toEqual([ + { + status: 'regular', + remoteId: 'd1', + title: 'My draft', + lastMessageAt: new Date('2026-06-30T10:00:00.000Z'), + custom: { isMutable: true }, + }, + { + status: 'regular', + remoteId: 's1', + title: 'Named', + lastMessageAt: new Date('2026-06-30T09:00:00.000Z'), + custom: { isMutable: false, agentName: 'from-sdk' }, + }, + { + status: 'regular', + remoteId: 's2', + title: 'untitled-agent', + lastMessageAt: new Date('2026-06-30T08:00:00.000Z'), + custom: { isMutable: false, agentName: 'untitled-agent' }, + }, + ]); + expect(result.nextCursor).toBe('page-2'); + }); + + it('forwards listSessionsAgentId as agentId', async () => { + const listSessions = vi.fn().mockResolvedValue(mockDraftListPage([])); + const server = mockServer({ + listSessions, + createSession: vi.fn(), + getSession: vi.fn(), + }); + + const adapter = createTrueForgeDraftThreadListAdapter({ + server, + defaultAgentSpec, + listSessionsAgentId: 'agent-x', + listSessionsCreatedByMe: true, + }); + + await adapter.list(); + + expect(listSessions).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'agent-x', createdByMe: true })); + }); + + it('creates a draft session on initialize', async () => { + const createSession = vi.fn().mockResolvedValue(mockDraft('d-new', undefined, '2026-06-30T12:00:00.000Z')); + const server = mockServer({ + listSessions: vi.fn(), + createSession, + getSession: vi.fn(), + }); + + const adapter = createTrueForgeDraftThreadListAdapter({ + server, + defaultAgentSpec, + }); + + const result = await adapter.initialize('local-thread-id'); + + expect(createSession).toHaveBeenCalledWith({ agentSpec: defaultAgentSpec }); + expect(result).toEqual({ remoteId: 'd-new', externalId: undefined }); + }); + + it('creates a draft session with the live agent spec when getAgentSpec is provided', async () => { + const liveAgentSpec: AgentSpec = { + model: { name: 'anthropic/claude-opus-4-8' }, + instructions: 'You are helpful.', + mcpServers: [{ id: 'github', name: 'github' }], + skills: [{ id: 'skill-a', name: 'skill-a' }], + }; + const createSession = vi.fn().mockResolvedValue(mockDraft('d-new', undefined, '2026-06-30T12:00:00.000Z')); + const server = mockServer({ + listSessions: vi.fn(), + createSession, + getSession: vi.fn(), + }); + + const adapter = createTrueForgeDraftThreadListAdapter({ + server, + defaultAgentSpec, + getAgentSpec: () => liveAgentSpec, + }); + + await adapter.initialize('local-thread-id'); + + expect(createSession).toHaveBeenCalledWith({ agentSpec: liveAgentSpec }); + }); + + it('rename persists title when renameSession is present', async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const adapter = createTrueForgeDraftThreadListAdapter({ + server: mockServer({ + renameSession, + listSessions: vi.fn(), + createSession: vi.fn(), + getSession: vi.fn(), + }), + defaultAgentSpec, + }); + + await adapter.rename('d1', 'Renamed draft'); + + expect(renameSession).toHaveBeenCalledWith({ + sessionId: 'd1', + title: 'Renamed draft', + }); + }); + + it('rename is a no-op when renameSession is omitted', async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const adapter = createTrueForgeDraftThreadListAdapter({ + server: mockServer({ + listSessions: vi.fn(), + createSession: vi.fn(), + getSession: vi.fn(), + }), + defaultAgentSpec, + }); + + await expect(adapter.rename('d1', 'Renamed draft')).resolves.toBeUndefined(); + expect(renameSession).not.toHaveBeenCalled(); + }); + + it('falls back to model name for title when draft has no title', async () => { + const getSession = vi.fn().mockResolvedValue(mockDraft('d1', undefined, '2026-06-30T10:00:00.000Z')); + const server = mockServer({ + listSessions: vi.fn(), + createSession: vi.fn(), + getSession, + }); + + const adapter = createTrueForgeDraftThreadListAdapter({ + server, + defaultAgentSpec, + }); + + const result = await adapter.fetch('d1'); + + expect(getSession).toHaveBeenCalledWith({ sessionId: 'd1' }); + expect(result.title).toBe('anthropic/claude-sonnet-4-6'); + }); +}); diff --git a/packages/assistant-ui-runtime/test/draft/useDraftAgentSpec.test.tsx b/packages/assistant-ui-runtime/test/draft/useDraftAgentSpec.test.tsx new file mode 100644 index 000000000..0863c1906 --- /dev/null +++ b/packages/assistant-ui-runtime/test/draft/useDraftAgentSpec.test.tsx @@ -0,0 +1,246 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DraftSessionBridge } from '../../src/draft/draftSessionBridge.js'; +import { useDraftAgentSpec } from '../../src/draft/useDraftAgentSpec.js'; + +const defaultAgentSpec = { model: { name: 'anthropic/claude-sonnet-4-6' } }; + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('useDraftAgentSpec', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stores updatedAt from sync and returns it once from takeTurnHeaderTimestamp', async () => { + const syncAgentSpec = vi.fn().mockResolvedValue('2026-06-30T12:00:00.000Z'); + const draftBridge: DraftSessionBridge = { + getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec), + syncAgentSpec, + }; + + const { result } = renderHook(() => + useDraftAgentSpec({ + draftSessionId: 'draft-1', + draftBridge, + defaultAgentSpec, + }), + ); + + await flushMicrotasks(); + expect(draftBridge.getDraftAgentSpec).toHaveBeenCalledWith('draft-1'); + + act(() => { + result.current.updateAgentSpec({ instructions: 'be brief' }); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + await flushMicrotasks(); + + expect(syncAgentSpec).toHaveBeenCalledOnce(); + + let first: string | undefined; + await act(async () => { + first = await result.current.takeTurnHeaderTimestamp(); + }); + expect(first).toBe('2026-06-30T12:00:00.000Z'); + + let second: string | undefined; + await act(async () => { + second = await result.current.takeTurnHeaderTimestamp(); + }); + expect(second).toBeUndefined(); + }); + + it('flushes a pending debounced sync before returning the timestamp', async () => { + const syncAgentSpec = vi.fn().mockResolvedValue('2026-06-30T13:00:00.000Z'); + const draftBridge: DraftSessionBridge = { + getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec), + syncAgentSpec, + }; + + const { result } = renderHook(() => + useDraftAgentSpec({ + draftSessionId: 'draft-1', + draftBridge, + defaultAgentSpec, + }), + ); + + await flushMicrotasks(); + expect(draftBridge.getDraftAgentSpec).toHaveBeenCalledWith('draft-1'); + + act(() => { + result.current.updateAgentSpec({ instructions: 'new' }); + }); + expect(syncAgentSpec).not.toHaveBeenCalled(); + + let timestamp: string | undefined; + await act(async () => { + timestamp = await result.current.takeTurnHeaderTimestamp(); + }); + + expect(syncAgentSpec).toHaveBeenCalledOnce(); + expect(timestamp).toBe('2026-06-30T13:00:00.000Z'); + }); + + it('drops pending sync state and stored timestamp when draftSessionId changes', async () => { + const syncAgentSpec = vi.fn().mockResolvedValue('2026-06-30T14:00:00.000Z'); + const draftBridge: DraftSessionBridge = { + getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec), + syncAgentSpec, + }; + + const { result, rerender } = renderHook( + ({ draftSessionId }: { draftSessionId: string }) => + useDraftAgentSpec({ + draftSessionId, + draftBridge, + defaultAgentSpec, + }), + { initialProps: { draftSessionId: 'draft-1' } }, + ); + + await flushMicrotasks(); + + // A completed sync stores a timestamp for draft-1... + act(() => { + result.current.updateAgentSpec({ instructions: 'for draft-1' }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + await flushMicrotasks(); + expect(syncAgentSpec).toHaveBeenCalledWith('draft-1', expect.anything()); + + // ...and another edit leaves a pending debounced flush for draft-1. + act(() => { + result.current.updateAgentSpec({ instructions: 'pending for draft-1' }); + }); + + rerender({ draftSessionId: 'draft-2' }); + await flushMicrotasks(); + + let timestamp: string | undefined; + await act(async () => { + timestamp = await result.current.takeTurnHeaderTimestamp(); + }); + + // Neither draft-1's stored timestamp nor its pending flush leaks into draft-2. + expect(timestamp).toBeUndefined(); + expect(syncAgentSpec).toHaveBeenCalledOnce(); + expect(result.current.isSpecSyncing).toBe(false); + }); + + it('flushes pending work before a coordinated save', async () => { + const syncAgentSpec = vi.fn().mockResolvedValue('2026-06-30T15:00:00.000Z'); + const draftBridge: DraftSessionBridge = { + getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec), + syncAgentSpec, + }; + const { result } = renderHook(() => + useDraftAgentSpec({ + draftSessionId: 'draft-1', + draftBridge, + defaultAgentSpec, + }), + ); + await flushMicrotasks(); + + act(() => { + result.current.updateAgentSpec({ instructions: 'latest draft' }); + }); + await act(async () => { + await result.current.flushAgentSpec(); + }); + + expect(syncAgentSpec).toHaveBeenCalledWith('draft-1', { + model: defaultAgentSpec.model, + instructions: 'latest draft', + }); + }); + + it('adopts an atomically persisted spec without scheduling another sync', async () => { + const syncAgentSpec = vi.fn().mockResolvedValue('unused'); + const draftBridge: DraftSessionBridge = { + getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec), + syncAgentSpec, + }; + const { result } = renderHook(() => + useDraftAgentSpec({ + draftSessionId: 'draft-1', + draftBridge, + defaultAgentSpec, + }), + ); + await flushMicrotasks(); + + act(() => { + result.current.adoptAgentSpec({ + agentSpec: { + model: { name: 'openai/gpt-5' }, + config: { generativeUi: { enabled: false } }, + }, + updatedAt: '2026-06-30T16:00:00.000Z', + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + + expect(result.current.agentSpec).toEqual({ + model: { name: 'openai/gpt-5' }, + config: { generativeUi: { enabled: false } }, + }); + expect(syncAgentSpec).not.toHaveBeenCalled(); + await expect(result.current.takeTurnHeaderTimestamp()).resolves.toBe('2026-06-30T16:00:00.000Z'); + }); + + it('preserves the last sync timestamp when adopt omits updatedAt', async () => { + const syncAgentSpec = vi.fn().mockResolvedValue('2026-06-30T17:00:00.000Z'); + const draftBridge: DraftSessionBridge = { + getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec), + syncAgentSpec, + }; + const { result } = renderHook(() => + useDraftAgentSpec({ + draftSessionId: 'draft-1', + draftBridge, + defaultAgentSpec, + }), + ); + await flushMicrotasks(); + + act(() => { + result.current.updateAgentSpec({ instructions: 'synced' }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + await flushMicrotasks(); + expect(syncAgentSpec).toHaveBeenCalledOnce(); + + act(() => { + result.current.adoptAgentSpec({ + agentSpec: { + model: { name: 'openai/gpt-5' }, + }, + }); + }); + + await expect(result.current.takeTurnHeaderTimestamp()).resolves.toBe('2026-06-30T17:00:00.000Z'); + }); +}); diff --git a/packages/assistant-ui-runtime/test/draftAgentConfig.test.ts b/packages/assistant-ui-runtime/test/draftAgentConfig.test.ts new file mode 100644 index 000000000..dde7edc73 --- /dev/null +++ b/packages/assistant-ui-runtime/test/draftAgentConfig.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeAgentSpec } from '../src/draft/agentSpec.js'; +import type { AgentChatServer } from '../src/server/index.js'; +import type { AgentSpec } from '../src/server/types.js'; +import { resolveTrueForgeAgentConfig, resolveTrueForgeAgentRuntimeOptions } from '../src/types.js'; + +describe('resolveTrueForgeAgentConfig', () => { + it('supports legacy agentName', () => { + expect(resolveTrueForgeAgentConfig({ agentName: 'my-agent' })).toEqual({ + mode: 'named', + agentName: 'my-agent', + }); + }); + + it('prefers explicit agentName over named config', () => { + expect( + resolveTrueForgeAgentConfig({ + agent: { mode: 'named', agentName: 'ignored' }, + agentName: 'preferred', + }), + ).toEqual({ + mode: 'named', + agentName: 'preferred', + }); + }); + + it('returns draft config unchanged', () => { + const spec = { model: { name: 'openai/gpt-4o' } }; + expect( + resolveTrueForgeAgentConfig({ + agent: { mode: 'draft', defaultAgentSpec: spec }, + }), + ).toEqual({ + mode: 'draft', + defaultAgentSpec: spec, + }); + }); +}); + +describe('resolveTrueForgeAgentRuntimeOptions', () => { + const server = {} as AgentChatServer; + + it('accepts draft mode without a private client', () => { + const resolved = resolveTrueForgeAgentRuntimeOptions({ + server, + agent: { mode: 'draft', defaultAgentSpec: { model: { name: 'x' } } }, + }); + expect(resolved.server).toBe(server); + expect(resolved.agent.mode).toBe('draft'); + }); + + it('resolves named mode with server', () => { + const resolved = resolveTrueForgeAgentRuntimeOptions({ + server, + agent: { mode: 'named', agentName: 'my-agent' }, + }); + expect(resolved.server).toBe(server); + expect(resolved.agent).toEqual({ mode: 'named', agentName: 'my-agent' }); + }); +}); + +describe('mergeAgentSpec', () => { + it('deep-merges model params', () => { + const base = { + model: { + name: 'anthropic/claude-sonnet-4-6', + params: { maxTokens: 1024, reasoningEffort: 'medium' }, + }, + }; + const next = mergeAgentSpec(base, { + model: { params: { reasoningEffort: 'high' } }, + }); + expect(next.model.params).toEqual({ maxTokens: 1024, reasoningEffort: 'high' }); + }); + + it('replaces mcpServers array wholesale', () => { + const base: AgentSpec = { + model: { name: 'openai/gpt-4o' }, + mcpServers: [{ id: 'github', name: 'github' }], + }; + const next = mergeAgentSpec(base, { + mcpServers: [{ id: 'slack', name: 'slack' }], + }); + expect(next.mcpServers).toEqual([{ id: 'slack', name: 'slack' }]); + }); + + it('replaces skills array wholesale', () => { + const base: AgentSpec = { + model: { name: 'openai/gpt-4o' }, + skills: [{ id: 'skill-a', name: 'skill-a' }], + }; + const next = mergeAgentSpec(base, { + skills: [{ id: 'skill-b', name: 'skill-b' }], + }); + expect(next.skills).toEqual([{ id: 'skill-b', name: 'skill-b' }]); + }); + + it('model partial update does not clear mcpServers or skills', () => { + const base: AgentSpec = { + model: { name: 'openai/gpt-4o', params: { maxTokens: 1024 } }, + mcpServers: [{ id: 'github', name: 'github' }], + skills: [{ id: 'skill-a', name: 'skill-a' }], + }; + const next = mergeAgentSpec(base, { + model: { name: 'anthropic/claude-sonnet-4-6' }, + }); + expect(next.model.name).toBe('anthropic/claude-sonnet-4-6'); + expect(next.mcpServers).toEqual(base.mcpServers); + expect(next.skills).toEqual(base.skills); + }); +}); diff --git a/packages/assistant-ui-runtime/test/foldPeerThreads.test.ts b/packages/assistant-ui-runtime/test/foldPeerThreads.test.ts new file mode 100644 index 000000000..69eae2510 --- /dev/null +++ b/packages/assistant-ui-runtime/test/foldPeerThreads.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, it } from 'vitest'; +import type { ModelMessageEvent, ThreadCreatedEvent, TurnEvent } from '../src/server/index.js'; + +import { ROOT_THREAD_ID } from '../src/constants.js'; +import { + buildRootAssistantContent, + ingestStreamEvent, + ingestTurnEvent, + PeerThreadFoldState, + recordToolApprovalInFold, + recordToolResponseInFold, +} from '../src/foldPeerThreads.js'; + +const createdAt = new Date().toISOString(); + +function modelMessage(event: Omit): ModelMessageEvent { + return { type: 'model.message', createdAt, ...event }; +} + +function threadCreated(event: Omit): ThreadCreatedEvent { + return { type: 'thread.created', createdAt, ...event }; +} + +describe('foldPeerThreads', () => { + it('ignores sandbox.created (no threadId, not foldable) without crashing', () => { + const state = new PeerThreadFoldState(); + const sandboxEvent = { + type: 'sandbox.created' as const, + id: 'sandbox-evt', + createdAt, + sandboxId: 'sbx-123', + } as unknown as TurnEvent; + + expect(() => ingestTurnEvent(state, sandboxEvent)).not.toThrow(); + expect(ingestStreamEvent(state, sandboxEvent)).toBe(false); + expect(buildRootAssistantContent(state)).toEqual([]); + }); + + it('anchors root content on main without inference', () => { + const state = new PeerThreadFoldState(); + + const rootMessage: TurnEvent = modelMessage({ + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'hello', + }); + + ingestTurnEvent(state, rootMessage); + + expect(buildRootAssistantContent(state)).toEqual([{ type: 'text', text: 'hello' }]); + }); + + it('captures sub-agent title from thread.created and tags nested messages', () => { + const state = new PeerThreadFoldState(); + + ingestTurnEvent( + state, + modelMessage({ + id: 'root-msg', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'spawn-1', + type: 'function', + function: { name: 'create_sub_agent', arguments: '{}' }, + toolInfo: { type: 'trueforge-system', name: 'create_sub_agent' }, + }, + ], + }), + ); + + ingestTurnEvent( + state, + threadCreated({ + id: 't-created', + threadId: 'child-1', + title: 'Research agent', + agentInfo: { type: 'dynamic', name: 'researcher', input: 'do research' }, + parent: { threadId: ROOT_THREAD_ID, toolCallId: 'spawn-1' }, + }), + ); + + ingestTurnEvent( + state, + modelMessage({ + id: 'child-msg', + threadId: 'child-1', + content: 'working', + }), + ); + + const parts = buildRootAssistantContent(state); + const spawn = parts.find(part => part.type === 'tool-call'); + expect(spawn?.type).toBe('tool-call'); + if (spawn?.type !== 'tool-call') { + return; + } + + expect(spawn.artifact).toEqual({ + subAgents: [ + { + threadId: 'child-1', + title: 'Research agent', + agentInfo: { + type: 'dynamic', + name: 'researcher', + input: 'do research', + }, + }, + ], + }); + + const nested = spawn.messages?.[0]; + expect(nested?.metadata.custom['subAgent']).toEqual({ + threadId: 'child-1', + title: 'Research agent', + name: 'researcher', + input: 'do research', + }); + }); + + it('attaches sub-agent artifact from thread.created before child model messages arrive', () => { + const state = new PeerThreadFoldState(); + + ingestTurnEvent( + state, + modelMessage({ + id: 'root-msg', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'spawn-1', + type: 'function', + function: { name: 'create_sub_agent', arguments: '{}' }, + toolInfo: { type: 'trueforge-system', name: 'create_sub_agent' }, + }, + ], + }), + ); + + ingestTurnEvent( + state, + threadCreated({ + id: 't-created', + threadId: 'child-1', + title: 'gateway-usage-7d', + agentInfo: { + type: 'dynamic', + name: 'gateway-usage-7d', + input: 'run queries', + }, + parent: { threadId: ROOT_THREAD_ID, toolCallId: 'spawn-1' }, + }), + ); + + const parts = buildRootAssistantContent(state); + const spawn = parts.find(part => part.type === 'tool-call'); + expect(spawn?.type).toBe('tool-call'); + if (spawn?.type !== 'tool-call') { + return; + } + + expect(spawn.messages).toBeUndefined(); + expect(spawn.artifact).toEqual({ + subAgents: [ + { + threadId: 'child-1', + title: 'gateway-usage-7d', + agentInfo: { + type: 'dynamic', + name: 'gateway-usage-7d', + input: 'run queries', + }, + }, + ], + }); + }); + + it('classifies stream events by thread id', () => { + const state = new PeerThreadFoldState(); + expect( + ingestStreamEvent( + state, + threadCreated({ + id: 'e1', + threadId: 'child-1', + title: 'Child', + agentInfo: { type: 'dynamic', name: 'child', input: 'x' }, + parent: { threadId: ROOT_THREAD_ID, toolCallId: 'tc' }, + }), + ), + ).toBe(true); + expect(state.threads.get('child-1')?.title).toBe('Child'); + }); + + it('ingests tool.response_required and marks tool calls as pending responses', () => { + const state = new PeerThreadFoldState(); + + ingestTurnEvent( + state, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { type: 'trueforge-system', name: 'ask_user_question' }, + }, + ], + }), + ); + + ingestTurnEvent(state, { + type: 'tool.response_required', + id: 'resp-req-1', + createdAt, + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }); + + const bucket = state.threads.get(ROOT_THREAD_ID)!; + expect(bucket.pendingResponses.get('question-1')).toMatchObject({ + id: 'question-1', + sourceEventId: 'model-1', + question: 'Pick one', + options: ['A', 'B'], + }); + + const parts = buildRootAssistantContent(state); + const toolCall = parts.find(part => part.type === 'tool-call'); + expect(toolCall?.type).toBe('tool-call'); + if (toolCall?.type !== 'tool-call') { + return; + } + expect(toolCall.interrupt).toEqual({ + type: 'human', + payload: { question: 'Pick one', options: ['A', 'B'] }, + }); + }); + + it('recordToolResponseInFold clears pending and attaches result', () => { + const state = new PeerThreadFoldState(); + + ingestTurnEvent( + state, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { type: 'trueforge-system', name: 'ask_user_question' }, + }, + ], + }), + ); + + ingestTurnEvent(state, { + type: 'tool.response_required', + id: 'resp-req-1', + createdAt, + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }); + + recordToolResponseInFold(state, { + toolCallId: 'question-1', + content: 'A', + }); + + const bucket = state.threads.get(ROOT_THREAD_ID)!; + expect(bucket.pendingResponses.has('question-1')).toBe(false); + expect(bucket.toolResults.get('question-1')).toBe('A'); + + const toolCall = buildRootAssistantContent(state).find(part => part.type === 'tool-call'); + expect(toolCall?.type).toBe('tool-call'); + if (toolCall?.type !== 'tool-call') { + return; + } + expect(toolCall.result).toBe('A'); + expect(toolCall.interrupt).toBeUndefined(); + }); + + it('ingests tool.response and clears pending ask-user state', () => { + const state = new PeerThreadFoldState(); + + ingestTurnEvent( + state, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { type: 'trueforge-system', name: 'ask_user_question' }, + }, + ], + }), + ); + + ingestTurnEvent(state, { + type: 'tool.response_required', + id: 'resp-req-1', + createdAt, + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }); + + ingestTurnEvent(state, { + type: 'tool.response', + id: 'resp-1', + createdAt, + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'A', + }); + + const bucket = state.threads.get(ROOT_THREAD_ID)!; + expect(bucket.pendingResponses.has('question-1')).toBe(false); + expect(bucket.toolResults.get('question-1')).toBe('A'); + + const toolCall = buildRootAssistantContent(state).find(part => part.type === 'tool-call'); + expect(toolCall?.type).toBe('tool-call'); + if (toolCall?.type !== 'tool-call') { + return; + } + expect(toolCall.result).toBe('A'); + expect(toolCall.interrupt).toBeUndefined(); + }); + + function seedPendingApproval(): PeerThreadFoldState { + const state = new PeerThreadFoldState(); + ingestTurnEvent( + state, + modelMessage({ + id: 'model-1', + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'tool-1', + type: 'function', + function: { name: 'run_shell', arguments: '{}' }, + toolInfo: { + type: 'mcp', + name: 'run_shell', + serverId: 'srv-1', + serverName: 'shell', + }, + }, + ], + }), + ); + ingestTurnEvent(state, { + type: 'tool.approval_required', + id: 'appr-req-1', + createdAt, + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'tool-1', sourceEventId: 'model-1' }], + }); + return state; + } + + it('recordToolApprovalInFold resolves a pending approval on allow', () => { + const state = seedPendingApproval(); + const bucket = state.threads.get(ROOT_THREAD_ID)!; + expect(bucket.pendingApprovals.has('tool-1')).toBe(true); + + recordToolApprovalInFold(state, { toolCallId: 'tool-1', approved: true }); + + // Once decided, the approval must leave the pending set so the turn is + // not re-paused (and the user re-prompted) for it after resume. + expect(bucket.pendingApprovals.has('tool-1')).toBe(false); + + const toolCall = buildRootAssistantContent(state).find(part => part.type === 'tool-call'); + if (toolCall?.type !== 'tool-call') { + throw new Error('expected a tool-call part'); + } + expect(toolCall.approval?.approved).toBe(true); + }); + + it('recordToolApprovalInFold synthesizes an error result on deny', () => { + const state = seedPendingApproval(); + + recordToolApprovalInFold(state, { + toolCallId: 'tool-1', + approved: false, + reason: 'not allowed', + }); + + const bucket = state.threads.get(ROOT_THREAD_ID)!; + expect(bucket.pendingApprovals.has('tool-1')).toBe(false); + + const toolCall = buildRootAssistantContent(state).find(part => part.type === 'tool-call'); + if (toolCall?.type !== 'tool-call') { + throw new Error('expected a tool-call part'); + } + expect(toolCall.approval?.approved).toBe(false); + expect(toolCall.isError).toBe(true); + expect(toolCall.result).toEqual({ error: 'not allowed' }); + }); +}); diff --git a/packages/assistant-ui-runtime/test/loadSessionSnapshot.test.ts b/packages/assistant-ui-runtime/test/loadSessionSnapshot.test.ts new file mode 100644 index 000000000..c5af17dcd --- /dev/null +++ b/packages/assistant-ui-runtime/test/loadSessionSnapshot.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentChatServer } from '../src/server/index.js'; + +import { createEmptySessionSnapshot } from '../src/sessionSnapshot.js'; + +vi.mock('../src/sessions.js', () => ({ + getSession: vi.fn(), +})); + +vi.mock('../src/convertTurnMessages.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + buildSnapshotFromSessionEvents: vi.fn(), + }; +}); + +import { buildSnapshotFromSessionEvents } from '../src/convertTurnMessages.js'; +import { loadSessionSnapshot } from '../src/loadSessionSnapshot.js'; +import { getSession } from '../src/sessions.js'; + +const mockServer = {} as AgentChatServer; + +describe('loadSessionSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deduplicates concurrent loads for the same session', async () => { + vi.mocked(getSession).mockResolvedValue({} as never); + vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(createEmptySessionSnapshot()); + + const [first, second] = await Promise.all([ + loadSessionSnapshot(mockServer, 'session-1'), + loadSessionSnapshot(mockServer, 'session-1'), + ]); + + expect(first).toBe(second); + expect(getSession).toHaveBeenCalledTimes(1); + expect(buildSnapshotFromSessionEvents).toHaveBeenCalledTimes(1); + }); + + it('allows a new load after the previous one settles', async () => { + vi.mocked(getSession).mockResolvedValue({} as never); + vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(createEmptySessionSnapshot()); + + await loadSessionSnapshot(mockServer, 'session-1'); + await loadSessionSnapshot(mockServer, 'session-1'); + + expect(getSession).toHaveBeenCalledTimes(2); + expect(buildSnapshotFromSessionEvents).toHaveBeenCalledTimes(2); + }); + + it('forwards onProgress to buildSnapshotFromSessionEvents', async () => { + vi.mocked(getSession).mockResolvedValue({} as never); + vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(createEmptySessionSnapshot()); + + const onProgress = vi.fn(); + await loadSessionSnapshot(mockServer, 'session-1', onProgress); + + expect(buildSnapshotFromSessionEvents).toHaveBeenCalledWith(mockServer, 'session-1', onProgress); + }); +}); diff --git a/packages/assistant-ui-runtime/test/modelMessageImageContent.test.ts b/packages/assistant-ui-runtime/test/modelMessageImageContent.test.ts new file mode 100644 index 000000000..6ef307c81 --- /dev/null +++ b/packages/assistant-ui-runtime/test/modelMessageImageContent.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import type { ModelMessageEvent } from '../src/server/index.js'; + +import { PeerThreadFoldState, ingestStreamEvent } from '../src/foldPeerThreads.js'; +import { buildAssistantContent } from '../src/modelMessageContent.js'; +import { mergeStreamEventDelta } from '../src/modelMessageImageContent.js'; + +const imageDataUri = 'data:image/jpeg;base64,/9j/4AAQ'; + +function modelMessage(event: Omit): ModelMessageEvent { + return { + type: 'model.message', + createdAt: new Date().toISOString(), + ...event, + }; +} + +describe('modelMessageImageContent', () => { + it('merges content_blocks image_url deltas into the base model message', () => { + const base = modelMessage({ + id: 'm1', + threadId: 'main', + }); + + mergeStreamEventDelta(base, { + type: 'model.message.delta', + id: 'm1', + threadId: 'main', + content_blocks: [ + { + index: 0, + delta: { + type: 'image_url', + image_url: { url: imageDataUri }, + }, + }, + ], + } as never); + + expect(base.content).toEqual([ + { + type: 'image_url', + image_url: { url: imageDataUri }, + }, + ]); + }); + + it('buildAssistantContent emits image parts for image_url content', () => { + const parts = buildAssistantContent( + modelMessage({ + id: 'm1', + threadId: 'main', + content: [ + { + type: 'image_url', + image_url: { url: imageDataUri }, + }, + ] as never, + }), + ); + + expect(parts).toEqual([ + { + type: 'image', + image: imageDataUri, + filename: 'image-1.jpeg', + }, + ]); + }); + + it('ingests content_blocks deltas through the fold state', () => { + const fold = new PeerThreadFoldState(); + ingestStreamEvent( + fold, + modelMessage({ + id: 'm1', + threadId: 'main', + }), + ); + ingestStreamEvent(fold, { + type: 'model.message.delta', + id: 'm1', + threadId: 'main', + content_blocks: [ + { + index: 0, + delta: { + type: 'image_url', + image_url: { url: imageDataUri }, + }, + }, + ], + } as never); + + const event = fold.threads.get('main')?.events.get('m1'); + expect(event?.type).toBe('model.message'); + if (event?.type !== 'model.message') { + return; + } + expect(event.content).toEqual([ + { + type: 'image_url', + image_url: { url: imageDataUri }, + }, + ]); + }); +}); diff --git a/packages/assistant-ui-runtime/test/requiredActionInputs.test.ts b/packages/assistant-ui-runtime/test/requiredActionInputs.test.ts new file mode 100644 index 000000000..bff0ccba0 --- /dev/null +++ b/packages/assistant-ui-runtime/test/requiredActionInputs.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from 'vitest'; + +import { ROOT_THREAD_ID } from '../src/constants.js'; +import { collectRequiredActionInputs, messageHasPendingRequiredActions } from '../src/requiredActionInputs.js'; +import { applyApprovalDecisionsToMessage, collectApprovalInputs } from '../src/toolApproval.js'; +import { applyToolResponseToMessage } from '../src/toolResponse.js'; + +describe('requiredActionInputs', () => { + describe('batched resume invariant', () => { + it('bundles approvals and responses once nothing is pending', () => { + const message = { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1', approved: true }, + }, + { + type: 'tool-call' as const, + toolCallId: 'question-1', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { type: 'human' as const, payload: { question: 'Q?' } }, + result: 'answer', + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { + toolApprovalThreadId: ROOT_THREAD_ID, + toolResponseThreadId: ROOT_THREAD_ID, + }, + }, + }; + + expect(messageHasPendingRequiredActions(message)).toBe(false); + expect(collectRequiredActionInputs(message)).toEqual([ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'answer', + }, + ]); + }); + + it('returns empty inputs while any required action is still pending', () => { + const pendingApproval = { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1' }, + }, + { + type: 'tool-call' as const, + toolCallId: 'question-1', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { type: 'human' as const, payload: { question: 'Q?' } }, + result: 'answer', + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }; + + expect(messageHasPendingRequiredActions(pendingApproval)).toBe(true); + expect(collectRequiredActionInputs(pendingApproval)).toEqual([]); + }); + + it('collects nested thread ids independently', () => { + const message = { + id: 'root-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'spawn-1', + toolName: 'create_sub_agent', + args: {}, + argsText: '{}', + messages: [ + { + id: 'child-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-sub', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-sub', approved: true }, + }, + ], + status: { + type: 'requires-action' as const, + reason: 'tool-calls' as const, + }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { toolApprovalThreadId: 'child-1' }, + }, + }, + ], + }, + { + type: 'tool-call' as const, + toolCallId: 'approval-root', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-root', approved: true }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { toolApprovalThreadId: ROOT_THREAD_ID }, + }, + }; + + expect(collectApprovalInputs(message, ROOT_THREAD_ID)).toEqual([ + { + type: 'user.tool_approval', + threadId: 'child-1', + toolCallId: 'approval-sub', + approval: { status: 'allow' }, + }, + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-root', + approval: { status: 'allow' }, + }, + ]); + }); + + it('collects both action types after approvals and responses are staged', () => { + const pending = { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1' }, + }, + { + type: 'tool-call' as const, + toolCallId: 'question-1', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { + type: 'human' as const, + payload: { question: 'Pick one', options: ['A', 'B'] }, + }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { + toolApprovalThreadId: ROOT_THREAD_ID, + toolResponseThreadId: ROOT_THREAD_ID, + }, + }, + }; + + const decided = applyApprovalDecisionsToMessage(pending, { + approvalId: 'approval-1', + approved: true, + }); + expect(messageHasPendingRequiredActions(decided)).toBe(true); + expect(collectRequiredActionInputs(decided)).toEqual([]); + + const answered = applyToolResponseToMessage(decided, { + toolCallId: 'question-1', + content: 'A', + }); + expect(messageHasPendingRequiredActions(answered)).toBe(false); + expect(collectRequiredActionInputs(answered)).toEqual([ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'A', + }, + ]); + }); + + it('does not emit approval-only inputs while a response is still pending', () => { + const approvalDecidedResponsePending = applyApprovalDecisionsToMessage( + { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1' }, + }, + { + type: 'tool-call' as const, + toolCallId: 'question-1', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { + type: 'human' as const, + payload: { question: 'Pick one' }, + }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { + toolApprovalThreadId: ROOT_THREAD_ID, + toolResponseThreadId: ROOT_THREAD_ID, + }, + }, + }, + { approvalId: 'approval-1', approved: true }, + ); + + expect(collectApprovalInputs(approvalDecidedResponsePending, ROOT_THREAD_ID)).toEqual([ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + ]); + expect(collectRequiredActionInputs(approvalDecidedResponsePending)).toEqual([]); + }); + + it('bundles nested multi-thread approvals and responses in one input array', () => { + const pending = { + id: 'root-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'spawn-1', + toolName: 'create_sub_agent', + args: {}, + argsText: '{}', + messages: [ + { + id: 'child-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'question-sub', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { + type: 'human' as const, + payload: { question: 'Sub?' }, + }, + }, + ], + status: { + type: 'requires-action' as const, + reason: 'tool-calls' as const, + }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { toolResponseThreadId: 'child-1' }, + }, + }, + ], + }, + { + type: 'tool-call' as const, + toolCallId: 'approval-root', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-root' }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { toolApprovalThreadId: ROOT_THREAD_ID }, + }, + }; + + const answered = applyToolResponseToMessage( + applyApprovalDecisionsToMessage(pending, { + approvalId: 'approval-root', + approved: true, + }), + { toolCallId: 'question-sub', content: 'sub-answer' }, + ); + + expect(collectRequiredActionInputs(answered)).toEqual([ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-root', + approval: { status: 'allow' }, + }, + { + type: 'user.tool_response', + threadId: 'child-1', + toolCallId: 'question-sub', + content: 'sub-answer', + }, + ]); + }); + }); +}); diff --git a/packages/assistant-ui-runtime/test/requiredActionsFromActiveUpdate.test.ts b/packages/assistant-ui-runtime/test/requiredActionsFromActiveUpdate.test.ts new file mode 100644 index 000000000..bc8fb42b6 --- /dev/null +++ b/packages/assistant-ui-runtime/test/requiredActionsFromActiveUpdate.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { ROOT_THREAD_ID } from '../src/constants.js'; +import { TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY, toolApprovalStatus } from '../src/toolApproval.js'; +import { TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY, toolResponseStatus } from '../src/toolResponse.js'; +import type { TurnStreamUpdate } from '../src/turnStreamUpdate.js'; +import { requiredActionsFromActiveUpdate } from '../src/useTrueForgeAgentMessages.js'; + +/** + * Regression coverage for the live-stream pause path: when a turn pauses at a + * tool approval or an `ask_user_question` (`tool.response_required`), + * `commitActiveStream` fabricates a synthetic `TurnStateDone`. Its + * `requiredActions` are reconstructed here from the paused update. If they were + * dropped (as they were before the fix), the committed assistant message would + * lose its `requires-action` status and the resume turn would never be sent. + */ +describe('requiredActionsFromActiveUpdate', () => { + it('reconstructs a tool.response_required pause (ask_user_question)', () => { + const update: TurnStreamUpdate = { + content: [], + status: toolResponseStatus(), + metadata: { + custom: { [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID }, + }, + }; + + expect(requiredActionsFromActiveUpdate(update)).toEqual([ + expect.objectContaining({ + type: 'tool.response_required', + threadId: ROOT_THREAD_ID, + toolCalls: [], + }), + ]); + }); + + it('reconstructs a tool.approval_required pause', () => { + const update: TurnStreamUpdate = { + content: [], + status: toolApprovalStatus(), + metadata: { + custom: { [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: 'sub-agent-1' }, + }, + }; + + expect(requiredActionsFromActiveUpdate(update)).toEqual([ + expect.objectContaining({ + type: 'tool.approval_required', + threadId: 'sub-agent-1', + toolCalls: [], + }), + ]); + }); + + it('reconstructs both approval and response pauses in one turn', () => { + const update: TurnStreamUpdate = { + content: [], + status: toolApprovalStatus(), + metadata: { + custom: { + [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID, + [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: 'sub-agent-1', + }, + }, + }; + + const actions = requiredActionsFromActiveUpdate(update); + expect(actions?.map(action => action.type)).toEqual(['tool.approval_required', 'tool.response_required']); + }); + + it('reconstructs an mcp.auth_required pause', () => { + const mcpServers = [{ id: 'gh', name: 'GitHub', authUrl: 'https://auth' }]; + const update: TurnStreamUpdate = { + content: [], + metadata: { custom: { pendingMcpAuth: true, mcpServers } }, + }; + + expect(requiredActionsFromActiveUpdate(update)).toEqual([ + expect.objectContaining({ type: 'mcp.auth_required', mcpServers }), + ]); + }); + + it('returns no actions for a completed (non-paused) update', () => { + const update: TurnStreamUpdate = { + content: [], + metadata: { + custom: { [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID }, + }, + }; + + expect(requiredActionsFromActiveUpdate(update)).toEqual([]); + }); +}); diff --git a/packages/assistant-ui-runtime/test/sandboxDownload.test.ts b/packages/assistant-ui-runtime/test/sandboxDownload.test.ts new file mode 100644 index 000000000..2f2a1274b --- /dev/null +++ b/packages/assistant-ui-runtime/test/sandboxDownload.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSandboxDownloadRequest } from '../src/sandboxDownload.js'; + +describe('buildSandboxDownloadRequest', () => { + it('succeeds with turnId and path when sandboxId is missing (resume without sandbox.created in window)', () => { + expect( + buildSandboxDownloadRequest({ + sessionId: 'session-1', + turnId: 'turn-tip', + path: '/tmp/out.txt', + }), + ).toEqual({ + sessionId: 'session-1', + turnId: 'turn-tip', + path: '/tmp/out.txt', + }); + }); + + it('includes sandboxId when the live/history path recovered it', () => { + expect( + buildSandboxDownloadRequest({ + sessionId: 'session-1', + turnId: 'turn-1', + path: '/tmp/out.txt', + sandboxId: 'sbx-123', + }), + ).toEqual({ + sessionId: 'session-1', + turnId: 'turn-1', + path: '/tmp/out.txt', + sandboxId: 'sbx-123', + }); + }); + + it('errors only when sessionId is missing', () => { + expect(() => + buildSandboxDownloadRequest({ + sessionId: undefined, + turnId: 'turn-1', + path: '/tmp/out.txt', + sandboxId: 'sbx-123', + }), + ).toThrow(/session has not been saved/); + }); +}); diff --git a/packages/assistant-ui-runtime/test/server/types.test.ts b/packages/assistant-ui-runtime/test/server/types.test.ts new file mode 100644 index 000000000..6ff5458c9 --- /dev/null +++ b/packages/assistant-ui-runtime/test/server/types.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import type { PermissionsServer } from '../../src/server/types.js'; + +describe('PermissionsServer', () => { + it('supports USE grants for agents', async () => { + const server: PermissionsServer = { + listPermissions: async () => ({ + data: { type: 'agent', permissions: { agent: ['USE'] } }, + }), + }; + + await expect(server.listPermissions({ resourceType: 'agent', resourceIds: ['agent'] })).resolves.toEqual({ + data: { type: 'agent', permissions: { agent: ['USE'] } }, + }); + }); + + it('supports tenant CREATE grants keyed by entity kind', async () => { + const server: PermissionsServer = { + listPermissions: async () => ({ + data: { type: 'tenant', permissions: { agent: ['CREATE'] } }, + }), + }; + + await expect(server.listPermissions({ resourceType: 'tenant', resourceIds: [] })).resolves.toEqual({ + data: { type: 'tenant', permissions: { agent: ['CREATE'] } }, + }); + }); +}); diff --git a/packages/assistant-ui-runtime/test/streamTurn.test.ts b/packages/assistant-ui-runtime/test/streamTurn.test.ts new file mode 100644 index 000000000..e0e522580 --- /dev/null +++ b/packages/assistant-ui-runtime/test/streamTurn.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AgentChatServer, TurnStreamData } from '../src/server/index.js'; + +import { ROOT_THREAD_ID } from '../src/constants.js'; +import { PeerThreadFoldState } from '../src/foldPeerThreads.js'; +import { resumeTurnStream, streamTurnContent } from '../src/streamTurn.js'; + +const createdAt = new Date().toISOString(); +const SESSION_ID = 'session-1'; + +function streamData(sequenceNumber: number, event: TurnStreamData['event'] | Record): TurnStreamData { + return { sequenceNumber, event: event as TurnStreamData['event'] }; +} + +function mockServer(partial: Record): AgentChatServer { + return partial as unknown as AgentChatServer; +} + +async function collectUpdates(generator: AsyncGenerator<{ content: unknown[] }>): Promise<{ content: unknown[] }[]> { + const updates: { content: unknown[] }[] = []; + for await (const update of generator) { + updates.push(update); + } + return updates; +} + +describe('streamTurn', () => { + describe('streamTurnContent', () => { + it('prepares a user turn and yields folded stream updates', async () => { + const foldState = new PeerThreadFoldState(); + const createTurn = vi.fn(async function* () { + yield streamData(1, { + type: 'model.message', + createdAt, + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'hello from stream', + }); + }); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + + const updates = await collectUpdates( + streamTurnContent(server, SESSION_ID, foldState, { userMessage: 'hello' }, new AbortController().signal), + ); + + expect(createTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + input: [{ type: 'user.message', content: 'hello' }], + previousTurnId: 'auto', + abortSignal: expect.any(AbortSignal), + }); + expect(updates).toEqual([{ content: [{ type: 'text', text: 'hello from stream' }] }]); + }); + + it('passes required-action inputs through createTurn', async () => { + const inputs = [ + { + type: 'user.tool_approval' as const, + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' as const }, + }, + { + type: 'user.tool_response' as const, + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'A', + }, + ]; + const createTurn = vi.fn(async function* () {}); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + + await collectUpdates( + streamTurnContent(server, SESSION_ID, new PeerThreadFoldState(), { inputs }, new AbortController().signal), + ); + + expect(createTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + input: inputs, + previousTurnId: 'auto', + abortSignal: expect.any(AbortSignal), + }); + }); + + it('uses empty input when resuming after MCP auth', async () => { + const createTurn = vi.fn(async function* () {}); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + + await collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { resumeMcpAuth: true }, + new AbortController().signal, + ), + ); + + expect(createTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + input: [], + previousTurnId: 'auto', + abortSignal: expect.any(AbortSignal), + }); + }); + + it('forwards an explicit previousTurnId when branching', async () => { + const createTurn = vi.fn(async function* () {}); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + + await collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { userMessage: 'edited', previousTurnId: 'turn-a' }, + new AbortController().signal, + ), + ); + + expect(createTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + input: [{ type: 'user.message', content: 'edited' }], + previousTurnId: 'turn-a', + abortSignal: expect.any(AbortSignal), + }); + }); + + it('forwards previousTurnId "none" when branching from root', async () => { + const createTurn = vi.fn(async function* () {}); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + + await collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { userMessage: 'first', previousTurnId: 'none' }, + new AbortController().signal, + ), + ); + + expect(createTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + input: [{ type: 'user.message', content: 'first' }], + previousTurnId: 'none', + abortSignal: expect.any(AbortSignal), + }); + }); + + it('returns early without cancelling the backend run when already aborted', async () => { + const createTurn = vi.fn(async function* () {}); + const cancelSession = vi.fn().mockResolvedValue(undefined); + const server = mockServer({ createTurn, cancelSession }); + const abortController = new AbortController(); + abortController.abort(); + + const updates = await collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { userMessage: 'hello' }, + abortController.signal, + ), + ); + + expect(cancelSession).not.toHaveBeenCalled(); + expect(createTurn).not.toHaveBeenCalled(); + expect(updates).toEqual([]); + }); + + it('does not cancel the backend run when the stream is aborted mid-flight', async () => { + const abortController = new AbortController(); + const createTurn = vi.fn(async function* () { + yield streamData(1, { + type: 'model.message', + createdAt, + id: 'm1', + threadId: ROOT_THREAD_ID, + content: 'partial', + }); + abortController.abort(); + }); + const cancelSession = vi.fn().mockResolvedValue(undefined); + const server = mockServer({ createTurn, cancelSession }); + + await collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { userMessage: 'hello' }, + abortController.signal, + ), + ); + + expect(cancelSession).not.toHaveBeenCalled(); + }); + + it('forwards headers to createTurn', async () => { + const createTurn = vi.fn(async function* () {}); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + const abortSignal = new AbortController().signal; + + await collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { + userMessage: 'hello', + headers: { + 'x-tfy-session-last-updated-at': '2026-06-30T10:00:00.000Z', + }, + }, + abortSignal, + ), + ); + + expect(createTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + input: [{ type: 'user.message', content: 'hello' }], + previousTurnId: 'auto', + abortSignal, + headers: { + 'x-tfy-session-last-updated-at': '2026-06-30T10:00:00.000Z', + }, + }); + }); + + it('notifies gateway turn id when turn.done errors with no content yields', async () => { + const gatewayTurnId = '01ky6mqzmczwt6ssyd5r02gjjc'; + const createTurn = vi.fn(async function* () { + yield streamData(1, { + type: 'turn.created', + createdAt, + id: 'created-1', + turnId: gatewayTurnId, + input: [{ type: 'user.message', content: 'hello' }], + }); + yield streamData(2, { + type: 'turn.done', + createdAt, + id: 'done-1', + state: { + status: 'error', + message: 'Publisher Model is not servable in region us-central1.', + completedAt: createdAt, + }, + }); + }); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + const onTurnIdAvailable = vi.fn(); + + await expect( + collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { userMessage: 'hello' }, + new AbortController().signal, + undefined, + onTurnIdAvailable, + ), + ), + ).rejects.toThrow('Publisher Model is not servable in region us-central1.'); + + expect(onTurnIdAvailable).toHaveBeenCalledTimes(1); + expect(onTurnIdAvailable).toHaveBeenCalledWith(gatewayTurnId); + }); + + it('does not notify when an error stream never emits turn.created', async () => { + const createTurn = vi.fn(async function* () { + yield streamData(1, { + type: 'turn.done', + createdAt, + id: 'done-1', + state: { + status: 'error', + completedAt: createdAt, + message: 'boom', + }, + }); + }); + const server = mockServer({ + createTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + const onTurnIdAvailable = vi.fn(); + + await expect( + collectUpdates( + streamTurnContent( + server, + SESSION_ID, + new PeerThreadFoldState(), + { userMessage: 'hello' }, + new AbortController().signal, + undefined, + onTurnIdAvailable, + ), + ), + ).rejects.toThrow('boom'); + + expect(onTurnIdAvailable).not.toHaveBeenCalled(); + }); + }); + + describe('resumeTurnStream', () => { + it('reconnects with afterSequenceNumber and yields updates', async () => { + const foldState = new PeerThreadFoldState(); + const subscribeToTurn = vi.fn(async function* () { + yield streamData(2, { + type: 'model.message', + createdAt, + id: 'm2', + threadId: ROOT_THREAD_ID, + content: 'resumed', + }); + }); + const server = mockServer({ + subscribeToTurn, + cancelSession: vi.fn().mockResolvedValue(undefined), + }); + + const updates = await collectUpdates( + resumeTurnStream(server, SESSION_ID, 'turn-1', foldState, new AbortController().signal, 1), + ); + expect(subscribeToTurn).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + turnId: 'turn-1', + afterSequenceNumber: 1, + abortSignal: expect.any(AbortSignal), + }); + expect(updates).toEqual([{ content: [{ type: 'text', text: 'resumed' }] }]); + }); + + it('yields nothing when the server omits subscribeToTurn', async () => { + const cancelSession = vi.fn().mockResolvedValue(undefined); + const server = mockServer({ cancelSession }); + + const updates = await collectUpdates( + resumeTurnStream(server, SESSION_ID, 'turn-1', new PeerThreadFoldState(), new AbortController().signal), + ); + + expect(updates).toEqual([]); + expect(cancelSession).not.toHaveBeenCalled(); + }); + + it('returns early when aborted before streaming starts', async () => { + const subscribeToTurn = vi.fn(async function* () {}); + const cancelSession = vi.fn().mockResolvedValue(undefined); + const server = mockServer({ subscribeToTurn, cancelSession }); + const abortController = new AbortController(); + abortController.abort(); + + const updates = await collectUpdates( + resumeTurnStream(server, SESSION_ID, 'turn-1', new PeerThreadFoldState(), abortController.signal), + ); + + expect(cancelSession).not.toHaveBeenCalled(); + expect(subscribeToTurn).not.toHaveBeenCalled(); + expect(updates).toEqual([]); + }); + }); +}); diff --git a/packages/assistant-ui-runtime/test/toolApproval.test.ts b/packages/assistant-ui-runtime/test/toolApproval.test.ts new file mode 100644 index 000000000..904afdaa5 --- /dev/null +++ b/packages/assistant-ui-runtime/test/toolApproval.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyApprovalDecisionsToMessage, + collectApprovalInputs, + messageHasPendingApprovals, +} from '../src/toolApproval.js'; + +describe('toolApproval', () => { + it('detects pending nested approvals', () => { + const message = { + id: 'a1', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'tc-root', + toolName: 'create_sub_agent', + args: {}, + argsText: '{}', + messages: [ + { + id: 'sub', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'tc-sub', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-sub' }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }, + ], + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }; + + expect(messageHasPendingApprovals(message)).toBe(true); + }); + + it('collects decided approvals for sdk resume', () => { + const pending = { + id: 'a1', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1' }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }; + + const decided = applyApprovalDecisionsToMessage(pending, { + approvalId: 'approval-1', + approved: true, + }); + + expect(messageHasPendingApprovals(decided)).toBe(false); + const inputs = collectApprovalInputs(decided, 'main'); + expect(inputs).toEqual([ + { + type: 'user.tool_approval', + threadId: 'main', + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + ]); + }); + + it('collects root approvals when custom thread id metadata is present', () => { + const pending = { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1' }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { toolApprovalThreadId: 'main' }, + }, + }; + + const decided = applyApprovalDecisionsToMessage(pending, { + approvalId: 'approval-1', + approved: true, + }); + + expect(messageHasPendingApprovals(decided)).toBe(false); + expect(collectApprovalInputs(decided, 'main')).toHaveLength(1); + }); +}); diff --git a/packages/assistant-ui-runtime/test/toolResponse.test.ts b/packages/assistant-ui-runtime/test/toolResponse.test.ts new file mode 100644 index 000000000..5a138328b --- /dev/null +++ b/packages/assistant-ui-runtime/test/toolResponse.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { ROOT_THREAD_ID } from '../src/constants.js'; +import { PeerThreadFoldState } from '../src/foldPeerThreads.js'; +import { + applyToolResponseToMessage, + applyUserToolResponsesToFold, + collectResponseInputs, + messageHasPendingResponses, +} from '../src/toolResponse.js'; + +describe('toolResponse', () => { + it('detects pending nested tool responses', () => { + const message = { + id: 'a1', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'tc-root', + toolName: 'create_sub_agent', + args: {}, + argsText: '{}', + messages: [ + { + id: 'sub', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'question-1', + toolName: 'ask_user_question', + args: { question: 'Pick one' }, + argsText: '{"question":"Pick one"}', + interrupt: { + type: 'human' as const, + payload: { question: 'Pick one', options: ['A', 'B'] }, + }, + }, + ], + status: { + type: 'requires-action' as const, + reason: 'tool-calls' as const, + }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { toolResponseThreadId: 'child-1' }, + }, + }, + ], + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {}, + }, + }; + + expect(messageHasPendingResponses(message)).toBe(true); + }); + + it('collects staged tool responses for sdk resume', () => { + const pending = { + id: 'a1', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'question-1', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { + type: 'human' as const, + payload: { question: 'Pick one', options: ['A', 'B'] }, + }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { toolResponseThreadId: 'main' }, + }, + }; + + const answered = applyToolResponseToMessage(pending, { + toolCallId: 'question-1', + content: 'A', + }); + + expect(messageHasPendingResponses(answered)).toBe(false); + expect(collectResponseInputs(answered, 'main')).toEqual([ + { + type: 'user.tool_response', + threadId: 'main', + toolCallId: 'question-1', + content: 'A', + }, + ]); + }); + + it('applyUserToolResponsesToFold records answers from turn input', () => { + const fold = new PeerThreadFoldState(); + const bucket = fold.getOrCreateBucket(ROOT_THREAD_ID); + bucket.pendingResponses.set('question-1', { + id: 'question-1', + sourceEventId: 'model-1', + question: 'Pick one', + }); + + applyUserToolResponsesToFold(fold, [ + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'A', + }, + ]); + + expect(bucket.pendingResponses.has('question-1')).toBe(false); + expect(bucket.toolResults.get('question-1')).toBe('A'); + }); +}); diff --git a/packages/assistant-ui-runtime/test/trueforgeExtras.test.ts b/packages/assistant-ui-runtime/test/trueforgeExtras.test.ts new file mode 100644 index 000000000..321c8cced --- /dev/null +++ b/packages/assistant-ui-runtime/test/trueforgeExtras.test.ts @@ -0,0 +1,156 @@ +import type { AssistantClient } from '@assistant-ui/store'; +import { describe, expect, it, vi } from 'vitest'; + +import { + getTrueForgeExtras, + trueForgeExtras, + tryGetTrueForgeExtras, + type TrueForgeRuntimeExtras, +} from '../src/trueforgeExtras.js'; + +function clientWithExtras(extras: unknown): AssistantClient { + return { + thread: () => ({ + getState: () => ({ extras }), + }), + } as unknown as AssistantClient; +} + +describe('tryGetTrueForgeExtras', () => { + it('reads extras from the current client', () => { + const extras = trueForgeExtras.provide({ + pendingApprovals: [], + pendingToolResponses: [], + pendingMcpAuth: null, + resumeUnavailable: false, + sandboxId: undefined, + respondToToolApproval: vi.fn(), + respondToToolResponse: vi.fn(), + resumeMcpAuth: vi.fn(), + downloadSandboxFile: vi.fn(), + cancel: vi.fn(), + resetFromTurn: vi.fn(), + reload: vi.fn(), + hasOlderHistory: false, + isLoadingOlderHistory: false, + loadOlderHistory: vi.fn(), + draft: null, + } satisfies TrueForgeRuntimeExtras); + + expect(tryGetTrueForgeExtras(clientWithExtras(extras))).toBe(extras); + }); + + it('walks nested readonly clients (Object.create parent) to find root extras', () => { + const respondToToolApproval = vi.fn(); + const extras = trueForgeExtras.provide({ + pendingApprovals: [{ approvalId: 'a1', threadId: 'child-1', toolName: 'bash', args: {}, argsText: '{}' }], + pendingToolResponses: [], + pendingMcpAuth: null, + resumeUnavailable: false, + sandboxId: undefined, + respondToToolApproval, + respondToToolResponse: vi.fn(), + resumeMcpAuth: vi.fn(), + downloadSandboxFile: vi.fn(), + cancel: vi.fn(), + resetFromTurn: vi.fn(), + reload: vi.fn(), + hasOlderHistory: false, + isLoadingOlderHistory: false, + loadOlderHistory: vi.fn(), + draft: null, + } satisfies TrueForgeRuntimeExtras); + + const root = clientWithExtras(extras); + // Mirrors ReadonlyThreadProvider: nested AUI is Object.create(parent) with + // thread overwritten to a readonly client that has no extras. + const nested = Object.assign(Object.create(root), { + thread: () => ({ + getState: () => ({ extras: undefined }), + }), + }) as AssistantClient; + + expect(tryGetTrueForgeExtras(nested)).toBe(extras); + // Namespace .get must walk too (not only the named helper). + expect(trueForgeExtras.get(nested)).toBe(extras); + getTrueForgeExtras(nested).respondToToolApproval({ + approvalId: 'a1', + approved: true, + }); + expect(respondToToolApproval).toHaveBeenCalledWith({ + approvalId: 'a1', + approved: true, + }); + }); + + it('returns undefined when no ancestor has TrueForge extras', () => { + expect(tryGetTrueForgeExtras(clientWithExtras(undefined))).toBeUndefined(); + }); + + it('survives RootAssistantClient-style proxies that throw on missing accessors', () => { + const extras = trueForgeExtras.provide({ + pendingApprovals: [], + pendingToolResponses: [], + pendingMcpAuth: null, + resumeUnavailable: false, + sandboxId: undefined, + respondToToolApproval: vi.fn(), + respondToToolResponse: vi.fn(), + resumeMcpAuth: vi.fn(), + downloadSandboxFile: vi.fn(), + cancel: vi.fn(), + resetFromTurn: vi.fn(), + reload: vi.fn(), + hasOlderHistory: false, + isLoadingOlderHistory: false, + loadOlderHistory: vi.fn(), + draft: null, + } satisfies TrueForgeRuntimeExtras); + + // Mirrors @assistant-ui/store createRootAssistantClient: empty proxy that + // throws on any get (e.g. "subscribe" / "thread"). + const rootProto = new Proxy( + {}, + { + get(_, prop) { + throw new Error(`The current scope does not have a "${String(prop)}" property.`); + }, + }, + ); + const root = Object.assign(Object.create(rootProto), { + subscribe: (cb: () => void) => { + cb(); + return () => {}; + }, + thread: () => ({ + getState: () => ({ extras }), + }), + }) as AssistantClient; + + expect(tryGetTrueForgeExtras(root)).toBe(extras); + + // useTrueForgeRuntimeExtras walks .subscribe up the same chain. + const nested = Object.assign(Object.create(root), { + thread: () => ({ + getState: () => ({ extras: undefined }), + }), + }) as AssistantClient; + expect(tryGetTrueForgeExtras(nested)).toBe(extras); + expect(() => { + let current: object | null = nested; + const seen = new Set(); + while (current != null && !seen.has(current)) { + seen.add(current); + try { + const subscribe = (current as { subscribe?: (cb: () => void) => () => void }).subscribe; + if (typeof subscribe === 'function') { + subscribe(() => {}); + } + } catch { + break; + } + current = Object.getPrototypeOf(current); + } + }).not.toThrow(); + }); +}); diff --git a/packages/assistant-ui-runtime/test/trueforgeOwnedSessionsThreadListAdapter.test.ts b/packages/assistant-ui-runtime/test/trueforgeOwnedSessionsThreadListAdapter.test.ts new file mode 100644 index 000000000..e2d3c02d2 --- /dev/null +++ b/packages/assistant-ui-runtime/test/trueforgeOwnedSessionsThreadListAdapter.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentChatServer, Session } from '../src/server/index.js'; + +import { createTrueForgeOwnedSessionsThreadListAdapter } from '../src/trueforgeOwnedSessionsThreadListAdapter.js'; + +function mockNamedSession(id: string, title: string, updatedAt: string): Session { + return { + id, + agentName: 'my-agent', + title, + createdAt: updatedAt, + updatedAt, + isMutable: false, + }; +} + +function mockDraftSession(id: string, title: string | undefined, updatedAt: string): Session { + return { + id, + agentSpec: { model: { name: 'anthropic/claude-sonnet-4-6' } }, + ...(title === undefined ? {} : { title }), + createdAt: updatedAt, + updatedAt, + isMutable: true, + }; +} + +function mockServer(partial: Partial): AgentChatServer { + return partial as AgentChatServer; +} + +describe('createTrueForgeOwnedSessionsThreadListAdapter', () => { + it('lists owned sessions (named + draft) with pagination cursor', async () => { + const listSessions = vi.fn().mockResolvedValue({ + data: [ + mockNamedSession('s1', 'Named chat', '2026-06-30T12:00:00.000Z'), + mockDraftSession('d1', 'Draft chat', '2026-06-30T11:00:00.000Z'), + ], + nextPageToken: 'page-2', + }); + const server = mockServer({ listSessions, getSession: vi.fn() }); + + const adapter = createTrueForgeOwnedSessionsThreadListAdapter({ server }); + const result = await adapter.list(); + + expect(listSessions).toHaveBeenCalledWith( + expect.objectContaining({ + createdByMe: false, + limit: 20, + startTimestamp: expect.any(String), + }), + ); + expect(result.threads).toEqual([ + { + status: 'regular', + remoteId: 's1', + title: 'Named chat', + lastMessageAt: new Date('2026-06-30T12:00:00.000Z'), + custom: { isMutable: false, agentName: 'my-agent' }, + }, + { + status: 'regular', + remoteId: 'd1', + title: 'Draft chat', + lastMessageAt: new Date('2026-06-30T11:00:00.000Z'), + custom: { isMutable: true }, + }, + ]); + expect(result.nextCursor).toBe('page-2'); + }); + + it('forwards listSessionsAgentId as agentId', async () => { + const listSessions = vi.fn().mockResolvedValue({ data: [] }); + const server = mockServer({ listSessions, getSession: vi.fn() }); + + const adapter = createTrueForgeOwnedSessionsThreadListAdapter({ + server, + listSessionsAgentId: 'agent-x', + listSessionsCreatedByMe: true, + }); + await adapter.list(); + + expect(listSessions).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'agent-x', createdByMe: true })); + }); + + it('falls back to model name for untitled drafts', async () => { + const listSessions = vi.fn().mockResolvedValue({ + data: [mockDraftSession('d1', undefined, '2026-06-30T11:00:00.000Z')], + }); + const server = mockServer({ listSessions, getSession: vi.fn() }); + + const adapter = createTrueForgeOwnedSessionsThreadListAdapter({ server }); + const result = await adapter.list(); + + expect(result.threads[0]?.title).toBe('anthropic/claude-sonnet-4-6'); + }); + + it('rename persists title when renameSession is present', async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const adapter = createTrueForgeOwnedSessionsThreadListAdapter({ + server: mockServer({ + renameSession, + listSessions: vi.fn(), + getSession: vi.fn(), + }), + }); + + await adapter.rename('s1', 'Customer A'); + + expect(renameSession).toHaveBeenCalledWith({ + sessionId: 's1', + title: 'Customer A', + }); + }); + + it('rename is a no-op when renameSession is omitted', async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const adapter = createTrueForgeOwnedSessionsThreadListAdapter({ + server: mockServer({ + listSessions: vi.fn(), + getSession: vi.fn(), + }), + }); + + await expect(adapter.rename('s1', 'Customer A')).resolves.toBeUndefined(); + expect(renameSession).not.toHaveBeenCalled(); + }); + + it('throws on initialize because the adapter is read-only', async () => { + const server = mockServer({ + listSessions: vi.fn(), + getSession: vi.fn(), + }); + + const adapter = createTrueForgeOwnedSessionsThreadListAdapter({ server }); + + await expect(adapter.initialize('local')).rejects.toThrow(/read-only/); + }); +}); diff --git a/packages/assistant-ui-runtime/test/trueforgeThreadListAdapter.test.ts b/packages/assistant-ui-runtime/test/trueforgeThreadListAdapter.test.ts new file mode 100644 index 000000000..b3c96d2e6 --- /dev/null +++ b/packages/assistant-ui-runtime/test/trueforgeThreadListAdapter.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentChatServer, Session } from '../src/server/index.js'; + +import { createTrueForgeThreadListAdapter } from '../src/trueforgeThreadListAdapter.js'; + +function mockSession(id: string, title: string, updatedAt: string, agentName?: string): Session { + return { + id, + title, + updatedAt, + createdAt: updatedAt, + isMutable: false, + ...(agentName != null ? { agentName } : {}), + }; +} + +function mockListSessionsPage(sessions: Session[], nextPageToken?: string) { + return { + data: sessions, + ...(nextPageToken != null ? { nextPageToken } : {}), + }; +} + +function mockServer(partial: Partial): AgentChatServer { + return partial as AgentChatServer; +} + +describe('createTrueForgeThreadListAdapter', () => { + it('lists the first page without agentId when filter is omitted', async () => { + const listSessions = vi + .fn() + .mockResolvedValue( + mockListSessionsPage([mockSession('s1', 'First', '2026-06-30T10:00:00.000Z', 'my-agent')], 'page-2'), + ); + const server = mockServer({ listSessions }); + const adapter = createTrueForgeThreadListAdapter({ + server, + agentName: 'my-agent', + }); + + const result = await adapter.list(); + + expect(listSessions).toHaveBeenCalledWith({ + createdByMe: false, + limit: 20, + startTimestamp: expect.any(String), + }); + expect(result.threads).toEqual([ + { + status: 'regular', + remoteId: 's1', + title: 'First', + lastMessageAt: new Date('2026-06-30T10:00:00.000Z'), + custom: { isMutable: false, agentName: 'my-agent' }, + }, + ]); + expect(result.nextCursor).toBe('page-2'); + }); + + it('forwards listSessionsAgentId as agentId', async () => { + const listSessions = vi + .fn() + .mockResolvedValue(mockListSessionsPage([mockSession('s2', 'Second', '2026-06-29T10:00:00.000Z')])); + const server = mockServer({ listSessions }); + const adapter = createTrueForgeThreadListAdapter({ + server, + agentName: 'my-agent', + listSessionsAgentId: 'filter-agent', + listSessionsCreatedByMe: true, + }); + + const result = await adapter.list({ after: 'page-2' }); + + expect(listSessions).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'filter-agent', + createdByMe: true, + limit: 20, + pageToken: 'page-2', + }), + ); + expect(result.nextCursor).toBeUndefined(); + }); + + it('omits nextCursor when the backend returns no next page token', async () => { + const listSessions = vi + .fn() + .mockResolvedValue(mockListSessionsPage([mockSession('s1', 'Only', '2026-06-30T10:00:00.000Z')])); + const server = mockServer({ listSessions }); + const adapter = createTrueForgeThreadListAdapter({ + server, + agentName: 'my-agent', + }); + + const result = await adapter.list(); + + expect(result.nextCursor).toBeUndefined(); + }); + + it('delete calls server.deleteSession when implemented', async () => { + const deleteSession = vi.fn().mockResolvedValue(undefined); + const server = mockServer({ deleteSession }); + const adapter = createTrueForgeThreadListAdapter({ + server, + agentName: 'my-agent', + }); + + await adapter.delete('s1'); + + expect(deleteSession).toHaveBeenCalledWith({ sessionId: 's1' }); + }); + + it('rename persists title when renameSession is present', async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const server = mockServer({ + renameSession, + }); + const adapter = createTrueForgeThreadListAdapter({ + server, + agentName: 'my-agent', + }); + + await adapter.rename('s1', 'Acme onboarding'); + + expect(renameSession).toHaveBeenCalledWith({ + sessionId: 's1', + title: 'Acme onboarding', + }); + }); + + it('rename is a no-op when renameSession is omitted', async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const adapter = createTrueForgeThreadListAdapter({ + server: mockServer({}), + agentName: 'my-agent', + }); + + await expect(adapter.rename('s1', 'Acme onboarding')).resolves.toBeUndefined(); + expect(renameSession).not.toHaveBeenCalled(); + }); + + it('delete is a no-op when server.deleteSession is missing', async () => { + const adapter = createTrueForgeThreadListAdapter({ + server: mockServer({}), + agentName: 'my-agent', + }); + + await expect(adapter.delete('s1')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/assistant-ui-runtime/test/tsconfig.json b/packages/assistant-ui-runtime/test/tsconfig.json new file mode 100644 index 000000000..76a173588 --- /dev/null +++ b/packages/assistant-ui-runtime/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": ["../src", "."] +} diff --git a/packages/assistant-ui-runtime/test/useTrueForgeAgentMessages.test.tsx b/packages/assistant-ui-runtime/test/useTrueForgeAgentMessages.test.tsx new file mode 100644 index 000000000..0822a7d4b --- /dev/null +++ b/packages/assistant-ui-runtime/test/useTrueForgeAgentMessages.test.tsx @@ -0,0 +1,1504 @@ +// @vitest-environment jsdom +import type { ThreadMessage } from '@assistant-ui/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentChatServer, Turn } from '../src/server/index.js'; + +import { collectPendingToolResponses } from '../src/collectPending.js'; +import { ROOT_THREAD_ID } from '../src/constants.js'; +import { prependOlderSessionHistory } from '../src/convertTurnMessages.js'; +import { buildRootAssistantContent, ingestTurnEvent, PeerThreadFoldState } from '../src/foldPeerThreads.js'; +import { loadSessionSnapshot } from '../src/loadSessionSnapshot.js'; +import { createEmptySessionSnapshot, replaceSessionSnapshot, type SessionSnapshot } from '../src/sessionSnapshot.js'; +import { resumeTurnStream, streamTurnContent } from '../src/streamTurn.js'; +import { messageHasPendingApprovals, TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY } from '../src/toolApproval.js'; +import { + messageHasPendingResponses, + TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY, + toolResponseMessageCustom, + toolResponseStatus, +} from '../src/toolResponse.js'; +import { useTrueForgeAgentMessages } from '../src/useTrueForgeAgentMessages.js'; + +vi.mock('../src/loadSessionSnapshot.js', () => ({ + loadSessionSnapshot: vi.fn(), +})); + +vi.mock('../src/streamTurn.js', () => ({ + streamTurnContent: vi.fn(), + resumeTurnStream: vi.fn(), +})); + +vi.mock('../src/convertTurnMessages.js', async importOriginal => { + const actual = await importOriginal(); + // Wrapped so tests can stub older-history paging; defaults to the real one. + return { + ...actual, + prependOlderSessionHistory: vi.fn(actual.prependOlderSessionHistory), + }; +}); + +const mockServer = { + cancelSession: vi.fn().mockResolvedValue(undefined), + listTurns: vi.fn(), + getTurn: vi.fn(), + // Present so resume-capable paths are exercised; resumeTurnStream is mocked. + subscribeToTurn: vi.fn(), +} as unknown as AgentChatServer; + +function snapshotWithAssistantMessage( + message: Extract, + extra?: Partial, +): SessionSnapshot { + const turnId = message.id.replace(/-assistant$/, ''); + return replaceSessionSnapshot(createEmptySessionSnapshot(), { + activeStream: { + turnId, + isContinuation: false, + update: { + content: [...message.content], + status: message.status, + metadata: { custom: message.metadata.custom }, + }, + }, + ...extra, + }); +} + +function snapshotWithUserTurn(userText: string): SessionSnapshot { + const createdAt = new Date().toISOString(); + return replaceSessionSnapshot(createEmptySessionSnapshot(), { + turns: [ + { + id: 'turn-1', + userText, + createdAt, + state: { + status: 'done', + requiredActions: [], + completedAt: createdAt, + }, + input: [{ type: 'user.message', content: userText }], + }, + ], + }); +} + +function snapshotWithAskUserPendingInFold(): SessionSnapshot { + const fold = new PeerThreadFoldState(); + const turnId = 'turn-ask'; + + ingestTurnEvent(fold, { + type: 'model.message', + id: 'model-1', + createdAt: new Date().toISOString(), + threadId: ROOT_THREAD_ID, + toolCalls: [ + { + id: 'question-1', + type: 'function', + function: { + name: 'ask_user_question', + arguments: JSON.stringify({ + question: 'Pick one', + options: ['A', 'B'], + }), + }, + toolInfo: { type: 'trueforge-system', name: 'ask_user_question' }, + }, + ], + }); + + ingestTurnEvent(fold, { + type: 'tool.response_required', + id: 'resp-req-1', + createdAt: new Date().toISOString(), + threadId: ROOT_THREAD_ID, + toolCalls: [{ id: 'question-1', sourceEventId: 'model-1' }], + }); + + const content = buildRootAssistantContent(fold); + + return replaceSessionSnapshot(createEmptySessionSnapshot(), { + fold, + pendingUser: { + turnId, + content: 'ask me a question', + createdAt: new Date(), + }, + activeStream: { + turnId, + isContinuation: false, + streamComplete: true, + update: { + content, + status: toolResponseStatus(), + metadata: { custom: toolResponseMessageCustom(ROOT_THREAD_ID) }, + }, + }, + }); +} + +function assistantMessageWithPendingApproval() { + return { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1' }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID }, + }, + }; +} + +function assistantMessageWithPendingApprovalAndResponse() { + return { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'approval-1', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-1' }, + }, + { + type: 'tool-call' as const, + toolCallId: 'question-1', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { + type: 'human' as const, + payload: { question: 'Pick one', options: ['A', 'B'] }, + }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { + [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID, + [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID, + }, + }, + }; +} + +function assistantMessageWithMultiThreadPendingActions() { + return { + id: 'turn-1-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'spawn-1', + toolName: 'create_sub_agent', + args: {}, + argsText: '{}', + messages: [ + { + id: 'child-assistant', + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'question-sub', + toolName: 'ask_user_question', + args: {}, + argsText: '{}', + interrupt: { + type: 'human' as const, + payload: { question: 'Sub?' }, + }, + }, + ], + status: { + type: 'requires-action' as const, + reason: 'tool-calls' as const, + }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: 'child-1' }, + }, + }, + ], + }, + { + type: 'tool-call' as const, + toolCallId: 'approval-root', + toolName: 'bash', + args: {}, + argsText: '{}', + approval: { id: 'approval-root' }, + }, + ], + status: { type: 'requires-action' as const, reason: 'tool-calls' as const }, + createdAt: new Date(), + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID }, + }, + }; +} + +async function* singleUpdateStream() { + yield { content: [{ type: 'text' as const, text: 'streamed reply' }] }; +} + +describe('useTrueForgeAgentMessages', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockServer.cancelSession).mockResolvedValue(undefined); + vi.mocked(mockServer.listTurns).mockResolvedValue({ data: [] }); + vi.mocked(loadSessionSnapshot).mockResolvedValue(createEmptySessionSnapshot()); + vi.mocked(streamTurnContent).mockReturnValue(singleUpdateStream()); + vi.mocked(resumeTurnStream).mockReturnValue(singleUpdateStream()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('clears messages when sessionId is undefined', async () => { + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: undefined })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.messages).toEqual([]); + expect(loadSessionSnapshot).not.toHaveBeenCalled(); + }); + + it('loads the initial URL session before it is marked as the main thread', async () => { + const { rerender } = renderHook( + ({ isMain }: { isMain: boolean }) => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: 'session-from-url', + isMain, + isInitialSession: true, + }), + { initialProps: { isMain: false } }, + ); + + await waitFor(() => + expect(loadSessionSnapshot).toHaveBeenCalledWith(mockServer, 'session-from-url', expect.any(Function)), + ); + + rerender({ isMain: true }); + await act(async () => Promise.resolve()); + expect(loadSessionSnapshot).toHaveBeenCalledTimes(1); + + rerender({ isMain: false }); + rerender({ isMain: true }); + await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(2)); + }); + + it('does not load an inactive background thread', async () => { + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: 'background-session', + isMain: false, + isInitialSession: false, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(loadSessionSnapshot).not.toHaveBeenCalled(); + }); + + it('retries a failed URL early load before the thread is promoted to main', async () => { + vi.mocked(loadSessionSnapshot) + .mockRejectedValueOnce(new Error('load failed')) + .mockResolvedValueOnce(snapshotWithUserTurn('Hello')); + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: 'session-from-url', + isMain: false, + isInitialSession: true, + }), + ); + + await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + result.current.retryLoad(); + }); + + await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(result.current.messages[0]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }), + ); + }); + + it('sendTurn lazily initializes a session when sessionId is undefined', async () => { + const initializeSession = vi.fn().mockResolvedValue({ + remoteId: 'session-new', + externalId: undefined, + }); + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: undefined, + initializeSession, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.sendTurn({ userMessage: 'hello there' }); + }); + + expect(initializeSession).toHaveBeenCalledOnce(); + expect(streamTurnContent).toHaveBeenCalled(); + expect(loadSessionSnapshot).not.toHaveBeenCalled(); + }); + + it('sendTurn forwards getTurnHeaders only when they resolve to a value', async () => { + const getTurnHeaders = vi + .fn() + .mockResolvedValueOnce({ + 'x-tfy-session-last-updated-at': '2026-06-30T12:00:00.000Z', + }) + .mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: 'session-1', + getTurnHeaders, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.sendTurn({ userMessage: 'first' }); + }); + + expect(getTurnHeaders).toHaveBeenCalledTimes(1); + expect(streamTurnContent).toHaveBeenCalledWith( + mockServer, + 'session-1', + expect.any(PeerThreadFoldState), + { + userMessage: 'first', + previousTurnId: 'none', + headers: { + 'x-tfy-session-last-updated-at': '2026-06-30T12:00:00.000Z', + }, + }, + expect.any(AbortSignal), + expect.any(Array), + expect.any(Function), + ); + + await act(async () => { + await result.current.sendTurn({ userMessage: 'second' }); + }); + + expect(getTurnHeaders).toHaveBeenCalledTimes(2); + expect(streamTurnContent).toHaveBeenLastCalledWith( + mockServer, + 'session-1', + expect.any(PeerThreadFoldState), + { userMessage: 'second' }, + expect.any(AbortSignal), + expect.any(Array), + expect.any(Function), + ); + }); + + it('loads converted session history on mount', async () => { + vi.mocked(loadSessionSnapshot).mockResolvedValue(snapshotWithUserTurn('hello')); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(loadSessionSnapshot).toHaveBeenCalledWith(mockServer, 'session-1', expect.any(Function)); + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]?.role).toBe('user'); + }); + + it('resumes a running turn after load', async () => { + const fold = new PeerThreadFoldState(); + ingestTurnEvent(fold, { + type: 'model.message', + id: 'model-resumed', + createdAt: new Date().toISOString(), + threadId: ROOT_THREAD_ID, + content: 'streamed reply', + }); + const runningTurn: Turn = { + id: 'turn-running', + sessionId: 'session-1', + input: [{ type: 'user.message', content: 'continue' }], + state: { status: 'running' }, + createdAt: new Date().toISOString(), + }; + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + fold, + turns: [ + { + id: runningTurn.id, + userText: 'continue', + createdAt: new Date().toISOString(), + state: { status: 'running' }, + input: [{ type: 'user.message', content: 'continue' }], + }, + ], + runningTurn, + groupRootBaseline: [], + unstable_resume: true, + }), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + + await waitFor(() => expect(result.current.isRunning).toBe(false)); + expect(resumeTurnStream).toHaveBeenCalled(); + await waitFor(() => + expect(result.current.messages.at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'streamed reply' }], + status: { type: 'complete', reason: 'stop' }, + }), + ); + }); + + it('shows loaded history as running when the server cannot resume the turn', async () => { + const onError = vi.fn(); + const runningTurn = { + id: 'turn-running', + input: [{ type: 'user.message', content: 'keep going' }], + createdAt: new Date().toISOString(), + } as Turn; + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + runningTurn, + unstable_resume: true, + pendingUser: { + turnId: runningTurn.id, + content: 'keep going', + createdAt: new Date(runningTurn.createdAt), + }, + }), + ); + const serverWithoutSubscribe = { + cancelSession: vi.fn().mockResolvedValue(undefined), + listTurns: vi.fn().mockResolvedValue({ data: [] }), + } as unknown as AgentChatServer; + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: serverWithoutSubscribe, + sessionId: 'session-1', + onError, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(resumeTurnStream).not.toHaveBeenCalled(); + // The turn keeps running server-side, so history renders with a + // pending indicator rather than an endless skeleton. + await waitFor(() => expect(result.current.isRunning).toBe(true)); + expect(result.current.messages[0]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'keep going' }], + }); + // Waiting, not failing: hosts render this as state, not an error. + expect(result.current.resumeUnavailable).toBe(true); + expect(onError).not.toHaveBeenCalled(); + }); + + it('clears the running state when cancelling a turn it could not resume', async () => { + const onError = vi.fn(); + const runningTurn: Turn = { + id: 'turn-running', + sessionId: 'session-1', + input: [{ type: 'user.message', content: 'keep going' }], + state: { status: 'running' }, + createdAt: new Date().toISOString(), + }; + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + runningTurn, + unstable_resume: true, + }), + ); + const serverWithoutSubscribe = { + cancelSession: vi.fn().mockResolvedValue(undefined), + listTurns: vi.fn().mockResolvedValue({ data: [] }), + } as unknown as AgentChatServer; + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: serverWithoutSubscribe, + sessionId: 'session-1', + onError, + }), + ); + + await waitFor(() => expect(result.current.isRunning).toBe(true)); + + await act(async () => { + await result.current.cancel(); + }); + + expect(serverWithoutSubscribe.cancelSession).toHaveBeenCalledWith({ + sessionId: 'session-1', + }); + // No stream was attached, so nothing else would release the composer. + expect(result.current.isRunning).toBe(false); + expect(result.current.resumeUnavailable).toBe(false); + }); + + it('stays in the waiting state instead of resuming when resumeRun has no subscribeToTurn', async () => { + const onError = vi.fn(); + const runningTurn = { id: 'turn-running' } as Turn; + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + runningTurn, + unstable_resume: true, + }), + ); + const serverWithoutSubscribe = { + cancelSession: vi.fn().mockResolvedValue(undefined), + listTurns: vi.fn().mockResolvedValue({ data: [] }), + } as unknown as AgentChatServer; + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: serverWithoutSubscribe, + sessionId: 'session-1', + onError, + }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + onError.mockClear(); + + await act(async () => { + await result.current.resumeRun(); + }); + + expect(resumeTurnStream).not.toHaveBeenCalled(); + expect(result.current.resumeUnavailable).toBe(true); + expect(onError).not.toHaveBeenCalled(); + }); + + it('clears isLoading while a resumed turn is still streaming', async () => { + let releaseStream: (() => void) | undefined; + vi.mocked(resumeTurnStream).mockReturnValue( + (async function* () { + await new Promise(resolve => { + releaseStream = resolve; + }); + yield { content: [{ type: 'text' as const, text: 'streamed reply' }] }; + })(), + ); + + const runningTurn = { + id: 'turn-running', + input: [{ type: 'user.message', content: 'keep going' }], + createdAt: new Date().toISOString(), + } as Turn; + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + runningTurn, + unstable_resume: true, + pendingUser: { + turnId: runningTurn.id, + content: 'keep going', + createdAt: new Date(runningTurn.createdAt), + }, + }), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(resumeTurnStream).toHaveBeenCalled(); + await waitFor(() => expect(result.current.isRunning).toBe(true)); + expect(result.current.messages[0]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'keep going' }], + }); + + await act(async () => { + releaseStream?.(); + }); + await waitFor(() => expect(result.current.isRunning).toBe(false)); + }); + + it('sendTurn appends a user message and streams the assistant reply', async () => { + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.sendTurn({ userMessage: 'hello there' }); + }); + + expect(streamTurnContent).toHaveBeenCalled(); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'hello there' }], + }); + expect(result.current.messages[1]).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'streamed reply' }], + status: { type: 'complete', reason: 'stop' }, + }); + }); + + it('editFromTurn drops prior turns before showing the edited user message', async () => { + const createdAt = new Date().toISOString(); + const rootTurn = { + id: 'turn-1', + sessionId: 'session-1', + createdAt, + previousTurnId: null, + state: { + status: 'done' as const, + requiredActions: [], + completedAt: createdAt, + }, + input: [{ type: 'user.message' as const, content: 'Hello' }], + } as Turn; + vi.mocked(mockServer.listTurns).mockResolvedValue({ + data: [rootTurn], + }); + vi.mocked(mockServer.getTurn).mockResolvedValue(rootTurn); + const fold = new PeerThreadFoldState(); + ingestTurnEvent(fold, { + type: 'model.message', + id: 'model-1', + createdAt, + threadId: ROOT_THREAD_ID, + role: 'assistant', + content: 'How are you', + } as never); + + vi.mocked(loadSessionSnapshot).mockResolvedValue({ + ...replaceSessionSnapshot(createEmptySessionSnapshot(), { + turns: [ + { + id: 'turn-1', + userText: 'Hello', + createdAt, + state: { + status: 'done', + requiredActions: [], + completedAt: createdAt, + }, + input: [{ type: 'user.message', content: 'Hello' }], + rootModelMessageIds: ['model-1'], + }, + ], + }), + fold, + }); + let releaseStream: (() => void) | undefined; + vi.mocked(streamTurnContent).mockReturnValue( + (async function* () { + yield { + content: [{ type: 'text' as const, text: 'sunny' }], + }; + await new Promise(resolve => { + releaseStream = resolve; + }); + })(), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.messages.map(m => m.role)).toEqual(['user', 'assistant']); + expect(result.current.messages[0]).toMatchObject({ + content: [{ type: 'text', text: 'Hello' }], + }); + + let editPromise: Promise; + await act(async () => { + editPromise = result.current.editFromTurn('turn-1', 'what is the weather like?'); + await Promise.resolve(); + }); + + await waitFor(() => { + const texts = result.current.messages + .filter(m => m.role === 'user') + .map(m => + m.content + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map(p => p.text) + .join(''), + ); + expect(texts).toEqual(['what is the weather like?']); + }); + expect( + result.current.messages.some( + m => m.role === 'user' && m.content.some(p => p.type === 'text' && p.text === 'Hello'), + ), + ).toBe(false); + expect( + result.current.messages.some( + m => m.role === 'assistant' && m.content.some(p => p.type === 'text' && p.text.includes('How are you')), + ), + ).toBe(false); + + await act(async () => { + releaseStream?.(); + await editPromise!; + }); + }); + + it('restores history when edit fails before turn.created', async () => { + const createdAt = new Date().toISOString(); + const onError = vi.fn(); + const original = snapshotWithUserTurn('Hello'); + vi.mocked(loadSessionSnapshot).mockResolvedValue(original); + const rootTurn = { + id: 'turn-1', + sessionId: 'session-1', + createdAt, + previousTurnId: null, + state: { + status: 'done' as const, + requiredActions: [], + completedAt: createdAt, + }, + input: [{ type: 'user.message' as const, content: 'Hello' }], + } as Turn; + vi.mocked(mockServer.listTurns).mockResolvedValue({ + data: [rootTurn], + }); + vi.mocked(mockServer.getTurn).mockResolvedValue(rootTurn); + vi.mocked(streamTurnContent).mockImplementation(async function* () { + throw new Error('Turn preparation failed'); + }); + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: 'session-1', + onError, + }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await expect(result.current.editFromTurn('turn-1', 'Edited')).rejects.toThrow('Turn preparation failed'); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }); + // runStream reports once; callers must not double-toast. + expect(onError).toHaveBeenCalledOnce(); + }); + + it('does not let a superseded stream complete the current stream', async () => { + let releaseFirstStream: (() => void) | undefined; + let releaseSecondStream: (() => void) | undefined; + let nextAnimationFrame = 1; + const animationFrames = new Map(); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const frame = nextAnimationFrame++; + animationFrames.set(frame, callback); + return frame; + }), + ); + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frame: number) => animationFrames.delete(frame)), + ); + vi.mocked(streamTurnContent) + .mockReturnValueOnce( + (async function* () { + yield { content: [{ type: 'text' as const, text: 'first reply' }] }; + await new Promise(resolve => { + releaseFirstStream = resolve; + }); + })(), + ) + .mockReturnValueOnce( + (async function* () { + yield { content: [{ type: 'text' as const, text: 'second reply' }] }; + await new Promise(resolve => { + releaseSecondStream = resolve; + }); + })(), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const firstSend = result.current.sendTurn({ userMessage: 'first' }); + await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(1)); + + const secondSend = result.current.sendTurn({ userMessage: 'second' }); + await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(2)); + + await act(async () => { + releaseFirstStream?.(); + await firstSend; + }); + + expect(result.current.isRunning).toBe(true); + + await act(async () => { + animationFrames.get(2)?.(performance.now()); + }); + expect(result.current.messages.at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'second reply' }], + }); + expect(result.current.messages.at(-1)?.status).not.toMatchObject({ + type: 'complete', + }); + + await act(async () => { + releaseSecondStream?.(); + await secondSend; + }); + }); + + it('carries a streamed sandboxId through commit so it survives after the stream completes', async () => { + vi.mocked(streamTurnContent).mockReturnValue( + (async function* () { + yield { + content: [{ type: 'text' as const, text: 'streamed reply' }], + metadata: { custom: { sandboxId: 'sbx-123' } }, + }; + })(), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.sendTurn({ userMessage: 'hello there' }); + }); + + expect(result.current.messages[1]).toMatchObject({ + role: 'assistant', + metadata: { custom: { sandboxId: 'sbx-123' } }, + }); + }); + + it('sendTurn with approvals streams a continuation without adding a user message', async () => { + vi.mocked(loadSessionSnapshot).mockResolvedValue( + snapshotWithAssistantMessage(assistantMessageWithPendingApproval()), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + await act(async () => { + await result.current.sendTurn({ + inputs: [ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + ], + }); + }); + + expect(streamTurnContent).toHaveBeenCalledWith( + mockServer, + 'session-1', + expect.any(PeerThreadFoldState), + { + inputs: [ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + ], + }, + expect.any(AbortSignal), + expect.any(Array), + expect.any(Function), + ); + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]?.role).toBe('assistant'); + }); + + it('commits a continuation under the gateway turn id when no active stream exists', async () => { + // Reproduces the "Turn not found: <7-char id>" failure: the paused + // stream was already committed (activeStream cleared), so the resume + // turn used to run under a local generateId() that leaked into + // custom.turnId and the committed record. + const fold = new PeerThreadFoldState(); + ingestTurnEvent(fold, { + type: 'model.message', + id: 'model-1', + createdAt: new Date().toISOString(), + threadId: ROOT_THREAD_ID, + content: 'committed reply', + }); + const createdAt = new Date().toISOString(); + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + fold, + turns: [ + { + id: 'turn-1', + userText: 'hi', + createdAt, + state: { + status: 'done', + requiredActions: [], + completedAt: createdAt, + }, + input: [{ type: 'user.message', content: 'hi' }], + rootModelMessageIds: ['model-1'], + }, + ], + }), + ); + vi.mocked(streamTurnContent).mockImplementation( + (_server, _sessionId, _fold, _options, _signal, _baseline, onTurnIdAvailable) => + (async function* () { + onTurnIdAvailable?.('gw-turn-2'); + yield { + content: [{ type: 'text' as const, text: 'resumed' }], + }; + })(), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + + await act(async () => { + await result.current.sendTurn({ + inputs: [ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + ], + }); + }); + + const assistant = result.current.messages[1]; + expect(assistant?.role).toBe('assistant'); + expect(assistant?.metadata.custom).toMatchObject({ turnId: 'gw-turn-2' }); + }); + + it('resolveSandboxIdForTurn returns the sandbox current as of that turn', async () => { + const createdAt = new Date().toISOString(); + const doneState = { + status: 'done' as const, + requiredActions: [], + completedAt: createdAt, + }; + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + turns: [ + { id: 'turn-1', createdAt, state: doneState, sandboxId: 'sbx-1' }, + { id: 'turn-2', createdAt, state: doneState }, + { id: 'turn-3', createdAt, state: doneState, sandboxId: 'sbx-2' }, + ], + }), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // turn-2's artifacts came from the sandbox created at turn-1, not the + // session-wide latest (turn-3's). + await expect(result.current.resolveSandboxIdForTurn('turn-2')).resolves.toBe('sbx-1'); + await expect(result.current.resolveSandboxIdForTurn('turn-3')).resolves.toBe('sbx-2'); + // Unknown turn falls back to the latest sandbox in the loaded window. + await expect(result.current.resolveSandboxIdForTurn('turn-unknown')).resolves.toBe('sbx-2'); + }); + + it('resolveSandboxIdForTurn pages in older history when the sandbox reference is not loaded', async () => { + const createdAt = new Date().toISOString(); + const doneState = { + status: 'done' as const, + requiredActions: [], + completedAt: createdAt, + }; + vi.mocked(loadSessionSnapshot).mockResolvedValue( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + turns: [{ id: 'turn-9', createdAt, state: doneState }], + historyPagination: { hasOlder: true, olderPageToken: 'tok-1' }, + }), + ); + vi.mocked(prependOlderSessionHistory).mockResolvedValueOnce( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + turns: [ + { id: 'turn-1', createdAt, state: doneState, sandboxId: 'sbx-old' }, + { id: 'turn-9', createdAt, state: doneState }, + ], + historyPagination: { hasOlder: false }, + }), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let resolved: string | undefined; + await act(async () => { + resolved = await result.current.resolveSandboxIdForTurn('turn-9'); + }); + + expect(resolved).toBe('sbx-old'); + expect(prependOlderSessionHistory).toHaveBeenCalledTimes(1); + }); + + it('does not merge session A older history onto session B after a switch', async () => { + // Bugbot: a stale resolveSandboxIdForTurn / loadOlderHistory closed over + // session A can run after switch, capture B's loadGeneration, and commit + // A's pages onto B's snapshot — or return B's sandboxId for A's turn. + const createdAt = new Date().toISOString(); + const doneState = { + status: 'done' as const, + requiredActions: [], + completedAt: createdAt, + }; + + vi.mocked(loadSessionSnapshot) + .mockResolvedValueOnce( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + turns: [{ id: 'turn-a', createdAt, state: doneState }], + historyPagination: { hasOlder: true, olderPageToken: 'tok-a' }, + }), + ) + .mockResolvedValueOnce( + replaceSessionSnapshot(createEmptySessionSnapshot(), { + turns: [ + { + id: 'turn-b', + createdAt, + state: doneState, + sandboxId: 'sbx-b', + }, + ], + historyPagination: { hasOlder: false }, + }), + ); + + vi.mocked(prependOlderSessionHistory).mockImplementation(async (_server, _sessionId, snapshot) => + replaceSessionSnapshot(snapshot, { + turns: [ + { + id: 'turn-old-a', + createdAt, + state: doneState, + sandboxId: 'sbx-a-pollute', + }, + ...snapshot.turns, + ], + historyPagination: { hasOlder: false }, + }), + ); + + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => useTrueForgeAgentMessages({ server: mockServer, sessionId }), + { initialProps: { sessionId: 'session-a' } }, + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // Capture the session-A closures, then switch so later calls are stale. + const resolveFromA = result.current.resolveSandboxIdForTurn; + const loadOlderFromA = result.current.loadOlderHistory; + + rerender({ sessionId: 'session-b' }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.resolveSandboxIdForTurn).not.toBe(resolveFromA)); + + let staleResolved: string | undefined = 'sentinel'; + await act(async () => { + staleResolved = await resolveFromA('turn-a'); + await loadOlderFromA(); + }); + + // Stale resolve must not read B's sandbox; stale load must not commit. + expect(staleResolved).toBeUndefined(); + expect(prependOlderSessionHistory).not.toHaveBeenCalled(); + await expect(result.current.resolveSandboxIdForTurn('turn-b')).resolves.toBe('sbx-b'); + expect( + result.current.messages.some( + message => (message.metadata.custom as { turnId?: string } | undefined)?.turnId === 'turn-old-a', + ), + ).toBe(false); + }); + + it('respondToToolApproval records approval decisions on the pending tool call', async () => { + vi.mocked(loadSessionSnapshot).mockResolvedValue( + snapshotWithAssistantMessage(assistantMessageWithPendingApproval()), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + await act(async () => { + result.current.respondToToolApproval({ + approvalId: 'approval-1', + approved: true, + }); + }); + + await waitFor(() => { + const assistant = result.current.messages[0]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(messageHasPendingApprovals(assistant)).toBe(false); + const toolCall = assistant.content[0]; + if (toolCall?.type !== 'tool-call') { + return; + } + expect(toolCall.approval?.approved).toBe(true); + }); + + expect(streamTurnContent).toHaveBeenCalled(); + }); + + it('respondToToolApproval sends combined inputs only after responses are answered', async () => { + vi.mocked(loadSessionSnapshot).mockResolvedValue( + snapshotWithAssistantMessage(assistantMessageWithPendingApprovalAndResponse()), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + await act(async () => { + result.current.respondToToolApproval({ + approvalId: 'approval-1', + approved: true, + }); + }); + + expect(streamTurnContent).not.toHaveBeenCalled(); + + await act(async () => { + result.current.respondToToolResponse({ + toolCallId: 'question-1', + content: 'A', + }); + }); + + await waitFor(() => expect(streamTurnContent).toHaveBeenCalled()); + expect(streamTurnContent).toHaveBeenCalledWith( + mockServer, + 'session-1', + expect.any(PeerThreadFoldState), + { + inputs: [ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-1', + approval: { status: 'allow' }, + }, + { + type: 'user.tool_response', + threadId: ROOT_THREAD_ID, + toolCallId: 'question-1', + content: 'A', + }, + ], + }, + expect.any(AbortSignal), + expect.any(Array), + expect.any(Function), + ); + + const assistant = result.current.messages[0]; + expect(assistant?.role).toBe('assistant'); + if (assistant?.role !== 'assistant') { + return; + } + expect(messageHasPendingApprovals(assistant)).toBe(false); + expect(messageHasPendingResponses(assistant)).toBe(false); + }); + + it('keeps ask-user resolved after respond and stream completion clears overlay', async () => { + vi.mocked(loadSessionSnapshot).mockResolvedValue(snapshotWithAskUserPendingInFold()); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + expect(collectPendingToolResponses(result.current.messages)).toHaveLength(1); + + await act(async () => { + result.current.respondToToolResponse({ + toolCallId: 'question-1', + content: 'A', + }); + }); + + await waitFor(() => expect(streamTurnContent).toHaveBeenCalled()); + await waitFor(() => expect(result.current.isRunning).toBe(false)); + + expect(collectPendingToolResponses(result.current.messages)).toHaveLength(0); + const assistant = result.current.messages.find(m => m.role === 'assistant'); + expect(assistant).toBeDefined(); + expect(messageHasPendingResponses(assistant)).toBe(false); + }); + + describe('batched resume invariant', () => { + it('issues exactly one createTurn input batch across root and sub-agent threads', async () => { + vi.mocked(loadSessionSnapshot).mockResolvedValue( + snapshotWithAssistantMessage(assistantMessageWithMultiThreadPendingActions()), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + await act(async () => { + result.current.respondToToolResponse({ + toolCallId: 'question-sub', + content: 'sub-answer', + }); + }); + expect(streamTurnContent).not.toHaveBeenCalled(); + + await act(async () => { + result.current.respondToToolApproval({ + approvalId: 'approval-root', + approved: true, + }); + }); + + await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(1)); + expect(streamTurnContent).toHaveBeenCalledWith( + mockServer, + 'session-1', + expect.any(PeerThreadFoldState), + { + inputs: [ + { + type: 'user.tool_approval', + threadId: ROOT_THREAD_ID, + toolCallId: 'approval-root', + approval: { status: 'allow' }, + }, + { + type: 'user.tool_response', + threadId: 'child-1', + toolCallId: 'question-sub', + content: 'sub-answer', + }, + ], + }, + expect.any(AbortSignal), + expect.any(Array), + expect.any(Function), + ); + }); + + it('does not resume after the first resolved action when another is still pending', async () => { + vi.mocked(loadSessionSnapshot).mockResolvedValue( + snapshotWithAssistantMessage(assistantMessageWithPendingApprovalAndResponse()), + ); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + await act(async () => { + result.current.respondToToolResponse({ + toolCallId: 'question-1', + content: 'A', + }); + }); + + expect(streamTurnContent).not.toHaveBeenCalled(); + expect(messageHasPendingApprovals(result.current.messages[0]!)).toBe(true); + expect(messageHasPendingResponses(result.current.messages[0]!)).toBe(false); + }); + }); + + it('cancel drains the stream gracefully and calls cancelSession', async () => { + let resolveStream: (() => void) | undefined; + vi.mocked(streamTurnContent).mockReturnValue( + (async function* () { + yield { content: [{ type: 'text' as const, text: 'partial' }] }; + await new Promise(resolve => { + resolveStream = resolve; + }); + })(), + ); + // cancelSession makes the backend close the SSE stream gracefully, + // which ends the active iterator on its own. + vi.mocked(mockServer.cancelSession).mockImplementation(async () => { + resolveStream?.(); + }); + + const { result } = renderHook(() => useTrueForgeAgentMessages({ server: mockServer, sessionId: 'session-1' })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let sendPromise: Promise | undefined; + await act(async () => { + sendPromise = result.current.sendTurn({ userMessage: 'hello' }); + }); + await waitFor(() => expect(result.current.isRunning).toBe(true)); + + await act(async () => { + await result.current.cancel(); + await sendPromise; + }); + + expect(mockServer.cancelSession).toHaveBeenCalledWith({ sessionId: 'session-1' }); + expect(result.current.isRunning).toBe(false); + // No reconcile is triggered by cancel; the session was only loaded once + // on mount and reconciles against the event log on the next page load. + expect(loadSessionSnapshot).toHaveBeenCalledTimes(1); + }); + + describe('pre-turn failure rollback', () => { + it('reports and restores a user message when initializeSession fails', async () => { + const onError = vi.fn(); + const onPreTurnFailure = vi.fn(); + const initializeSession = vi.fn().mockRejectedValue(new Error('Draft session creation failed')); + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: undefined, + initializeSession, + onError, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.messages).toEqual([]); + + await act(async () => { + await expect( + result.current.sendTurn({ + userMessage: 'test message', + onPreTurnFailure, + }), + ).rejects.toThrow('Draft session creation failed'); + }); + + expect(result.current.messages).toEqual([]); + expect(onPreTurnFailure).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(initializeSession).toHaveBeenCalledOnce(); + expect(streamTurnContent).not.toHaveBeenCalled(); + }); + + it('rolls back when the turns stream fails before turn.created', async () => { + const onError = vi.fn(); + const onPreTurnFailure = vi.fn(); + vi.mocked(streamTurnContent).mockImplementation(async function* () { + throw new Error('Turn preparation failed'); + }); + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: 'session-1', + onError, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await expect( + result.current.sendTurn({ + userMessage: 'test message', + onPreTurnFailure, + }), + ).rejects.toThrow('Turn preparation failed'); + }); + + expect(result.current.messages).toEqual([]); + expect(onPreTurnFailure).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('does not roll back after turn.created registers the user message', async () => { + const onError = vi.fn(); + const onPreTurnFailure = vi.fn(); + vi.mocked(streamTurnContent).mockImplementation( + async function* (_server, _sessionId, _fold, _options, _signal, _baseline, onTurnIdAvailable) { + onTurnIdAvailable?.('gateway-turn-123'); + yield { content: [{ type: 'text' as const, text: 'partial' }] }; + throw new Error('Mid-stream error'); + }, + ); + + const { result } = renderHook(() => + useTrueForgeAgentMessages({ + server: mockServer, + sessionId: 'session-1', + onError, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await expect( + result.current.sendTurn({ + userMessage: 'test message', + onPreTurnFailure, + }), + ).rejects.toThrow('Mid-stream error'); + }); + + const userMessages = result.current.messages.filter(m => m.role === 'user'); + expect(userMessages).toHaveLength(1); + expect(userMessages[0]?.content[0]).toMatchObject({ + type: 'text', + text: 'test message', + }); + expect(onPreTurnFailure).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); +}); diff --git a/packages/assistant-ui-runtime/test/useTrueForgeAgentRuntime.test.tsx b/packages/assistant-ui-runtime/test/useTrueForgeAgentRuntime.test.tsx new file mode 100644 index 000000000..4bf40a256 --- /dev/null +++ b/packages/assistant-ui-runtime/test/useTrueForgeAgentRuntime.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import type { RemoteThreadListAdapter, RemoteThreadListOptions } from '@assistant-ui/core'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AgentChatServer, AgentSpec } from '../src/server/types.js'; +import { useTrueForgeAgentRuntime } from '../src/useTrueForgeAgentRuntime.js'; + +const runtimeMock = vi.hoisted<{ + adapters: RemoteThreadListAdapter[]; +}>(() => ({ adapters: [] })); + +vi.mock('@assistant-ui/core/react', () => ({ + useRemoteThreadListRuntime: (options: RemoteThreadListOptions) => { + runtimeMock.adapters.push(options.adapter); + return {}; + }, +})); + +const draftSpec: AgentSpec = { + model: { name: 'anthropic/claude-sonnet-4-6' }, +}; + +function mockServer(): AgentChatServer { + return { + createSession: vi.fn(async () => ({ + id: 'session', + createdAt: '2026-09-09T00:00:00.000Z', + updatedAt: '2026-09-09T00:00:00.000Z', + isMutable: false, + })), + listSessions: vi.fn(async () => ({ data: [] })), + getSession: vi.fn(), + updateSession: vi.fn(), + createTurn: vi.fn(), + cancelSession: vi.fn(), + listTurns: vi.fn(), + getTurn: vi.fn(), + listEvents: vi.fn(), + }; +} + +describe('useTrueForgeAgentRuntime', () => { + beforeEach(() => { + runtimeMock.adapters.length = 0; + }); + + it('keeps loaded pages while using the latest mode to initialize sessions', async () => { + const server = mockServer(); + const { rerender } = renderHook( + ({ named }: { named: boolean }) => + useTrueForgeAgentRuntime({ + server, + listSessionsCreatedByMe: true, + agent: named ? { mode: 'named', agentName: 'support' } : { mode: 'draft', defaultAgentSpec: draftSpec }, + }), + { initialProps: { named: false } }, + ); + const draftAdapter = runtimeMock.adapters.at(-1); + if (draftAdapter === undefined) { + throw new Error('Expected the runtime to receive a thread-list adapter.'); + } + + await draftAdapter.list(); + await draftAdapter.list({ after: 'page-2' }); + expect(server.listSessions).toHaveBeenCalledWith(expect.objectContaining({ createdByMe: true })); + + rerender({ named: true }); + + expect(runtimeMock.adapters.at(-1)).toBe(draftAdapter); + await draftAdapter.initialize('local-named'); + expect(server.createSession).toHaveBeenLastCalledWith({ + agentName: 'support', + }); + + rerender({ named: false }); + expect(runtimeMock.adapters.at(-1)).toBe(draftAdapter); + await draftAdapter.initialize('local-draft'); + expect(server.createSession).toHaveBeenLastCalledWith({ + agentSpec: draftSpec, + }); + }); +}); diff --git a/packages/assistant-ui-runtime/tsconfig.json b/packages/assistant-ui-runtime/tsconfig.json new file mode 100644 index 000000000..e4b91c5b7 --- /dev/null +++ b/packages/assistant-ui-runtime/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "noEmit": true, + "types": ["node"] + }, + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/assistant-ui-runtime/tsup.config.ts b/packages/assistant-ui-runtime/tsup.config.ts new file mode 100644 index 000000000..cb422f6c8 --- /dev/null +++ b/packages/assistant-ui-runtime/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts', 'src/server/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'es2022', + external: ['react', '@assistant-ui/core'], +}); diff --git a/packages/assistant-ui-runtime/vitest.config.ts b/packages/assistant-ui-runtime/vitest.config.ts new file mode 100644 index 000000000..c8b00c4a1 --- /dev/null +++ b/packages/assistant-ui-runtime/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.{ts,tsx}'], + }, +}); diff --git a/packages/frontend/README.md b/packages/frontend/README.md index b37912669..868b3ae4a 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -20,7 +20,7 @@ Built on: - `` Chat, agent library, and settings catalogs are composed inside the UI SDK plugin — -the host does **not** call `createTrueFoundryServer` or maintain harness adapters. +the host does **not** call `createTrueForgeServer` or maintain harness adapters. ## Local development diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e62272db1..4d854de3d 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -26,7 +26,7 @@ "@assistant-ui/core": "0.2.22", "@assistant-ui/react": "0.14.27", "@assistant-ui/store": "0.2.21", - "@truefoundry/assistant-ui-runtime": "0.1.43", + "@truefoundry/assistant-ui-runtime": "workspace:*", "@truefoundry/trueforge-sdk": "workspace:*", "@truefoundry/trueforge-ui": "workspace:*", "monaco-editor": "^0.52.0", diff --git a/packages/frontend/src/gatewaySdkStubs.ts b/packages/frontend/src/gatewaySdkStubs.ts deleted file mode 100644 index 2489a8aed..000000000 --- a/packages/frontend/src/gatewaySdkStubs.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Optional peer of @truefoundry/assistant-ui-runtime. The runtime main entry - * re-exports the gateway adapter; Harness does not use it. Stub so Vite can - * tree-shake the adapter without resolving truefoundry-gateway-sdk. - */ -function gatewaySdkMissing(): never { - throw new Error('truefoundry-gateway-sdk is not installed in this app'); -} - -export const AgentSessionClient = gatewaySdkMissing; -export const PrivateAgentSessionClient = gatewaySdkMissing; -export const TruefoundryGatewayApi = gatewaySdkMissing; - -export default TruefoundryGatewayApi; diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 7e8c22eef..74faf8e16 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -1,15 +1,11 @@ import react from '@vitejs/plugin-react'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Plugin, ProxyOptions } from 'vite'; import { defaultClientConditions, defineConfig } from 'vite'; import { compression } from 'vite-plugin-compression2'; // Maintained ESM fork of vite-plugin-monaco-editor (works with Vite 6 ESM config). import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'; -const rootDir = path.dirname(fileURLToPath(import.meta.url)); -const gatewaySdkStub = path.join(rootDir, 'src/gatewaySdkStubs.ts'); - const SERVER = process.env.VITE_SERVER_URL ?? 'http://localhost:8790'; const PORT = Number(process.env.FRONTEND_PORT ?? 3000); if (!Number.isInteger(PORT)) { @@ -69,11 +65,6 @@ export default defineConfig(({ command }) => ({ // Never add 'import'/'require' here: Vite applies those per import kind, and // forcing 'import' makes CJS deps require @babel/runtime's ESM helpers. conditions: ['trueforge-dev', ...defaultClientConditions], - alias: { - 'truefoundry-gateway-sdk/agents/private': gatewaySdkStub, - 'truefoundry-gateway-sdk/agents': gatewaySdkStub, - 'truefoundry-gateway-sdk': gatewaySdkStub, - }, dedupe: ['react', 'react-dom', '@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/react'], }, server: { diff --git a/packages/trueforge-ui/README.md b/packages/trueforge-ui/README.md index d74ca22ca..0d5696ac1 100644 --- a/packages/trueforge-ui/README.md +++ b/packages/trueforge-ui/README.md @@ -4,15 +4,15 @@ [npm](https://www.npmjs.com/package/@truefoundry/trueforge-ui) [License](./LICENSE) -A themeable, composable React SDK for building production-ready AI agent chat applications. -Build your own agent chat platform with a flexible UI layer that works with **TrueFoundry** (Control Plane + Gateway), **TrueForge** (harness), or any custom backend. The SDK is fully open source, giving you complete control over your architecture, components, and integrations. +A themeable, composable React SDK for building production-ready AI agent chat applications. +Build your own agent chat platform with a flexible UI layer that works with **TrueForge** or any custom backend. The SDK is fully open source, giving you complete control over your architecture, components, and integrations. Powered by [assistant-ui](https://www.assistant-ui.com/), it follows the familiar **shadcn/ui** and **Tailwind CSS variable** theming conventions, making it easy to customize every aspect of the experience. **Features** - **Fully themeable** — Use built-in themes inspired by **TrueFoundry**, **ChatGPT**, **Claude**, and **Gemini**, or create your own brand identity. - **Composable by design** — Swap layouts, components, and interaction patterns to fit your product. -- **Backend agnostic** — Connect to **TrueFoundry**, **TrueForge**, or any custom API via `TrueForgeServerConfig`. +- **Backend agnostic** — Connect to **TrueForge** or any custom API via `TrueForgeServerConfig`. - **Open source** — Extend, customize, and contribute without vendor lock-in. - **Built on assistant-ui** — Leverage a modern React foundation with seamless shadcn/ui and Tailwind CSS integration. - **Production ready** — Focus on your agents while the SDK handles the chat experience. @@ -75,31 +75,6 @@ See [docs/compatibility.md](./docs/compatibility.md) for version ranges. ## Getting started -**TrueFoundry** (`type: "truefoundry"`) — Control Plane + Gateway - -Zero-config path: the SDK builds the agent UI server from your API key and -control plane URL (optional explicit gateway URL). - -```tsx -import { TrueForgeUI } from '@truefoundry/trueforge-ui'; - -export default function App() { - return ( -
- -
- ); -} -``` - **TrueForge** (`type: "trueforge"`) — Harness Zero-config path for the TrueForge / Harness API. The SDK dynamically loads @@ -159,34 +134,22 @@ the factory outside ``. **Custom** — bring your own `AgentUIServer` -Use this when you compose chat + builder yourself (e.g. chat-only gateway -adapter + stub catalog, or a full host BFF). Pass the server object directly. +Pass a complete server object directly. Use `createTrueForgeServer` to compose separately implemented ports. ```tsx -import { TrueForgeUI, createTrueFoundryServer } from '@truefoundry/trueforge-ui'; -import { createTrueFoundryChatServer } from '@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter'; +import { TrueForgeUI, createTrueForgeServer } from '@truefoundry/trueforge-ui'; -const chatServer = createTrueFoundryChatServer({ - apiKey: process.env.TFY_API_KEY!, - baseUrl: process.env.TFY_GATEWAY_URL!, -}); - -const server = createTrueFoundryServer({ +const server = createTrueForgeServer({ chatServer, - getModels: async () => [], - getSkills: async () => [], - getMcp: async () => [], - searchAgents: async () => [], - saveAgent: async () => ({}), + getCapabilities, + getModels, + getSkills, + getMcp, + searchAgents, + saveAgent, }); -export default function App() { - return ( -
- -
- ); -} +; ``` ## `` props @@ -196,10 +159,10 @@ export default function App() { ```tsx void` | — | Host error hook (runtime + server init). | -Later sections use `server` as a `TrueForgeServerConfig` (usually `type: "truefoundry"`). For a host-built port, pass the `AgentUIServer` directly. +Later sections use `server` as a `TrueForgeServerConfig` (usually `type: "trueforge"`). For a host-built port, pass the `AgentUIServer` directly. --- @@ -235,9 +198,9 @@ Pass a preset and/or tokens and the whole UI adapts. Values map onto product CSS ```tsx const server = { - type: 'truefoundry' as const, - apiKey: process.env.TFY_API_KEY!, - controlPlaneURL: process.env.TFY_CONTROL_PLANE_URL!, + type: 'trueforge' as const, + baseUrl: process.env.TRUEFORGE_BASE_URL, + token: process.env.TRUEFORGE_TOKEN, }; ; ``` -For deeper composition without `TrueForgeUI`, nest `SlotsProvider` outside `TrueFoundryChatProvider` — see [docs/customization.md](./docs/customization.md). +For deeper composition without `TrueForgeUI`, nest `SlotsProvider` outside `TrueForgeChatProvider` — see [docs/customization.md](./docs/customization.md). > _Screenshot: a custom layout assembled from_ `BrandLogo`_,_ `ThreadListContainer`_, and_ `Thread`_._ @@ -530,13 +493,6 @@ inside the component, or pass a ready `AgentUIServer` directly. ```ts type TrueForgeServerConfig = - | { - type: 'truefoundry'; - apiKey: string; - controlPlaneURL: string; - gatewayPlaneURL?: string; - permissions?: PermissionsServer; - } | { type: 'trueforge'; baseUrl?: string; @@ -569,37 +525,18 @@ type AgentUIServer = AgentChatServer & Omit a chrome port such as `catalog` or `schedules` to hide and unregister its routes. -When `permissions` is omitted from a custom or TrueFoundry server, actions remain enabled for backward compatibility. +When `permissions` is omitted from a custom server, actions remain enabled for backward compatibility. When provided, denied mutation controls stay visible but disabled with an explanatory tooltip. The built-in `type: "trueforge"` server enables checks automatically through the Harness permissions endpoint; an explicit `PermissionsServer` overrides that default. -**Zero-config TrueFoundry** — see [Getting started](#getting-started). The SDK calls `createTrueFoundryAgentUIServer` for you. - **Zero-config TrueForge (Harness)** — see [Getting started](#getting-started). `type: "trueforge"` resolves via `@truefoundry/trueforge-ui/plugins/trueforge-agent-server-adapter` (`createTrueForgeAgentUIServer`: chat + builder + default catalogs). Auth is host-owned: pass `token` and/or `fetch`. -**Compose your own `AgentUIServer` (custom):** - -```tsx -import { TrueForgeUI, createTrueFoundryServer } from '@truefoundry/trueforge-ui'; -import { createTrueFoundryChatServer } from '@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter'; - -const chatServer = createTrueFoundryChatServer({ apiKey, baseUrl }); -const agentServer = createTrueFoundryServer({ - chatServer, - getModels, - getSkills, - getMcp, - searchAgents, - saveAgent, -}); - -; -``` +**Compose your own `AgentUIServer` (custom):** use the exported `createTrueForgeServer` helper or implement the port directly. **Implement `AgentUIServer` yourself:** @@ -635,7 +572,7 @@ See [docs/server.md](./docs/server.md) for the full method list and BYO guidance | ------------------------------------------------------------------ | ---------- | ------------------------------------------------------------ | | `TrueForgeUI` | Component | Root component — accepts all props above | | `TrueForgeServerConfig` | Type | `server` prop: `truefoundry` / `trueforge` / `AgentUIServer` | -| `createTrueFoundryServer` | Function | Compose chat + builder into `AgentUIServer` | +| `createTrueForgeServer` | Function | Compose chat + builder into `AgentUIServer` | | `Thread`, `ThreadListContainer`, `BrandLogo` | Components | Layout primitives for custom layouts | | `resolveBrandChrome`, `useBrandName`, `useBrand` | Helpers | Brand chrome look + name for custom layouts | | Composer / message / tool atoms | Components | Overridable, themeable building blocks | diff --git a/packages/trueforge-ui/docs/api.md b/packages/trueforge-ui/docs/api.md index eb9584eb0..4ce32cd5b 100644 --- a/packages/trueforge-ui/docs/api.md +++ b/packages/trueforge-ui/docs/api.md @@ -9,19 +9,18 @@ like `Button` / `IconButton` are styled via theme tokens / CSS (not ## Quick start -| Export | Notes | -| -------------------------------- | ------------------------------------------------------------------------------------ | -| `TrueForgeUI` | Slots + runtime + built-in layout | -| `TrueForgeUIProps`, `ChatLayout` | Props / layout union | -| `TrueForgeServerConfig` | `server` prop: `type: "truefoundry"` \| `type: "trueforge"` \| ready `AgentUIServer` | +| Export | Notes | +| -------------------------------- | ----------------------------------------------------------- | +| `TrueForgeUI` | Slots + runtime + built-in layout | +| `TrueForgeUIProps`, `ChatLayout` | Props / layout union | +| `TrueForgeServerConfig` | `server` prop: `type: "trueforge"` or ready `AgentUIServer` | ### Built-in servers -| `server` config | What the SDK does | -| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `{ type: 'truefoundry', apiKey, controlPlaneURL, gatewayPlaneURL? }` | Calls runtime `createTrueFoundryAgentUIServer` | -| `{ type: 'trueforge', baseUrl?, token?, fetch?, catalog? }` | Dynamic-imports `plugins/trueforge-agent-server-adapter` → full Harness `AgentUIServer` | -| Ready `AgentUIServer` | Passthrough (host-composed or `createTrueFoundryServer`) | +| `server` config | What the SDK does | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `{ type: 'trueforge', baseUrl?, token?, fetch?, catalog? }` | Dynamic-imports `plugins/trueforge-agent-server-adapter` → full Harness `AgentUIServer` | +| Ready `AgentUIServer` | Passthrough (host-composed or `createTrueForgeServer`) | TrueForge hosts need `@truefoundry/trueforge-sdk`. Cookie apps pass `fetch`; embeds usually pass `token`. @@ -53,16 +52,16 @@ surface is split across the `AgentMetrics`, `AgentMetricsView`, The built-in TrueForge config enables permissions automatically through the Harness endpoint and accepts an explicit `PermissionsServer` override. -TrueFoundry and custom servers remain permissive unless they provide the optional -port. `useResourcePermissions` reads `USE`, `MANAGE`, and `DELETE` grants, while +Custom servers remain permissive unless they provide the optional port. +`useResourcePermissions` reads `USE`, `MANAGE`, and `DELETE` grants, while `PermissionGuard` keeps denied actions visible and explained. ## Compose | Export | Notes | | ------------------------------------------------------------- | ------------------------------- | -| `TrueFoundryChatProvider` | Named-agent runtime + toasts | -| `TrueFoundryChatProviderProps` | `client` XOR `apiKey`+`baseUrl` | +| `TrueForgeChatProvider` | Named-agent runtime + toasts | +| `TrueForgeChatProviderProps` | `client` XOR `apiKey`+`baseUrl` | | `Thread` | Full thread + composer | | `ThreadContainer`, `ComposerContainer`, `ThreadListContainer` | Building blocks | | `ToasterProvider`, `useToaster`, `useToasterOptional` | Success and error toasts | @@ -79,11 +78,11 @@ port. `useResourcePermissions` reads `USE`, `MANAGE`, and `DELETE` grants, while ## Chrome / runtime -| Export | Notes | -| ------------------------------------------------------------------------------------- | --------------------------------------------- | -| `useAui`, `useAuiState`, `AssistantState` | Also `@truefoundry/trueforge-ui/assistant-ui` | -| `AgentSessionClient` | Gateway client | -| `useTrueFoundryAgentRuntime`, `trueFoundryAttachmentAdapter`, other `useTrueFoundry*` | Runtime hooks | +| Export | Notes | +| ------------------------------------------------------------------------------- | --------------------------------------------- | +| `useAui`, `useAuiState`, `AssistantState` | Also `@truefoundry/trueforge-ui/assistant-ui` | +| `AgentSessionClient` | Gateway client | +| `useTrueForgeAgentRuntime`, `trueForgeAttachmentAdapter`, other `useTrueForge*` | Runtime hooks | ## Types for overrides diff --git a/packages/trueforge-ui/docs/compatibility.md b/packages/trueforge-ui/docs/compatibility.md index 935e92210..a095f20b2 100644 --- a/packages/trueforge-ui/docs/compatibility.md +++ b/packages/trueforge-ui/docs/compatibility.md @@ -3,9 +3,7 @@ Peer and dependency ranges for `@truefoundry/trueforge-ui` (see `package.json` for authoritative versions). -**Status:** this package is `0.x` (APIs may change). It currently depends on -`truefoundry-gateway-sdk@^0.4.0-rc.6` — pin with a lockfile for reproducible -installs until a stable gateway release is adopted. +**Status:** this package is `0.x` and APIs may change. ## Required peers @@ -17,18 +15,17 @@ Install once at the app root. ## Bundled dependencies (installed with the SDK) -| Package | Range | -| ----------------------------------- | ---------- | -| `@assistant-ui/core` | `^0.2.22` | -| `@assistant-ui/react` | `^0.14.24` | -| `@truefoundry/assistant-ui-runtime` | `0.1.25` | -| `truefoundry-gateway-sdk` | `^0.4.0` | -| `lucide-react` | `^0.562.0` | +| Package | Range | +| ----------------------------------- | ----------------- | +| `@assistant-ui/core` | `^0.2.22` | +| `@assistant-ui/react` | `^0.14.24` | +| `@truefoundry/assistant-ui-runtime` | workspace release | +| `lucide-react` | `^0.562.0` | `@assistant-ui/*` are also listed as **optional** peers so hosts that install them directly for customization stay on a compatible range. -Runtime + gateway are wired by `TrueFoundryChatProvider` and re-exported for +The runtime is wired by `TrueForgeChatProvider` and re-exported for advanced use. `lucide-react` supplies default UI icons via `IconRegistry`; hosts may override via `theme.icons` or register custom SVGR components. diff --git a/packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts b/packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts deleted file mode 100644 index 258c61e13..000000000 --- a/packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * TEMP: Gateway-backed `AgentUIServer` for `docs/server-types.temp.ts`. - * - * Not wired into the package. Sketch of how `createTrueFoundryServer` would look - * once the unified session port is promoted. - * - * Pattern: - * - Host-facing API = unified (createSession / listSessions / updateSession + isMutable) - * - Gateway still has named + draft clients — adapter fans out internally - */ - -import type { Page } from 'truefoundry-gateway-sdk'; -import type { AgentSession as GwAgentSession } from 'truefoundry-gateway-sdk/agents'; -import { AgentSessionClient } from 'truefoundry-gateway-sdk/agents'; -import type { AgentDraftSession as GwAgentDraftSession } from 'truefoundry-gateway-sdk/agents/private'; -import { PrivateAgentSessionClient } from 'truefoundry-gateway-sdk/agents/private'; - -import type { - AgentLibraryEntry, - AgentSession, - AgentSkill, - AgentSpec, - AgentUIServer, - ConnectorState, - CreateSessionRequest, - ListSessionsParams, - ListSessionsResponse, - ModelEntry, - PageResult, - SearchAgentsParams, - Session, - UpdateSessionRequest, -} from './server-types.temp.js'; - -type GwSession = GwAgentSession | GwAgentDraftSession; - -export type CreateTrueFoundryServerOptions< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSpec extends AgentSpec = AgentSpec, - TSave = unknown, -> = { - apiKey: string; - baseUrl: string; - getModels: () => Promise; - getSkills: () => Promise; - getMcp: () => Promise; - searchAgents: (req?: SearchAgentsParams) => Promise; - saveAgent: (req: { agentName: string; agentSpec: TSpec; sessionId?: string }) => Promise; - deleteAgent?: (req: { agentName: string }) => Promise; - deleteSession?: (req: { sessionId: string }) => Promise; -}; - -/** TFY adapter: unified port + gateway clients escape hatch. */ -export type TrueFoundryServer< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSpec extends AgentSpec = AgentSpec, - TSave = unknown, -> = AgentUIServer< - Session, - CreateSessionRequest & { tfyMetadata?: string }, - ListSessionsParams, - UpdateSessionRequest, - import('./server-types.temp.js').Turn, - import('./server-types.temp.js').SessionEventItem, - TSpec, - import('./server-types.temp.js').TurnStreamingEvent, - TModel, - TSkill, - TMcp, - TAgent, - TSave -> & { - getGatewayClients(): { - client: AgentSessionClient; - privateClient: PrivateAgentSessionClient; - }; -}; - -function isDraft(session: GwSession): session is GwAgentDraftSession { - return session.type === 'session/draft'; -} - -/** Map gateway named/draft session → unified UI session (+ methods). */ -function wrapSession(raw: GwSession): AgentSession { - const mutable = isDraft(raw); - return { - id: raw.id, - title: raw.title, - agentName: raw.agentName, - agentSpec: mutable ? raw.agentSpec : undefined, - isMutable: mutable, - createdBySubject: raw.createdBySubject, - createdAt: raw.createdAt, - updatedAt: raw.updatedAt, - prepareTurn: opts => raw.prepareTurn(opts), - listTurns: opts => raw.listTurns(opts) as unknown as ReturnType, - getTurn: req => raw.getTurn(req), - cancel: () => raw.cancel(), - listEvents: opts => raw.listEvents(opts) as unknown as ReturnType, - update: mutable ? req => raw.update(req) : undefined, - }; -} - -function mapPage(page: Page, map: (item: TIn) => TOut): PageResult { - return { - data: page.data.map(map), - response: page.response, - hasNextPage: () => page.hasNextPage(), - getNextPage: async () => mapPage(await page.getNextPage(), map), - }; -} - -async function resolveRaw( - client: AgentSessionClient, - privateClient: PrivateAgentSessionClient, - sessionId: string, -): Promise { - try { - return await client.getSession({ sessionId }); - } catch { - return privateClient.getDraftSession({ draftSessionId: sessionId }); - } -} - -/** - * Composes gateway chat clients with host-provided builder catalog callbacks. - * Draft vs named is an internal fan-out; the host only sees unified sessions. - */ -export function createTrueFoundryServer< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSpec extends AgentSpec = AgentSpec, - TSave = unknown, ->( - opts: CreateTrueFoundryServerOptions, -): TrueFoundryServer { - const gatewayOpts = { apiKey: opts.apiKey, baseUrl: opts.baseUrl }; - const client = new AgentSessionClient(gatewayOpts); - const privateClient = new PrivateAgentSessionClient(gatewayOpts); - - type CreateReq = CreateSessionRequest & { tfyMetadata?: string }; - - const server = { - // --- unified chat --- - async createSession(req: CreateReq) { - if (req.agentSpec) { - const draft = await privateClient.createDraftSession({ - agentSpec: req.agentSpec, - agentName: req.agentName, - tfyMetadata: req.tfyMetadata, - }); - return wrapSession(draft); - } - if (req.agentName) { - const named = await client.createSession({ - agentName: req.agentName, - tfyMetadata: req.tfyMetadata, - }); - return wrapSession(named); - } - throw new Error('createSession requires agentName and/or agentSpec'); - }, - - async listSessions(req?: ListSessionsParams) { - const page = await privateClient.listOwnedSessions(req); - return mapPage(page, wrapSession) as PageResult; - }, - - async getSession(req: { sessionId: string }) { - return wrapSession(await resolveRaw(client, privateClient, req.sessionId)); - }, - - async updateSession(req: UpdateSessionRequest) { - const raw = await resolveRaw(client, privateClient, req.sessionId); - if (!isDraft(raw)) { - throw new Error('updateSession: session is not mutable (isMutable=false)'); - } - await raw.update({ agentSpec: req.agentSpec }); - // title is not on gateway draft update today — host/BFF if needed - return wrapSession(raw); - }, - - createTurn: ((req: { - sessionId: string; - input?: Parameters[0] extends infer O - ? O extends { input?: infer I } - ? I - : never - : never; - previousTurnId?: string | 'auto'; - stream?: boolean; - pollIntervalMs?: number; - }) => { - if (req.stream === false) { - return (async () => { - const session = await resolveRaw(client, privateClient, req.sessionId); - return session - .prepareTurn({ - input: req.input, - previousTurnId: req.previousTurnId, - }) - .execute({ stream: false, pollIntervalMs: req.pollIntervalMs }); - })(); - } - return (async function* () { - const session = await resolveRaw(client, privateClient, req.sessionId); - yield* session - .prepareTurn({ - input: req.input, - previousTurnId: req.previousTurnId, - }) - .execute({ stream: true }); - })(); - }) as TrueFoundryServer['createTurn'], - - async cancelSession({ sessionId }: { sessionId: string }) { - await (await resolveRaw(client, privateClient, sessionId)).cancel(); - }, - - async deleteSession({ sessionId }: { sessionId: string }) { - if (!opts.deleteSession) { - throw new Error('deleteSession is not on the gateway SDK. Pass deleteSession to createTrueFoundryServer.'); - } - await opts.deleteSession({ sessionId }); - }, - - async listTurns({ sessionId, ...page }: { sessionId: string; limit?: number; pageToken?: string }) { - const raw = await resolveRaw(client, privateClient, sessionId); - return raw.listTurns(page) as unknown as ReturnType; - }, - - async getTurn({ sessionId, turnId }: { sessionId: string; turnId: string }) { - return (await resolveRaw(client, privateClient, sessionId)).getTurn({ - turnId, - }); - }, - - async listEvents({ - sessionId, - ...page - }: { - sessionId: string; - pageToken?: string; - lastTurnId?: string; - limit?: number; - }) { - const raw = await resolveRaw(client, privateClient, sessionId); - return raw.listEvents(page) as unknown as ReturnType; - }, - - async *subscribeToTurn({ - sessionId, - turnId, - afterSequenceNumber, - }: { - sessionId: string; - turnId: string; - afterSequenceNumber?: number; - }) { - const raw = await resolveRaw(client, privateClient, sessionId); - const turn = await raw.getTurn({ turnId }); - yield* turn.stream({ afterSequenceNumber }); - }, - - downloadSandboxFile: (sandboxId: string, req: { path: string }) => - privateClient.downloadSandboxFile(sandboxId, req), - - // --- builder (host-owned) --- - getModels: opts.getModels, - getSkills: opts.getSkills, - getMcp: opts.getMcp, - searchAgents: opts.searchAgents, - saveAgent: opts.saveAgent, - async deleteAgent(req: { agentName: string }) { - if (!opts.deleteAgent) { - throw new Error('deleteAgent is host-owned. Pass deleteAgent to createTrueFoundryServer.'); - } - await opts.deleteAgent(req); - }, - - getGatewayClients: () => ({ client, privateClient }), - }; - - return server as TrueFoundryServer; -} diff --git a/packages/trueforge-ui/docs/server-types.temp.md b/packages/trueforge-ui/docs/server-types.temp.md deleted file mode 100644 index e5b8deade..000000000 --- a/packages/trueforge-ui/docs/server-types.temp.md +++ /dev/null @@ -1,538 +0,0 @@ -# TEMP: Self-contained server types (copied, no imports) - -> **Draft only.** Not wired into the package. -> Types below are **pasted** from `truefoundry-gateway-sdk` / runtime shapes so you can read the contract without imports. -> Live code still re-exports from gateway (`src/server/types.ts`) until you decide to promote. - -Source pin (approx): `truefoundry-gateway-sdk@0.4.0-rc.1` - ---- - -## Catalog (SDK-owned — already local) - -```ts -export interface ModelEntry { - name: string; - provider: string; - apiModel: string; - modelId: string; -} - -export interface AgentSkill { - id: string; - name: string; - fqn?: string; - description?: string; -} - -export interface ConnectorState { - id: string; - name: string; - description?: string; -} - -export interface AgentLibraryEntry { - name: string; - description?: string; - model?: string; - skillsCount?: number; - mcpCount?: number; - author?: string; - updatedAt?: string; -} - -export type SearchAgentsParams = { - query?: string; - limit?: number; - offset?: number; -}; - -export type DraftAgentSpecPatch = { - model: { - name: string; - params?: { maxTokens?: number; reasoningEffort?: string }; - }; - skills: { fqn: string; preload: false }[]; - mcpServers: { - type: 'truefoundry-mcp-registry'; - name: string; - enableTools: ['@all']; - }[]; -}; -``` - ---- - -## Gateway / runtime shapes (copied) - -### Subject - -```ts -export interface Subject { - subjectId: string; - subjectType: string; - subjectSlug: string; -} -``` - -### Pagination - -```ts -export interface TokenPagination { - nextPageToken?: string; - previousPageToken?: string; - limit: number; -} - -/** Structural page shape hosts must satisfy (gateway `Page` is a class). */ -export interface PageResult { - data: T[]; - response: R; - hasNextPage(): boolean; - getNextPage(): Promise>; -} - -export type ListSessionsOrder = 'asc' | 'desc'; - -export type PageParams = { - limit?: number; - order?: ListSessionsOrder; - pageToken?: string; - startTimestamp?: string; - endTimestamp?: string; -}; - -export type PreviousTurnIdInput = 'auto' | 'none' | string; -``` - -### AgentSpec (+ nested) - -```ts -export interface ModelParamsCacheControl { - type: string; -} - -export interface ModelParams { - maxTokens?: number; - temperature?: number; - topP?: number; - topK?: number; - parallelToolCalls?: boolean; - reasoningEffort?: string; - cacheControl?: ModelParamsCacheControl; -} - -export interface Model { - name: string; - params?: ModelParams; -} - -export interface SkillMount { - /** Fully qualified name of the agent skill version. */ - fqn: string; - /** If true, SKILL.md is injected into agent context. */ - preload?: boolean; -} - -export type ToolsSelectorTag = '@all' | '@read-only'; -export type ToolsSelectorItem = ToolsSelectorTag | string; - -export type RequireApprovalToolsSelectorTag = '@all' | '@write' | '@destructive'; -export type RequireApprovalToolSelectorItem = RequireApprovalToolsSelectorTag | string; - -export interface BaseMcpServer { - name: string; - enableTools?: ToolsSelectorItem[]; - disableTools?: ToolsSelectorItem[]; - preloadTools?: ToolsSelectorItem[]; - requireApprovalForTools?: RequireApprovalToolSelectorItem[]; - preload?: boolean; -} - -export interface RegisteredMcpServer extends BaseMcpServer { - type: 'truefoundry-mcp-registry'; -} - -export interface InlineMcpServer extends BaseMcpServer { - type: 'inline'; - url: string; -} - -export type McpServer = RegisteredMcpServer | InlineMcpServer; - -export interface AgentSpecUserMessage { - type: 'user.message'; - content: string; -} - -export type ResponseFormat = - | { type: 'text' } - | { type: 'json_object' } - | { - type: 'json_schema'; - jsonSchema: { - name: string; - description?: string; - schema?: Record; - strict?: boolean; - }; - }; - -export interface RuntimeConfig { - iterationLimit?: number; - sandbox?: { - enabled: boolean; - fileDownloads?: boolean; - }; - dynamicSubAgents?: { enabled?: boolean }; - contextManagement?: { - compaction?: CompactionConfig; - largeToolResponse?: { enabled?: boolean }; - }; - generativeUi?: { enabled?: boolean }; - askUserQuestions?: { enabled?: boolean }; -} - -export interface CompactionConfig { - enabled?: boolean; - trigger?: { - type: 'input_tokens'; - value: number; - }; -} - -/** Agent Definition (gateway `TruefoundryGatewayApi.AgentSpec`) */ -export interface AgentSpec { - model: Model; - instructions?: string; - messages?: AgentSpecUserMessage[]; - mcpServers?: McpServer[]; - responseFormat?: ResponseFormat; - skills?: SkillMount[]; - config?: RuntimeConfig; -} - -/** Partial patch (from `@truefoundry/assistant-ui-runtime`) */ -export type AgentSpecUpdate = { - instructions?: string; - model?: Partial & { - params?: Partial; - }; - mcpServers?: McpServer[]; - skills?: SkillMount[]; - messages?: AgentSpecUserMessage[]; - responseFormat?: ResponseFormat; - config?: RuntimeConfig; -}; -``` - -### Session / DraftSession - -```ts -export interface Session { - type: 'session'; - id: string; - agentName: string; - title?: string; - createdBySubject: Subject; - createdAt: string; - updatedAt: string; -} - -export interface DraftSession { - type: 'session/draft'; - id: string; - agentSpec: AgentSpec; - agentName?: string; - title?: string; - createdBySubject: Subject; - createdAt: string; - updatedAt: string; -} -``` - -### Turn input / state - -```ts -export type UserMessageContent = - | string - | Array< - | { type: 'text'; text: string } // TextContent (simplified) - | { type: 'file'; /* FileContent — see gateway */ [key: string]: unknown } - >; - -export interface UserMessage { - type: 'user.message'; - content: UserMessageContent; -} - -export interface ApprovalAllow { - status: 'allow'; -} - -export interface ApprovalDeny { - status: 'deny'; - reason?: string; -} - -export type ApprovalDecision = ApprovalAllow | ApprovalDeny; - -export interface UserToolApprovalEvent { - type: 'user.tool_approval'; - threadId: string; - toolCallId: string; - approval: ApprovalDecision; -} - -export interface UserToolResponseEvent { - type: 'user.tool_response'; - threadId: string; - toolCallId: string; - content: string; -} - -export type TurnInputItem = UserMessage | UserToolApprovalEvent | UserToolResponseEvent; - -export interface TurnStateRunning { - status: 'running'; -} - -export interface TurnStateDone { - status: 'done'; - output?: { - type: 'model.message'; - id: string; - threadId: string; - createdAt: string; - content?: unknown; - name?: string; - refusal?: string; - reasoningContent?: string; - toolCalls?: unknown[]; - finishReason?: string; - usage?: unknown; - }; - requiredActions: unknown[]; // ActionRequiredEvent[] in gateway - completedAt: string; -} - -export type TurnStateCancelledReason = - 'server-execution-timeout' | 'client-cancelled' | 'cancelled-for-next-turn' | 'abandoned'; - -export interface TurnStateCancelled { - status: 'cancelled'; - reason: TurnStateCancelledReason; - completedAt: string; -} - -export interface TurnStateError { - status: 'error'; - message: string; - completedAt: string; -} - -export type TurnState = TurnStateRunning | TurnStateDone | TurnStateCancelled | TurnStateError; - -export interface Turn { - id: string; - sessionId: string; - previousTurnId?: string; - input?: TurnInputItem[]; - state: TurnState; - createdBySubject: Subject; - createdAt: string; -} -``` - -### Stream / session events - -```ts -/** - * Gateway `TurnStreamingEvent` union (names only — full event payloads in gateway): - * ModelMessageEvent | ModelMessageDeltaEvent | ToolResponseEvent | - * ThreadCreatedEvent | ThreadDoneEvent | McpAuthRequiredEvent | McpInitializeEvent | - * SandboxCreatedEvent | ToolApprovalRequiredEvent | ToolResponseRequiredEvent | - * TurnCreatedEvent | TurnDoneEvent - */ -export type TurnStreamingEvent = { type: string; [key: string]: unknown }; - -export interface TurnStreamData { - /** SSE event id for resume via subscribeToTurn. */ - sequenceNumber: number; - event: TurnStreamingEvent; -} - -/** - * Gateway `SessionEvent` union (subset of streaming, without deltas): - * TurnCreatedEvent | TurnDoneEvent | ModelMessageEvent | ToolResponseEvent | - * ThreadCreatedEvent | ThreadDoneEvent | McpAuthRequiredEvent | McpInitializeEvent | - * SandboxCreatedEvent | ToolApprovalRequiredEvent | ToolResponseRequiredEvent - */ -export type SessionEvent = { type: string; [key: string]: unknown }; - -export interface SessionEventItem { - turnId: string; - event: SessionEvent; -} -``` - -### List responses - -```ts -export interface ListSessionsResponse { - data: Session[]; - pagination: TokenPagination; -} - -export interface ListDraftSessionsResponse { - data: DraftSession[]; - pagination: TokenPagination; -} - -export interface ListOwnedSessionsResponse { - data: Array; - pagination: TokenPagination; -} - -export interface ListTurnsResponse { - data: Turn[]; - pagination: TokenPagination; -} - -export interface ListSessionEventsResponse { - data: SessionEventItem[]; - pagination: TokenPagination; -} -``` - -### Behavioral session surfaces (classes → structural paste) - -Gateway ships `AgentSession` / `AgentDraftSession` as classes. For a BYO-facing -copy, treat them as DTO + methods: - -```ts -export interface PreparedTurn { - execute(opts: { stream: false; pollIntervalMs?: number }): Promise; - execute(opts?: { stream?: true }): AsyncIterable; -} - -export interface AgentSession extends Session { - prepareTurn(opts?: { input?: TurnInputItem[]; previousTurnId?: PreviousTurnIdInput }): PreparedTurn; - listTurns(opts?: { pageToken?: string; limit?: number }): Promise>; - getTurn(req: { turnId: string }): Promise; - cancel(): Promise; - listEvents(opts?: { - pageToken?: string; - lastTurnId?: string; - limit?: number; - }): Promise>; -} - -export interface AgentDraftSession extends DraftSession { - update(req?: { agentSpec?: AgentSpec }): Promise; - prepareTurn(opts?: { input?: TurnInputItem[]; previousTurnId?: PreviousTurnIdInput }): PreparedTurn; - listTurns(opts?: { pageToken?: string; limit?: number }): Promise>; - getTurn(req: { turnId: string }): Promise; - cancel(): Promise; - listEvents(opts?: { - pageToken?: string; - lastTurnId?: string; - limit?: number; - }): Promise>; -} -``` - ---- - -## Server ports (using the copies above) - -```ts -export interface AgentChatServer { - createSession(req: { agentName: string; tfyMetadata?: string }): Promise; - // listSessions( - // req: { agentName: string } & PageParams, - // ): Promise>; - getSession(req: { sessionId: string }): Promise; - - createDraftSession(req: { - agentSpec: AgentSpec; - agentName?: string; - tfyMetadata?: string; - }): Promise; - getDraftSession(req: { draftSessionId: string }): Promise; - // listDraftSessions( - // req?: { agentName?: string } & PageParams, - // ): Promise>; - listSessions( // for all the sessions listing - req?: { agentName?: string } & PageParams, // agentName filter - ): Promise>; - updateDraftSession(req: { draftSessionId: string; agentSpec?: AgentSpec }): Promise; - - createTurn(req: { - sessionId: string; - input?: TurnInputItem[]; - previousTurnId?: PreviousTurnIdInput; - stream: false; - pollIntervalMs?: number; - }): Promise; - createTurn(req: { - sessionId: string; - input?: TurnInputItem[]; - previousTurnId?: PreviousTurnIdInput; - stream?: true; - }): AsyncIterable; - - cancelSession(req: { sessionId: string }): Promise; - deleteSession?(req: { sessionId: string }): Promise; - listTurns(req: { - sessionId: string; - limit?: number; - pageToken?: string; - }): Promise>; - getTurn(req: { sessionId: string; turnId: string }): Promise; - listEvents(req: { - sessionId: string; - pageToken?: string; - lastTurnId?: string; - limit?: number; - }): Promise>; - subscribeToTurn?(req: { - sessionId: string; - turnId: string; - afterSequenceNumber?: number; - }): AsyncIterable; - - downloadSandboxFile?(sandboxId: string, req: { path: string }): Promise; // BinaryResponse in gateway -} - -export interface AgentBuilderServer< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSave = unknown, -> { - getModels(): Promise; - getSkills(): Promise; - getMcp(): Promise; - searchAgents(req?: SearchAgentsParams): Promise; - saveAgent(req: { agentName: string; agentSpec: AgentSpec; draftSessionId?: string }): Promise; - deleteAgent?(req: { agentName: string }): Promise; -} - -export type AgentUIServer< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSave = unknown, -> = AgentChatServer & AgentBuilderServer; -``` - ---- - -## Notes / gaps in this paste - -- `TurnStreamingEvent` / `SessionEvent` / some `TurnStateDone` leaves are **summarized** (`unknown` / `{ type: string }`) — full event payloads are large gateway unions; expand on demand. -- `Page` in gateway is a **class**; `PageResult` above is a structural stand-in. -- Live `src/server/types.ts` still **imports/re-exports** gateway — this file is documentation / promotion draft only. diff --git a/packages/trueforge-ui/docs/server-types.temp.ts b/packages/trueforge-ui/docs/server-types.temp.ts deleted file mode 100644 index 502c98716..000000000 --- a/packages/trueforge-ui/docs/server-types.temp.ts +++ /dev/null @@ -1,561 +0,0 @@ -/** - * TEMP: Self-contained server types (copied, no imports) - * - * Draft only. Not wired into the package. - * - * Intent: - * - One session surface (no draft/named method split). Named vs inline-spec is - * data on the session (`agentName` / `agentSpec`), not parallel APIs. - * - MUI-style constrained generics everywhere hosts implement or extend: - * `T extends Base = Base` — SDK declares the minimum; hosts add extras. - * - * Live code still re-exports from gateway (src/server/types.ts) until you - * promote this draft. - * - * Source pin (approx): truefoundry-gateway-sdk@0.4.0-rc.1 - */ - -// --------------------------------------------------------------------------- -// Catalog (SDK-owned — already local) -// --------------------------------------------------------------------------- - -/** - * SDK-required model row. Hosts may add fields via `TModel extends ModelEntry`. - * Written to `AgentSpec.model.name` (host maps display → api id as needed). - * - * @example - * ```ts - * type MyModel = ModelEntry & { apiModel: string; modelId: string }; - * // → AgentSpec.model.name = model.apiModel - * ``` - */ -export interface ModelEntry { - /** Display label in the model picker, e.g. `"Sonnet 5"`. */ - name: string; - /** Provider id for icon / grouping, e.g. `"anthropic"`. */ - provider: string; -} - -export interface AgentSkill { - id: string; - name: string; - description?: string; -} - -/** - * SDK-required MCP connector row. Written to `AgentSpec.mcpServers[].name`. - * - * @example - * ```ts - * const mcp: ConnectorState = { - * id: "1", - * name: "ai-gateway-mcp", - * description: "mcp server for ai gateway", - * }; - * // → AgentSpec.mcpServers[].name = mcp.name - * ``` - */ -export interface ConnectorState { - /** Stable id for list keys, e.g. `"1"`. */ - id: string; - /** Registry name mounted onto `mcpServers`, e.g. `"ai-gateway-mcp"`. */ - name: string; - /** Optional blurb for the picker, e.g. `"mcp server for ai gateway"`. */ - description?: string; -} - -export interface AgentLibraryEntry { - name: string; -} - -export type SearchAgentsParams = { - limit?: number; - offset?: number; -}; - -export type DraftAgentSpecPatch = { - model: { - name: string; - params?: { maxTokens?: number; reasoningEffort?: string }; - }; - skills: { fqn: string; preload: false }[]; - mcpServers: { - type: 'truefoundry-mcp-registry'; - name: string; - enableTools: ['@all']; - }[]; -}; - -// --------------------------------------------------------------------------- -// Shared primitives -// --------------------------------------------------------------------------- - -export interface TokenPagination { - nextPageToken?: string; - previousPageToken?: string; - limit: number; -} - -/** Structural page shape hosts must satisfy (gateway `Page` is a class). */ -export interface PageResult { - data: T[]; - response: R; - hasNextPage(): boolean; - getNextPage(): Promise>; -} - -export type ListSessionsOrder = 'asc' | 'desc'; - -export type PageParams = { - limit?: number; - order?: ListSessionsOrder; - pageToken?: string; - startTimestamp?: string; - endTimestamp?: string; -}; - -export type PreviousTurnIdInput = 'auto' | 'none' | string; - -// --------------------------------------------------------------------------- -// AgentSpec (+ nested) — base; hosts extend via TSpec extends AgentSpec -// --------------------------------------------------------------------------- - -export interface ModelParamsCacheControl { - type: string; -} - -export interface ModelParams { - maxTokens?: number; - temperature?: number; - topP?: number; - topK?: number; - parallelToolCalls?: boolean; - reasoningEffort?: string; - cacheControl?: ModelParamsCacheControl; -} - -export interface Model { - name: string; - params?: ModelParams; -} - -export interface SkillMount { - fqn: string; - preload?: boolean; -} - -export type ToolsSelectorTag = '@all' | '@read-only'; -export type ToolsSelectorItem = ToolsSelectorTag | string; - -export type RequireApprovalToolsSelectorTag = '@all' | '@write' | '@destructive'; -export type RequireApprovalToolSelectorItem = RequireApprovalToolsSelectorTag | string; - -export interface BaseMcpServer { - name: string; - enableTools?: ToolsSelectorItem[]; - disableTools?: ToolsSelectorItem[]; - preloadTools?: ToolsSelectorItem[]; - requireApprovalForTools?: RequireApprovalToolSelectorItem[]; - preload?: boolean; -} - -export interface RegisteredMcpServer extends BaseMcpServer { - type: 'truefoundry-mcp-registry'; -} - -export interface InlineMcpServer extends BaseMcpServer { - type: 'inline'; - url: string; -} - -export type McpServer = RegisteredMcpServer | InlineMcpServer; - -export interface AgentSpecUserMessage { - type: 'user.message'; - content: string; -} - -export type ResponseFormat = - | { type: 'text' } - | { type: 'json_object' } - | { - type: 'json_schema'; - jsonSchema: { - name: string; - description?: string; - schema?: Record; - strict?: boolean; - }; - }; - -export interface RuntimeConfig { - iterationLimit?: number; - sandbox?: { - enabled: boolean; - fileDownloads?: boolean; - }; - dynamicSubAgents?: { enabled?: boolean }; - contextManagement?: { - compaction?: CompactionConfig; - largeToolResponse?: { enabled?: boolean }; - }; - generativeUi?: { enabled?: boolean }; - askUserQuestions?: { enabled?: boolean }; -} - -export interface CompactionConfig { - enabled?: boolean; - trigger?: { - type: 'input_tokens'; - value: number; - }; -} - -/** Agent definition base. Hosts extend via `TSpec extends AgentSpec`. */ -export interface AgentSpec { - model: Model; - instructions?: string; - messages?: AgentSpecUserMessage[]; - mcpServers?: McpServer[]; - // responseFormat?: ResponseFormat; - skills?: SkillMount[]; - // config?: RuntimeConfig; -} - -export type AgentSpecUpdate = { - instructions?: string; - model?: Partial & { - params?: Partial; - }; - mcpServers?: McpServer[]; - skills?: SkillMount[]; - messages?: AgentSpecUserMessage[]; - responseFormat?: ResponseFormat; - config?: RuntimeConfig; -}; - -// --------------------------------------------------------------------------- -// Session — one shape (named vs inline-spec is data, not dual APIs) -// --------------------------------------------------------------------------- - -/** - * SDK-minimal session row. - * - Bound to a published agent → `agentName` set, `agentSpec` usually absent. - * - Inline / mutable spec → `agentSpec` set (host may still set `agentName`). - * - `isMutable` tells the UI whether to show the mutable agent-spec builder - * (and allow `updateSession`); do not infer from `agentSpec` alone. - * Hosts add extras via `TSession extends Session`. - */ -export interface Session { - id: string; - title?: string; - agentName?: string; - agentSpec?: AgentSpec; - /** UI: mutable builder + `updateSession(spec)` when true; immutable otherwise. */ - isMutable: boolean; - createdAt: string; - updatedAt: string; -} - -/** - * Create payload base. Provide at least one of `agentName` | `agentSpec` - * (enforced by host / runtime, not the type system). - * Hosts add extras via `TCreate extends CreateSessionRequest`. - */ -export interface CreateSessionRequest { - agentName?: string; - agentSpec?: AgentSpec; -} - -/** List filter + pagination. Hosts extend via `TList extends ListSessionsParams`. */ -export interface ListSessionsParams extends PageParams { - /** - * Host-owned agent identity filter. Hosts that key agents by name pass that name here. - * Unknown / deleted ids MUST return an empty page (stale history filter, SingleAgent - * mismatch) — do not throw and fail the whole thread list. - */ - agentId?: string; -} - -/** - * Patch session (title / inline spec). No-op or error when `isMutable` is false. - * Hosts extend via `TUpdate extends UpdateSessionRequest`. - */ -export interface UpdateSessionRequest { - sessionId: string; - agentSpec?: AgentSpec; - title?: string; -} - -export interface ListSessionsResponse { - data: TSession[]; - pagination: TokenPagination; -} - -// --------------------------------------------------------------------------- -// Turn input / state -// --------------------------------------------------------------------------- - -export type UserMessageContent = - string | Array<{ type: 'text'; text: string } | { type: 'file'; [key: string]: unknown }>; - -export interface UserMessage { - type: 'user.message'; - content: UserMessageContent; -} - -export interface ApprovalAllow { - status: 'allow'; -} - -export interface ApprovalDeny { - status: 'deny'; - reason?: string; -} - -export type ApprovalDecision = ApprovalAllow | ApprovalDeny; - -export interface UserToolApprovalEvent { - type: 'user.tool_approval'; - threadId: string; - toolCallId: string; - approval: ApprovalDecision; -} - -export interface UserToolResponseEvent { - type: 'user.tool_response'; - threadId: string; - toolCallId: string; - content: string; -} - -export type TurnInputItem = UserMessage | UserToolApprovalEvent | UserToolResponseEvent; - -export interface TurnStateRunning { - status: 'running'; -} - -export interface TurnStateDone { - status: 'done'; - output?: { - type: 'model.message'; - id: string; - threadId: string; - createdAt: string; - content?: unknown; - name?: string; - refusal?: string; - reasoningContent?: string; - toolCalls?: unknown[]; - finishReason?: string; - usage?: unknown; - }; - requiredActions: unknown[]; - completedAt: string; -} - -export type TurnStateCancelledReason = - 'server-execution-timeout' | 'client-cancelled' | 'cancelled-for-next-turn' | 'abandoned'; - -export interface TurnStateCancelled { - status: 'cancelled'; - reason: TurnStateCancelledReason; - completedAt: string; -} - -export interface TurnStateError { - status: 'error'; - message: string; - completedAt: string; -} - -export type TurnState = TurnStateRunning | TurnStateDone | TurnStateCancelled | TurnStateError; - -/** SDK-minimal turn. Hosts extend via `TTurn extends Turn`. */ -export interface Turn { - id: string; - sessionId: string; - previousTurnId?: string; - input?: TurnInputItem[]; - state: TurnState; - createdAt: string; -} - -export interface ListTurnsResponse { - data: TTurn[]; - pagination: TokenPagination; -} - -// --------------------------------------------------------------------------- -// Stream / session events -// --------------------------------------------------------------------------- - -/** - * Opaque streaming event (full union lives in gateway). - * Hosts may narrow via `TStreamEvent extends TurnStreamingEvent`. - */ -export type TurnStreamingEvent = { type: string; [key: string]: unknown }; - -export interface TurnStreamData { - sequenceNumber: number; - event: TStreamEvent; -} - -export type SessionEvent = { type: string; [key: string]: unknown }; - -/** SDK-minimal history event row. Hosts extend via `TEvent extends SessionEventItem`. */ -export interface SessionEventItem { - turnId: string; - event: SessionEvent; -} - -export interface ListSessionEventsResponse { - data: TEvent[]; - pagination: TokenPagination; -} - -// --------------------------------------------------------------------------- -// Behavioral session surface (DTO + methods) -// --------------------------------------------------------------------------- - -export interface PreparedTurn { - execute(opts: { stream: false; pollIntervalMs?: number }): Promise; - execute(opts?: { stream?: true }): AsyncIterable>; -} - -export interface SessionMethods< - TTurn extends Turn = Turn, - TEvent extends SessionEventItem = SessionEventItem, - TSpec extends AgentSpec = AgentSpec, - TStreamEvent extends TurnStreamingEvent = TurnStreamingEvent, -> { - prepareTurn(opts?: { input?: TurnInputItem[]; previousTurnId?: PreviousTurnIdInput }): PreparedTurn; - listTurns(opts?: { pageToken?: string; limit?: number }): Promise>>; - getTurn(req: { turnId: string }): Promise; - cancel(): Promise; - listEvents(opts?: { - pageToken?: string; - lastTurnId?: string; - limit?: number; - }): Promise>>; - /** Present when `isMutable` is true. */ - update?(req?: { agentSpec?: TSpec }): Promise; -} - -export type AgentSession< - TSession extends Session = Session, - TTurn extends Turn = Turn, - TEvent extends SessionEventItem = SessionEventItem, - TSpec extends AgentSpec = AgentSpec, - TStreamEvent extends TurnStreamingEvent = TurnStreamingEvent, -> = TSession & SessionMethods; - -// --------------------------------------------------------------------------- -// Server ports -// --------------------------------------------------------------------------- - -/** - * Unified chat / session port. - * TFY adapter may still fan out to gateway createSession vs createDraftSession - * internally — that is an implementation detail, not part of this contract. - */ -export interface AgentChatServer< - TSession extends Session = Session, - TCreate extends CreateSessionRequest = CreateSessionRequest, - TList extends ListSessionsParams = ListSessionsParams, - TUpdate extends UpdateSessionRequest = UpdateSessionRequest, - TTurn extends Turn = Turn, - TEvent extends SessionEventItem = SessionEventItem, - TSpec extends AgentSpec = AgentSpec, - TStreamEvent extends TurnStreamingEvent = TurnStreamingEvent, -> { - createSession(req: TCreate): Promise>; - listSessions( - req?: TList, - ): Promise, ListSessionsResponse>>; - getSession(req: { sessionId: string }): Promise>; - updateSession(req: TUpdate): Promise>; - - createTurn(req: { - sessionId: string; - input?: TurnInputItem[]; - previousTurnId?: PreviousTurnIdInput; - stream: false; - pollIntervalMs?: number; - }): Promise; - createTurn(req: { - sessionId: string; - input?: TurnInputItem[]; - previousTurnId?: PreviousTurnIdInput; - stream?: true; - }): AsyncIterable>; - - cancelSession(req: { sessionId: string }): Promise; - deleteSession?(req: { sessionId: string }): Promise; - listTurns(req: { - sessionId: string; - limit?: number; - pageToken?: string; - }): Promise>>; - getTurn(req: { sessionId: string; turnId: string }): Promise; - listEvents(req: { - sessionId: string; - pageToken?: string; - lastTurnId?: string; - limit?: number; - }): Promise>>; - subscribeToTurn?(req: { - sessionId: string; - turnId: string; - afterSequenceNumber?: number; - }): AsyncIterable>; - - downloadSandboxFile?(sandboxId: string, req: { path: string }): Promise; -} - -export interface AgentBuilderServer< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSpec extends AgentSpec = AgentSpec, - TSave = unknown, -> { - getModels(): Promise; - getSkills(): Promise; - getMcp(): Promise; - listAgents(req?: SearchAgentsParams): Promise; // for now only pagination, no search - saveAgent(req: { - agentName: string; - agentSpec: TSpec; - /** Session that held the inline spec being promoted (if any). */ - sessionId?: string; - }): Promise; - deleteAgent?(req: { agentName: string }): Promise; -} - -export type AgentUIServer< - TSession extends Session = Session, - TCreate extends CreateSessionRequest = CreateSessionRequest, - TList extends ListSessionsParams = ListSessionsParams, - TUpdate extends UpdateSessionRequest = UpdateSessionRequest, - TTurn extends Turn = Turn, - TEvent extends SessionEventItem = SessionEventItem, - TSpec extends AgentSpec = AgentSpec, - TStreamEvent extends TurnStreamingEvent = TurnStreamingEvent, - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSave = unknown, -> = AgentChatServer & - AgentBuilderServer; - -// Todo : Make the SkillsMount as Generic - -// TrueForge SDK FE -> Generic Type // User can bring his own APIs (no touch to our client) -// Runtime // (Decouple from the SDK Client) -// TrueForge SDK FE -> Work was done here - -//Generic -> TrueForge SDK FE -> Runtime + Gateway SDK -> Backend (Generic) -// Should our Runtime package not have the Gateway SDK diff --git a/packages/trueforge-ui/docs/server.md b/packages/trueforge-ui/docs/server.md index bc201cf10..746c0bd37 100644 --- a/packages/trueforge-ui/docs/server.md +++ b/packages/trueforge-ui/docs/server.md @@ -1,847 +1,37 @@ -# Plan: Server abstraction (UI SDK only) +# Server configuration -Host-facing port so TrueFoundry gateway **or** bring-your-own APIs plug into one -contract: +`` accepts either the built-in TrueForge server configuration or a ready `AgentUIServer`. -- **Chat** — sessions, turns, draft `AgentSpec` sync -- **Builder** — catalog (models / skills / MCP, read-only) + `saveAgent` - (promote draft → named agent) - -## Constraint - -Everything lives in **`@truefoundry/trueforge-ui`**. - -**No changes** to `@truefoundry/assistant-ui-runtime`. - -The runtime keeps accepting `client: AgentSessionClient` (and `privateClient` -for draft). This package owns the host-facing `Server` façade and adapters. - -## Implication (explicit) - -| Concern | Reality in this plan | -| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| BYO builder (`getModels` / `getSkills` / `getMcp` / `saveAgent`) | Real — implemented and consumed in this SDK | -| BYO chat streaming | Limited — hosts must still supply gateway-compatible clients to the runtime, **or** wrap their API behind objects the runtime already expects | -| `AgentChatServer` | Stable **host** API in this SDK; TFY path extracts/passes gateway clients into the unchanged runtime | - -## Locked decisions - -| Decision | Choice | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------- | -| Shape | TypeScript `interface` (compose), not inheritance | -| Split | `AgentChatServer` + `AgentBuilderServer` | -| Package | This SDK only | -| Modes (v1) | Named + Draft (single agent) | -| Chat request / response types | Gateway SDK types (`AgentSession`, `AgentSpec`, `Turn`, …) | -| Catalog types | SDK-minimal `ModelEntry` / `AgentSkill` / `ConnectorState` / `AgentLibraryEntry`; hosts extend via generics | -| `saveAgent` | Promote draft `AgentSpec` → named agent; return stays host-defined for now | -| Catalog lists | Read-only in v1 | -| Runtime | Unchanged; still `client` / `privateClient` / `agent` | -| Chat generics | **None** in v1 — session/turn types are gateway-fixed re-exports | - -## Interfaces (target) - -Chat types are imported from `truefoundry-gateway-sdk` (agents + API). -`AgentSpec` is gateway SDK (`TruefoundryGatewayApi.AgentSpec`), re-exported by -`@truefoundry/assistant-ui-runtime`. Builder catalog types are **SDK-minimal** -(fields this UI reads); hosts keep extras typed with `T extends Base`. - -```ts -import type { AgentSession, AgentDraftSession, Turn, TurnStreamData } from 'truefoundry-gateway-sdk/agents'; -import type { - AgentSpec, - TurnInputItem, - PreviousTurnIdInput, - TurnState, - SessionEventItem, - ListSessionsResponse, - ListDraftSessionsResponse, - ListOwnedSessionsResponse, - ListTurnsResponse, - ListSessionEventsResponse, - ListSessionsOrder, -} from 'truefoundry-gateway-sdk'; -import type { Page, BinaryResponse } from 'truefoundry-gateway-sdk/core'; // or equivalent core export - -type PageParams = { - limit?: number; - order?: ListSessionsOrder; - pageToken?: string; - startTimestamp?: string; - endTimestamp?: string; -}; - -/** Chat / session port — gateway-fixed (not generic). */ -interface AgentChatServer { - // named - createSession(req: { agentName: string; tfyMetadata?: string }): Promise; - listSessions(req: { agentName: string } & PageParams): Promise>; - getSession(req: { sessionId: string }): Promise; - - // draft - createDraftSession(req: { - agentSpec: AgentSpec; - agentName?: string; - tfyMetadata?: string; - }): Promise; - getDraftSession(req: { draftSessionId: string }): Promise; - listDraftSessions( - req?: { agentName?: string } & PageParams, - ): Promise>; - listOwnedSessions( - req?: { agentName?: string } & PageParams, - ): Promise>; - /** Port helper: getDraftSession + update. Runtime draft sync may still go via privateClient. */ - updateDraftSession(req: { draftSessionId: string; agentSpec?: AgentSpec }): Promise; - - // turns (overloads match PreparedTurn.execute) - createTurn(req: { - sessionId: string; - input?: TurnInputItem[]; - previousTurnId?: PreviousTurnIdInput; - stream: false; - pollIntervalMs?: number; - }): Promise; - createTurn(req: { - sessionId: string; - input?: TurnInputItem[]; - previousTurnId?: PreviousTurnIdInput; - stream?: true; - }): AsyncIterable; - - cancelSession(req: { sessionId: string }): Promise; - /** Host/BFF — optional until delete-UI ships. */ - deleteSession?(req: { sessionId: string }): Promise; - listTurns(req: { sessionId: string; limit?: number; pageToken?: string }): Promise>; - getTurn(req: { sessionId: string; turnId: string }): Promise; - listEvents(req: { - sessionId: string; - pageToken?: string; - lastTurnId?: string; - limit?: number; - }): Promise>; - subscribeToTurn(req: { - sessionId: string; - turnId: string; - afterSequenceNumber?: number; - }): AsyncIterable; - - downloadSandboxFile?(sandboxId: string, req: { path: string }): Promise; -} - -/** - * Builder catalog + persist. - * Not in truefoundry-gateway-sdk — host/BFF provides these. - * Like MUI Table: bases declare what the SDK needs; T extends Base keeps host extras typed. - */ -interface AgentBuilderServer< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSave = unknown, -> { - getModels(): Promise; - getSkills(): Promise; - getMcp(): Promise; - /** Search / list named agents for the Agents. */ - searchAgents(req?: { query?: string; limit?: number; offset?: number }): Promise; - /** Promote draft AgentSpec → named agent */ - saveAgent(req: { agentName: string; agentSpec: AgentSpec; draftSessionId?: string }): Promise; - /** Host/BFF — optional until delete-agent UI ships. */ - deleteAgent?(req: { agentName: string }): Promise; -} - -type AgentUIServer< - TModel extends ModelEntry = ModelEntry, - TSkill extends AgentSkill = AgentSkill, - TMcp extends ConnectorState = ConnectorState, - TAgent extends AgentLibraryEntry = AgentLibraryEntry, - TSave = unknown, -> = AgentChatServer & AgentBuilderServer; -``` - -Defaults keep bare `AgentUIServer` equivalent to the SDK-minimal catalog shapes. -**Note:** Gateway session helpers are class instances (`AgentSession` / -`AgentDraftSession` with methods). The TFY adapter returns those directly. BYO -implementations must return values that satisfy the same types (or wrap until -the port only exposes plain DTOs — not required for v1). - -Plain DTOs are also re-exported as `Session` / `DraftSession` (`TruefoundryGatewayApi`). - -### Extending catalog types - -```ts -type MyModel = ModelEntry & { latencyMs: number }; - -const server: AgentUIServer = createTrueFoundryServer({ - // … - getModels: async () => [ - { - id: '…', - name: '…', - provider: { name: '…' }, - properties: {}, - apiModel: '…', - modelId: '…', - latencyMs: 12, - }, - ], -}); -// server.getModels() → Promise -``` - -## Catalog APIs & AgentSpec Types - -Catalog bases are **SDK-minimal** (fields this UI reads). Platform/cpApi payloads -may be richer — map them into these shapes, or keep extras via generics. - -### Catalog methods - -```ts -getModels(): Promise; -getSkills(): Promise; -getMcp(): Promise; -searchAgents(req?: { query?: string; limit?: number; offset?: number }): Promise; -``` - -In platform apps the RTK Query endpoints are named `getEnabledModels`, -`getAgentSkills`, `getMcpServers`, and `getAgents` -(`GET /api/svc/v1/agents?namePrefix=…`). - -#### `AgentLibraryEntry` - -```ts -interface AgentLibraryEntry { - name: string; - description?: string; - model?: string; - skillsCount?: number; - mcpCount?: number; - author?: string; - updatedAt?: string; -} -``` - -#### `ModelEntry` - -Written to `AgentSpec.model.name` via `apiModel` (fallback: `name`). - -```ts -interface ModelEntry { - name: string; - provider: string; - apiModel: string; - modelId: string; -} -``` - -#### `AgentSkill` - -Mounted onto `AgentSpec.skills` via `fqn` (fallback: `name`). - -```ts -interface AgentSkill { - id: string; - name: string; - fqn?: string; - description?: string; -} -``` - -#### `ConnectorState` - -Written to `AgentSpec.mcpServers[].name`. - -```ts -interface ConnectorState { - id: string; - name: string; - description?: string; -} -``` - -#### Session / DraftSession (gateway DTOs) - -```ts -interface Session { - type: 'session'; - id: string; - agentName: string; - title?: string; - createdBySubject: Subject; - createdAt: string; - updatedAt: string; -} - -interface DraftSession { - type: 'session/draft'; - id: string; - agentSpec: AgentSpec; - agentName?: string; - title?: string; - createdBySubject: Subject; - createdAt: string; - updatedAt: string; -} -``` - -Thread list uses `id`, `title?`, `updatedAt`. Draft title falls back to -`agentSpec.model.name`. `AgentSession` / `AgentDraftSession` add methods on top. - -### `AgentSpec` (complete) - -Source of truth: `truefoundry-gateway-sdk` → `TruefoundryGatewayApi.AgentSpec` -Re-exported by: `@truefoundry/assistant-ui-runtime` - -```ts -interface AgentSpec { - model: Model; - instructions?: string; - messages?: AgentSpecUserMessage[]; - mcpServers?: McpServer[]; - responseFormat?: ResponseFormat; - skills?: SkillMount[]; - config?: RuntimeConfig; -} - -interface CompactionConfig { - enabled?: boolean; - trigger?: { - type: 'input_tokens'; - value: number; - }; -} - -interface Model { - name: string; - params?: ModelParams; -} - -interface ModelParams { - maxTokens?: number; - temperature?: number; - topP?: number; - topK?: number; - parallelToolCalls?: boolean; - reasoningEffort?: string; - cacheControl?: { type: string }; -} - -interface SkillMount { - /** Fully qualified name of the agent skill version. */ - fqn: string; - /** If true, SKILL.md is injected into agent context. */ - preload?: boolean; -} - -type ToolsSelectorTag = '@all' | '@read-only'; -type ToolsSelectorItem = ToolsSelectorTag | string; - -type RequireApprovalToolsSelectorTag = '@all' | '@write' | '@destructive'; -type RequireApprovalToolSelectorItem = RequireApprovalToolsSelectorTag | string; - -interface BaseMcpServer { - name: string; - enableTools?: ToolsSelectorItem[]; - disableTools?: ToolsSelectorItem[]; - preloadTools?: ToolsSelectorItem[]; - requireApprovalForTools?: RequireApprovalToolSelectorItem[]; - preload?: boolean; -} - -interface RegisteredMcpServer extends BaseMcpServer { - type: 'truefoundry-mcp-registry'; -} - -interface InlineMcpServer extends BaseMcpServer { - type: 'inline'; - url: string; -} - -type McpServer = RegisteredMcpServer | InlineMcpServer; - -interface AgentSpecUserMessage { - type: 'user.message'; - content: string; -} - -type ResponseFormat = - | { type: 'text' } - | { type: 'json_object' } - | { - type: 'json_schema'; - jsonSchema: { - name: string; - description?: string; - schema?: Record; - strict?: boolean; - }; - }; - -interface RuntimeConfig { - iterationLimit?: number; - sandbox?: { - enabled: boolean; - fileDownloads?: boolean; - }; - dynamicSubAgents?: { enabled?: boolean }; - contextManagement?: { - compaction?: CompactionConfig; - largeToolResponse?: { enabled?: boolean }; - }; - generativeUi?: { enabled?: boolean }; - askUserQuestions?: { enabled?: boolean }; -} -``` - -### `AgentSpecUpdate` (partial patch) - -```ts -type AgentSpecUpdate = { - instructions?: string; - model?: Partial & { - params?: Partial; - }; - mcpServers?: McpServer[]; - skills?: SkillMount[]; - messages?: AgentSpecUserMessage[]; - responseFormat?: ResponseFormat; - config?: RuntimeConfig; -}; -``` - -### Draft subset this app writes - -Typical builder → draft patch shape (not the full `AgentSpec`): - -```ts -{ - model: { - name: string; - params?: { maxTokens?: number; reasoningEffort?: string }; - }; - skills: { fqn: string; preload: false }[]; - mcpServers: { - type: "truefoundry-mcp-registry"; - name: string; - enableTools: ["@all"]; - }[]; -} -``` - -## Gateway mapping (`AgentChatServer`) - -| Port method | Gateway source | -| -------------------------------------- | ----------------------------------------------- | -| `createSession` | `AgentSessionClient.createSession` | -| `listSessions` | `AgentSessionClient.listSessions` | -| `getSession` | `AgentSessionClient.getSession` | -| `createDraftSession` | `PrivateAgentSessionClient.createDraftSession` | -| `getDraftSession` | `PrivateAgentSessionClient.getDraftSession` | -| `listDraftSessions` | `PrivateAgentSessionClient.listDraftSessions` | -| `listOwnedSessions` | `PrivateAgentSessionClient.listOwnedSessions` | -| `updateDraftSession` | `getDraftSession` + `AgentDraftSession.update` | -| `createTurn` | session `prepareTurn` → `execute` / stream | -| `cancelSession` | session `cancel` | -| `listTurns` / `getTurn` / `listEvents` | session turn helpers | -| `subscribeToTurn` | turn `stream` / subscribe | -| `downloadSandboxFile` | `PrivateAgentSessionClient.downloadSandboxFile` | - -**Not in gateway:** `getModels`, `getSkills`, `getMcp`, `searchAgents`, `saveAgent`, -`deleteAgent?`, `deleteSession?`. - -## Call flow (end state) - -``` -Host - └─ createTrueFoundryServer / custom AgentUIServer -TrueForgeUI({ server, agentConfig, … }) - ├─ builder UI ──► server.getModels / getSkills / getMcp / saveAgent - └─ TrueFoundryChatProvider - └─ useTrueFoundryAgentRuntime({ - client, // from TFY adapter internals - privateClient?, // draft - agent: named | draft - }) // runtime package untouched -``` - -## Phases - -### Phase 0 — Spec freeze - -1. Keep this doc as the source of truth for interfaces and mapping. -2. Confirm compose type: `AgentUIServer = AgentChatServer & AgentBuilderServer`. - -**Done when:** interfaces agreed; no code required. - -### Phase 1 — Types + TFY compose helper - -1. Add `AgentChatServer`, `AgentBuilderServer`, `AgentUIServer` under e.g. `src/server/`. -2. Add `createTrueFoundryServer({ apiKey, baseUrl, saveAgent, getModels?, getSkills?, getMcp? })`: - - constructs `AgentSessionClient` + `PrivateAgentSessionClient` - - implements chat methods as thin wraps - - implements builder methods via host callbacks (`ModelEntry[]` / `AgentSkill[]` / `ConnectorState[]`) - - exposes SDK-internal handles for the runtime, e.g. - `getGatewayClients(): { client, privateClient }` - (not part of the public BYO contract) -3. Export from the package entry. - -**Done when:** types + factory exist; unit test that the factory returns both -clients and delegates `createSession` / `createDraftSession`. - -### Phase 2 — Wire `server` into UI shell (named) - -1. Extend `TrueFoundryChatProvider` / `TrueForgeUI`: - - prefer `server: AgentUIServer` - - keep legacy `client` or `apiKey` + `baseUrl` temporarily -2. When `server` is TFY-composed: resolve gateway clients → pass into existing - `useTrueFoundryAgentRuntime({ client, privateClient?, agentName | agent })`. -3. Builder methods unused on the named-only path. - -**Done when:** named agent works with -`server={createTrueFoundryServer(...)}` (same UX as today). - -**Verify:** provider tests with a mock/TFY server that still supplies -gateway-shaped clients for the runtime. - -### Phase 3 — Draft mode + builder port usage - -1. Support `agentConfig` modes (`SingleAgent` | `AgentLibrary` | - `AgentComposer` | `AgentLibraryWithComposer`). -2. Draft → runtime `agent: { mode: "draft", … }` + `privateClient` from TFY - server handles. -3. Use builder port: - - read-only lists from `getModels` / `getSkills` / `getMcp` - - apply selection → `updateDraftSession` / runtime - `useTrueFoundryUpdateAgentSpec` (already re-exported where available) - - Save → `saveAgent({ agentName, agentSpec, draftSessionId? })` -4. Builder surface: hooks-first or minimal chrome (confirm before build) — - still only in this SDK. - -**Done when:** draft chat runs against inline spec; save calls host -`saveAgent` once. - -**Verify:** example or tests with stub catalogs + stub `saveAgent`. - -### Phase 4 — Docs + BYO guidance - -1. Gateway path: `createTrueFoundryServer` + shell props. -2. BYO path (honest): - - implement `AgentBuilderServer` fully - - for chat: use TFY clients **or** provide `AgentChatServer` **plus** - whatever the shell still needs to feed the runtime (document the gap: - runtime is not abstracted) -3. Optional: in-memory stub for builder methods only. - -**Done when:** README / this doc show both paths. - -## Usage sketch (gateway-direct) +## Built-in TrueForge server ```tsx -const server = createTrueFoundryServer({ - apiKey: process.env.TFY_API_KEY!, - baseUrl: process.env.TFY_GATEWAY_URL!, - saveAgent: async ({ agentName, agentSpec }) => { - // platform/BFF — promote draft AgentSpec → named agent - return fetch('/api/agents', { - method: 'POST', - body: JSON.stringify({ name: agentName, spec: agentSpec }), - }).then(r => r.json()); - }, - getModels: async () => [], - getSkills: async () => [], - getMcp: async () => [], - searchAgents: async () => [], -}); - ; +/> ``` -## BYO completeness checklist - -### Catalog — complete for this UI - -| UI call | Required response fields | -| --------------------------- | ---------------------------------------------------------- | -| `getModels()` | `name`, `provider`, `apiModel`, `modelId` | -| `getSkills()` | `id`, `name`; `fqn?`, `description?` | -| `getMcp()` | `id`, `name`; `description?` | -| `searchAgents(req?)` | `name`; optional display fields on `AgentLibraryEntry` | -| `saveAgent` / `deleteAgent` | On port; **not called by UI yet** (optional until Save UI) | - -### Chat — method list complete; standalone BYO is not - -1. This UI does **not** call `server.*` chat methods. `TrueFoundryChatProvider` - resolves `client` / `privateClient` for `useTrueFoundryAgentRuntime`. -2. Session returns are **behavioral** (methods), not plain JSON DTOs alone. -3. Stream/history events are full gateway unions: `TurnStreamData.event` → - `TurnStreamingEvent`; history → `SessionEventItem`. -4. Draft `updateAgentSpec` in the runtime goes through `PrivateAgentSessionClient` - → raw gateway `draftSessions.update`, **not** `server.updateDraftSession`. +`baseUrl` defaults to the current origin. Use `token` for bearer authentication or provide a custom `fetch` for cookie sessions and interceptors. Optional `catalog` and `permissions` ports override the built-in implementations. -**Must work for product chat (via gateway clients / compatible BFF):** -`createSession`, `listSessions`, `getSession`, `createTurn` (stream), -`cancelSession`, `listEvents`, `subscribeToTurn`, plus draft: -`createDraftSession`, `getDraftSession`, `listDraftSessions`, -`updateDraftSession` (port), `downloadSandboxFile` when sandbox downloads are -enabled. +## Custom server -**On the port but unused by this UI today:** -`listOwnedSessions`, non-stream `createTurn`, `deleteSession`, -`saveAgent`, `deleteAgent`. - -### v1 BYO guidance - -- **Builder:** implement `AgentBuilderServer` fully against your APIs (real BYO surface). -- **Chat:** point gateway clients at a gateway-compatible BFF, or use - `createTrueFoundryServer`. Do not claim “replace chat with arbitrary REST” in v1. - -## Usage sketch (bring your own backend) - -Map your APIs into `AgentUIServer`. Chat method **request/response shapes** must -match gateway types (`AgentSession`, `AgentSpec`, `Turn`, …). Builder catalog -returns SDK-minimal `ModelEntry[]` / `AgentSkill[]` / `ConnectorState[]` (extend -via generics); `saveAgent` return is host-defined (`unknown`). +Pass a complete `AgentUIServer` directly; there is no `{ type: 'custom' }` wrapper. ```tsx -import type { - AgentUIServer, - AgentSpec, - AgentSession, - AgentDraftSession, - ModelEntry, - AgentSkill, - ConnectorState, - AgentLibraryEntry, -} from '@truefoundry/trueforge-ui'; -import { - TrueForgeUI, - // TFY path still needs gateway clients for the unchanged runtime: - AgentSessionClient, - PrivateAgentSessionClient, -} from '@truefoundry/trueforge-ui'; - -/** - * Example: your REST/BFF is the source of truth for catalog + save, - * and you adapt session CRUD to our chat port. - * - * v1 note: `@truefoundry/assistant-ui-runtime` still consumes - * AgentSessionClient / PrivateAgentSessionClient. A common pattern is: - * - implement AgentBuilderServer fully against your APIs - * - for chat, either wrap gateway clients OR implement AgentChatServer - * and still pass gateway-shaped clients into the shell for streaming - * (see docs/server.md implication table) - */ -function createMyBackendServer(opts: { - apiBase: string; - getToken: () => string; - /** Gateway clients used by the runtime for turn streaming (TFY-compatible). */ - gateway: { - client: AgentSessionClient; - privateClient: PrivateAgentSessionClient; - }; -}): AgentUIServer { - const headers = () => ({ - Authorization: `Bearer ${opts.getToken()}`, - 'Content-Type': 'application/json', - }); - - const chatFromGateway: Pick< - AgentUIServer, - | 'createSession' - | 'listSessions' - | 'getSession' - | 'createDraftSession' - | 'getDraftSession' - | 'listDraftSessions' - | 'listOwnedSessions' - | 'updateDraftSession' - | 'createTurn' - | 'cancelSession' - | 'listTurns' - | 'getTurn' - | 'listEvents' - | 'subscribeToTurn' - | 'downloadSandboxFile' - > = { - // Thin-wrap gateway for chat (simplest BYO that still streams today). - // Replace these bodies with fetch(opts.apiBase + …) once you map to gateway types. - createSession: req => opts.gateway.client.createSession(req), - listSessions: req => opts.gateway.client.listSessions(req), - getSession: req => opts.gateway.client.getSession(req), - createDraftSession: req => opts.gateway.privateClient.createDraftSession(req), - getDraftSession: req => opts.gateway.privateClient.getDraftSession(req), - listDraftSessions: req => opts.gateway.privateClient.listDraftSessions(req), - listOwnedSessions: req => opts.gateway.privateClient.listOwnedSessions(req), - updateDraftSession: async ({ draftSessionId, agentSpec }) => { - const draft = await opts.gateway.privateClient.getDraftSession({ - draftSessionId, - }); - await draft.update({ agentSpec }); - return draft; - }, - createTurn: async function* (req) { - const session = - (await opts.gateway.client.getSession({ sessionId: req.sessionId }).catch(() => null)) ?? - (await opts.gateway.privateClient.getDraftSession({ - draftSessionId: req.sessionId, - })); - const prepared = session.prepareTurn({ - input: req.input, - previousTurnId: req.previousTurnId, - }); - if (req.stream === false) { - yield await prepared.execute({ - stream: false, - pollIntervalMs: req.pollIntervalMs, - }); - return; - } - yield* prepared.execute({ stream: true }); - }, - cancelSession: async ({ sessionId }) => { - const session = await opts.gateway.client.getSession({ sessionId }); - await session.cancel(); - }, - listTurns: async ({ sessionId, ...page }) => { - const session = await opts.gateway.client.getSession({ sessionId }); - return session.listTurns(page); - }, - getTurn: async ({ sessionId, turnId }) => { - const session = await opts.gateway.client.getSession({ sessionId }); - return session.getTurn({ turnId }); - }, - listEvents: async ({ sessionId, ...page }) => { - const session = await opts.gateway.client.getSession({ sessionId }); - return session.listEvents(page); - }, - subscribeToTurn: async function* ({ sessionId, turnId, afterSequenceNumber }) { - const session = await opts.gateway.client.getSession({ sessionId }); - const turn = await session.getTurn({ turnId }); - yield* turn.stream({ afterSequenceNumber }); - }, - downloadSandboxFile: (sandboxId, req) => opts.gateway.privateClient.downloadSandboxFile(sandboxId, req), - }; - - return { - ...chatFromGateway, - - // --- fully your backend --- - async getModels() { - const res = await fetch(`${opts.apiBase}/catalog/models`, { - headers: headers(), - }); - return res.json() as Promise; - }, - async getSkills() { - const res = await fetch(`${opts.apiBase}/catalog/skills`, { - headers: headers(), - }); - return res.json() as Promise; - }, - async getMcp() { - const res = await fetch(`${opts.apiBase}/catalog/mcp`, { - headers: headers(), - }); - return res.json() as Promise; - }, - async searchAgents(req) { - const params = new URLSearchParams(); - if (req?.query) params.set('namePrefix', req.query); - if (req?.limit != null) params.set('limit', String(req.limit)); - if (req?.offset != null) params.set('offset', String(req.offset)); - const res = await fetch(`${opts.apiBase}/agents?${params}`, { - headers: headers(), - }); - return res.json() as Promise; - }, - async saveAgent({ agentName, agentSpec, draftSessionId }) { - const res = await fetch(`${opts.apiBase}/agents`, { - method: 'POST', - headers: headers(), - body: JSON.stringify({ - name: agentName, - spec: agentSpec, - draftSessionId, - }), - }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); - }, - }; -} +import { TrueForgeUI, type AgentUIServer } from '@truefoundry/trueforge-ui'; -// App -const gatewayClient = new AgentSessionClient({ - apiKey: process.env.TFY_API_KEY!, - baseUrl: process.env.TFY_GATEWAY_URL!, -}); -const gatewayPrivate = new PrivateAgentSessionClient({ - apiKey: process.env.TFY_API_KEY!, - baseUrl: process.env.TFY_GATEWAY_URL!, -}); - -const server = createMyBackendServer({ - apiBase: 'https://api.mycompany.com/v1', - getToken: () => process.env.MY_API_TOKEN!, - gateway: { client: gatewayClient, privateClient: gatewayPrivate }, -}); +const server: AgentUIServer = { + // Implement chat and builder ports. +}; -export function App() { - return ( - - ); -} +; ``` -**Pure custom chat (no gateway):** implement every `AgentChatServer` method so -return values satisfy gateway types (`AgentSession`, `Page<…>`, -`AsyncIterable`, …). Until the runtime accepts `AgentChatServer` -directly, the shell still needs a path into `useTrueFoundryAgentRuntime` — -document that gap for hosts; v1 BYO strength is **builder + branding**, with -chat either on gateway or a compatible adapter. - -## Non-goals (v1) - -- Any PR to `@truefoundry/assistant-ui-runtime` -- Full BYO chat streaming without gateway-shaped clients -- Typed `saveAgent` response (stays `unknown` / host-defined) -- Multi-agent -- Publish flow beyond `saveAgent` -- Removing `tfy-web-components` / theming overhaul (separate track) - -## PR sequence (all in this repo) - -1. Types + `createTrueFoundryServer` + tests -2. `server` prop on provider / shell (named) -3. Draft `agentConfig` + builder / save wiring -4. Docs + examples (gateway + BYO stub for builder) - -## Open before Phase 3 +The canonical contracts live in `@truefoundry/assistant-ui-runtime/server` and are re-exported by `@truefoundry/trueforge-ui`. Hosts should import them from the UI package. -- Builder surface: **hooks-only** vs **in-package minimal UI** +Use `createTrueForgeServer` to compose separately implemented chat, builder, catalog, session, metrics, schedule, and permissions ports into one `AgentUIServer`. diff --git a/packages/trueforge-ui/package.json b/packages/trueforge-ui/package.json index 34c8ea69b..4e1e47461 100644 --- a/packages/trueforge-ui/package.json +++ b/packages/trueforge-ui/package.json @@ -57,16 +57,18 @@ "src/icons/registerAgentIcons.ts" ], "scripts": { - "prebuild": "pnpm -w run sdk:types", + "runtime:build": "pnpm --filter @truefoundry/assistant-ui-runtime build", + "prebuild": "pnpm run runtime:build && pnpm -w run sdk:types", "build": "node ../../scripts/rm-path.mjs dist && tailwindcss --input src/build-styles.css --output dist/styles.css --minify && tsup", "dev:watch": "pnpm run \"/^(dev:watch:js|dev:watch:css)$/\"", "dev:watch:js": "tsup --watch", "dev:watch:css": "tailwindcss --input src/build-styles.css --output dist/styles.css --watch", "pack:tgz": "pnpm build && pnpm pack", + "pretest": "pnpm run runtime:build", "test": "vitest run && pnpm test:package", "test:package": "pnpm build && node test/packageExports.smoke.mjs", "test:coverage": "vitest run --coverage", - "pretypecheck": "pnpm -w run sdk:types", + "pretypecheck": "pnpm run runtime:build && pnpm -w run sdk:types", "typecheck": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json", "lint": "eslint src test --ext .ts,.tsx", "format": "prettier --write \"src/**/*.{ts,tsx,css,md}\" \"test/**/*.{ts,tsx}\" \"*.md\" \"docs/**/*.md\" --config ../../.prettierrc" @@ -95,7 +97,7 @@ "@openuidev/react-headless": "^0.9.4", "@openuidev/react-lang": "^0.2.9", "@openuidev/react-ui": "^0.13.2", - "@truefoundry/assistant-ui-runtime": "0.1.43", + "@truefoundry/assistant-ui-runtime": "workspace:*", "@truefoundry/trueforge-sdk": "workspace:*", "chart.js": "^4.5.1", "clsx": "^2.1.1", @@ -110,14 +112,13 @@ "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "thinking-orbs": "^0.3.1", - "truefoundry-gateway-sdk": "^0.4.2", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.39.0", "@tailwindcss/cli": "^4.3.2", "@testing-library/dom": "^10.0.0", - "@testing-library/jest-dom": "^6.6.3", + "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19.2.17", diff --git a/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx b/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx index 2ebef736c..bacbaeb4a 100644 --- a/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx +++ b/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx @@ -1,9 +1,9 @@ 'use client'; import { - useTrueFoundryAdoptAgentSpec, - useTrueFoundryAgentSpec, - useTrueFoundryFlushAgentSpec, + useTrueForgeAdoptAgentSpec, + useTrueForgeAgentSpec, + useTrueForgeFlushAgentSpec, } from '@truefoundry/assistant-ui-runtime'; import { useRef, useState } from 'react'; @@ -57,11 +57,11 @@ function SaveAgentButtonContent({ children: string; instructionsOverride?: string; }) { - const { agentSpec, draftSessionId } = useTrueFoundryAgentSpec(); + const { agentSpec, draftSessionId } = useTrueForgeAgentSpec(); const agentSpecRef = useRef(agentSpec); agentSpecRef.current = agentSpec; - const flushAgentSpec = useTrueFoundryFlushAgentSpec(); - const adoptAgentSpec = useTrueFoundryAdoptAgentSpec(); + const flushAgentSpec = useTrueForgeFlushAgentSpec(); + const adoptAgentSpec = useTrueForgeAdoptAgentSpec(); const builder = useOptionalServer(); const shell = useOptionalShellMode(); const agentId = shell?.mode.status === 'active' ? shell.mode.agentId : undefined; diff --git a/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx b/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx index c1bd7408a..ee7636fc1 100644 --- a/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx +++ b/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryAgentSpec, useTrueFoundryUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'; import { useDebouncedAgentInstructions } from '../../hooks/useDebouncedAgentInstructions.js'; @@ -14,8 +14,8 @@ type AgentConfigInstructionsContextValue = { const AgentConfigInstructionsContext = createContext(null); export function AgentConfigInstructionsProvider({ children }: { children: ReactNode }) { - const { agentSpec } = useTrueFoundryAgentSpec(); - const updateAgentSpec = useTrueFoundryUpdateAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); + const updateAgentSpec = useTrueForgeUpdateAgentSpec(); const commit = useCallback((instructions: string) => updateAgentSpec?.({ instructions }), [updateAgentSpec]); const { draft, onChange, flush } = useDebouncedAgentInstructions({ value: agentSpec?.instructions ?? '', diff --git a/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx b/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx index 6fabdf6c3..c31154225 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryAgentSpec, useTrueFoundryUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import { useMCPAuth } from '../../hooks/useMcpAuth.js'; @@ -248,8 +248,8 @@ export function DraftCompositeSelector({ disabled, isRunning, onAttach }: DraftC const capabilities = useServerCapabilities(); const settingsCatalog = useOptionalCatalogServer(); const shell = useOptionalShellMode(); - const { agentSpec } = useTrueFoundryAgentSpec(); - const updateAgentSpec = useTrueFoundryUpdateAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); + const updateAgentSpec = useTrueForgeUpdateAgentSpec(); const [open, setOpen] = useState(false); const [tab, setTab] = useState('connectors'); const [query, setQuery] = useState(''); diff --git a/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx b/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx index a7c46ac7f..c5431d09f 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryAgentSpec, useTrueFoundryUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; import { useEffect, useId, useRef, useState } from 'react'; import { Icon } from '../../icons/Icon.js'; @@ -22,8 +22,8 @@ export type DraftModelSelectorProps = { export function DraftModelSelector({ disabled, isRunning }: DraftModelSelectorProps) { const { models, loading, ensureLoaded } = useDraftCatalog(); - const { agentSpec } = useTrueFoundryAgentSpec(); - const updateAgentSpec = useTrueFoundryUpdateAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); + const updateAgentSpec = useTrueForgeUpdateAgentSpec(); const catalog = useOptionalCatalogServer(); const shell = useOptionalShellMode(); const [open, setOpen] = useState(false); diff --git a/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx b/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx index 6e61df456..58a02edf8 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryAgentSpec, useTrueFoundryUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; import { useEffect, useId, useRef, useState } from 'react'; import { Icon } from '../../icons/Icon.js'; @@ -19,8 +19,8 @@ export type DraftReasoningEffortSelectorProps = { export function DraftReasoningEffortSelector({ disabled, isRunning }: DraftReasoningEffortSelectorProps) { const { models, ensureLoaded } = useDraftCatalog(); - const { agentSpec } = useTrueFoundryAgentSpec(); - const updateAgentSpec = useTrueFoundryUpdateAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); + const updateAgentSpec = useTrueForgeUpdateAgentSpec(); const [open, setOpen] = useState(false); const containerRef = useRef(null); const clearedStickyEffortForModelRef = useRef(null); diff --git a/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx b/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx index b5f9a7672..8cdbf5c3e 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryAgentSpec, useTrueFoundryUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; import { useEffect } from 'react'; import { @@ -135,8 +135,8 @@ export function reconcileDraftWebSearch({ */ export function DraftSpecPreferenceBridge() { const { mode, pendingSessionId, rememberDraftSpec } = useShellMode(); - const { agentSpec } = useTrueFoundryAgentSpec(); - const updateAgentSpec = useTrueFoundryUpdateAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); + const updateAgentSpec = useTrueForgeUpdateAgentSpec(); const capabilities = useServerCapabilities(); const sandboxEnabled = capabilities?.sandbox.enabled; const webSearchEnabled = capabilities?.webSearch?.enabled; diff --git a/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx b/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx index 668cdb46b..1a3b525ef 100644 --- a/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx +++ b/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx @@ -1,9 +1,9 @@ 'use client'; import { - useTrueFoundryAgentSpec, - useTrueFoundryFlushAgentSpec, - useTrueFoundryUpdateAgentSpec, + useTrueForgeAgentSpec, + useTrueForgeFlushAgentSpec, + useTrueForgeUpdateAgentSpec, } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useEffect, useState } from 'react'; @@ -19,9 +19,9 @@ import type { AgentSpec, ConnectorState, McpToolSelection } from '../server/type import { useSlot } from '../theme/SlotsProvider.js'; export function AgentConfigDrawerContainer({ showClose = false }: { showClose?: boolean }) { - const { agentSpec } = useTrueFoundryAgentSpec(); - const updateAgentSpec = useTrueFoundryUpdateAgentSpec(); - const flushAgentSpec = useTrueFoundryFlushAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); + const updateAgentSpec = useTrueForgeUpdateAgentSpec(); + const flushAgentSpec = useTrueForgeFlushAgentSpec(); const shell = useShellMode(); const agentId = shell.mode.status === 'active' ? shell.mode.agentId : undefined; const { allows } = useResourcePermissions({ diff --git a/packages/trueforge-ui/src/containers/AskUserContainer.tsx b/packages/trueforge-ui/src/containers/AskUserContainer.tsx index eaa41b6f4..c98199a8e 100644 --- a/packages/trueforge-ui/src/containers/AskUserContainer.tsx +++ b/packages/trueforge-ui/src/containers/AskUserContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useThreadIsRunning } from '@assistant-ui/core/react'; -import { useTrueFoundryToolResponses } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeToolResponses } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ASK_USER_CUSTOM_OPTION, @@ -15,7 +15,7 @@ const EMPTY_ANSWER: AskUserAnswerDraft = { radioValue: '', custom: '' }; export function AskUserContainer({ disabled = false }: { disabled?: boolean }) { const AskUserPrompt = useSlot('AskUserPrompt'); - const { pending, respond } = useTrueFoundryToolResponses(); + const { pending, respond } = useTrueForgeToolResponses(); const isRunning = useThreadIsRunning(); const item = pending[0]; diff --git a/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx b/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx index 7b37e64ad..1d5b684df 100644 --- a/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx +++ b/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx @@ -2,7 +2,7 @@ import { useActionBarCopy, useMessageError, useThreadIsRunning, type PartState } from '@assistant-ui/core/react'; import { MessagePrimitive, useAuiState, type EnrichedPartState, type GroupByContext } from '@assistant-ui/react'; -import { useTrueFoundryResumeUnavailable } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeResumeUnavailable } from '@truefoundry/assistant-ui-runtime'; import { useSlot } from '../theme/SlotsProvider.js'; import { computeAgentStepsSplit } from '../utils/computeAgentStepsSplit.js'; @@ -37,7 +37,7 @@ export function AssistantMessageContainer() { const MessageErrorBanner = useSlot('MessageErrorBanner'); const MessageIndicator = useSlot('MessageIndicator'); const isThreadRunning = useThreadIsRunning(); - const resumeUnavailable = useTrueFoundryResumeUnavailable(); + const resumeUnavailable = useTrueForgeResumeUnavailable(); const error = useMessageError(); const createdAt = useAuiState(s => s.message.createdAt); const isMessageRunning = useAuiState(s => s.message.status?.type === 'running'); diff --git a/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx b/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx index 583be9724..29ed8f6ca 100644 --- a/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx +++ b/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx @@ -7,7 +7,7 @@ import { type ReasoningMessagePart, type TextMessagePart, } from '@assistant-ui/react'; -import { useTrueFoundryDownloadSandboxFile } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeDownloadSandboxFile } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useMemo, useRef } from 'react'; import { MARKDOWN_SMOOTH_BACKLOG_CHARS, useThrottledMarkdownText } from '../hooks/useThrottledMarkdownText.js'; @@ -31,7 +31,7 @@ function triggerBrowserDownload(blob: Blob, filename: string) { export function AssistantTextContainer() { const Markdown = useSlot('Markdown'); const toaster = useToasterOptional(); - const downloadSandboxFile = useTrueFoundryDownloadSandboxFile(); + const downloadSandboxFile = useTrueForgeDownloadSandboxFile(); // The download closure changes as the turn streams; a ref keeps onDownloadArtifact stable so // OpenUI does not remount. const downloadRef = useRef({ downloadSandboxFile, toaster }); diff --git a/packages/trueforge-ui/src/containers/ComposerContainer.tsx b/packages/trueforge-ui/src/containers/ComposerContainer.tsx index 437664e20..71088cdaf 100644 --- a/packages/trueforge-ui/src/containers/ComposerContainer.tsx +++ b/packages/trueforge-ui/src/containers/ComposerContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'; -import { useTrueFoundryAgentSpec, useTrueFoundryCancel } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeCancel } from '@truefoundry/assistant-ui-runtime'; import { useRef } from 'react'; import { DraftCatalogProvider } from '../atoms/draft/DraftCatalogProvider.js'; @@ -51,13 +51,13 @@ function ComposerBody({ const shell = useOptionalShellMode(); const hasText = useAuiState(s => s.composer.text.trim().length > 0); const hasAttachments = useAuiState(s => s.composer.attachments.length > 0); - const { agentSpec } = useTrueFoundryAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); // Named (immutable) agents use a server-side model; only draft/mutable composers pick one here. const requiresModel = shell == null || (shell.mode.status === 'active' && shell.mode.isMutable); const hasModel = Boolean(agentSpec?.model?.name?.trim()); const { isBusy, send, resetBusy } = useComposerBusyState(); const canManageSession = useActiveSessionCanManage(); - const cancel = useTrueFoundryCancel(); + const cancel = useTrueForgeCancel(); const fileInputRef = useRef(null); const disabled = isBusy || forceDisabled || !canManageSession; const canSubmit = canSubmitComposer({ disabled, hasText, hasAttachments, requiresModel, hasModel }); diff --git a/packages/trueforge-ui/src/containers/CustomActionContainer.tsx b/packages/trueforge-ui/src/containers/CustomActionContainer.tsx index a243324ec..26e84da20 100644 --- a/packages/trueforge-ui/src/containers/CustomActionContainer.tsx +++ b/packages/trueforge-ui/src/containers/CustomActionContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useThreadIsRunning } from '@assistant-ui/core/react'; -import { useTrueFoundryToolResponses } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeToolResponses } from '@truefoundry/assistant-ui-runtime'; import { useCallback } from 'react'; import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderersContext.js'; @@ -9,7 +9,7 @@ import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderer /** Host custom-action UI for a pending client-side tool response. */ export function CustomActionContainer({ disabled = false }: { disabled?: boolean }) { const renderers = useOptionalCustomActionRenderers(); - const { pending, respond } = useTrueFoundryToolResponses(); + const { pending, respond } = useTrueForgeToolResponses(); const isRunning = useThreadIsRunning(); const item = pending[0]; diff --git a/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx b/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx index 11d95a48a..64e937b30 100644 --- a/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx +++ b/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryHistoryPagination } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeHistoryPagination } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useSlot } from '../theme/SlotsProvider.js'; @@ -20,7 +20,7 @@ const VIEWPORT_SELECTOR = '[data-slot="aui_thread-viewport"]'; */ export function HistoryLoaderContainer() { const HistoryLoader = useSlot('HistoryLoader'); - const { hasOlderHistory, isLoadingOlderHistory, loadOlderHistory } = useTrueFoundryHistoryPagination(); + const { hasOlderHistory, isLoadingOlderHistory, loadOlderHistory } = useTrueForgeHistoryPagination(); const [sentinel, setSentinel] = useState(null); const inflightRef = useRef(false); diff --git a/packages/trueforge-ui/src/containers/McpAuthContainer.tsx b/packages/trueforge-ui/src/containers/McpAuthContainer.tsx index 0ea6bc7c6..7d8bb2965 100644 --- a/packages/trueforge-ui/src/containers/McpAuthContainer.tsx +++ b/packages/trueforge-ui/src/containers/McpAuthContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useThreadIsRunning } from '@assistant-ui/core/react'; -import { useTrueFoundryMcpAuth } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeMcpAuth } from '@truefoundry/assistant-ui-runtime'; import { useDraftCatalog } from '@/atoms/draft/DraftCatalogProvider.js'; import { useMCPAuth } from '@/hooks/useMcpAuth.js'; @@ -9,7 +9,7 @@ import { useOptionalCatalogServer } from '@/server/ServerContext.js'; import { useSlot } from '../theme/SlotsProvider.js'; type McpAuthPromptProps = { - servers: NonNullable['pending']>['mcpServers']; + servers: NonNullable['pending']>['mcpServers']; onContinue: () => void; readOnly: boolean; }; @@ -32,7 +32,7 @@ function CatalogMcpAuthPrompt({ servers, onContinue, readOnly }: McpAuthPromptPr export function McpAuthContainer({ disabled = false }: { disabled?: boolean }) { const McpAuthPrompt = useSlot('McpAuthPrompt'); - const { pending, resume } = useTrueFoundryMcpAuth(); + const { pending, resume } = useTrueForgeMcpAuth(); const isRunning = useThreadIsRunning(); const catalog = useOptionalCatalogServer(); diff --git a/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx b/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx index eac85c918..4d5f88bd9 100644 --- a/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx +++ b/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useTrueFoundryResumeUnavailable } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeResumeUnavailable } from '@truefoundry/assistant-ui-runtime'; import { useSlot } from '../theme/SlotsProvider.js'; export function ResumeUnavailableContainer() { const ResumeUnavailable = useSlot('ResumeUnavailable'); - const resumeUnavailable = useTrueFoundryResumeUnavailable(); + const resumeUnavailable = useTrueForgeResumeUnavailable(); if (!resumeUnavailable) return null; diff --git a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx index 0a5caf749..f1343f1b2 100644 --- a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx +++ b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx @@ -8,7 +8,7 @@ import { type ToolCallMessagePartComponent, type ToolCallMessagePartProps, } from '@assistant-ui/react'; -import { useTrueFoundryRespondToToolApproval } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeRespondToToolApproval } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useState } from 'react'; import { useSlot } from '../theme/SlotsProvider.js'; @@ -61,7 +61,7 @@ function NestedSubAgentAssistantMessage() { function ToolApprovalSlot({ part }: { part: ToolCallMessagePartProps }) { const isNestedReadonly = useNestedApprovalBridge(); - const respondToNestedApproval = useTrueFoundryRespondToToolApproval(); + const respondToNestedApproval = useTrueForgeRespondToToolApproval(); const respond = (response: ToolApprovalResponse) => { if (!isNestedReadonly) { diff --git a/packages/trueforge-ui/src/containers/TrueFoundryChatProvider.tsx b/packages/trueforge-ui/src/containers/TrueForgeChatProvider.tsx similarity index 87% rename from packages/trueforge-ui/src/containers/TrueFoundryChatProvider.tsx rename to packages/trueforge-ui/src/containers/TrueForgeChatProvider.tsx index 10394c7fb..8e4bd2a90 100644 --- a/packages/trueforge-ui/src/containers/TrueFoundryChatProvider.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeChatProvider.tsx @@ -2,10 +2,10 @@ import { AssistantRuntimeProvider, useAuiState } from '@assistant-ui/react'; import { - trueFoundryAttachmentAdapter, - useTrueFoundryAgentRuntime, - type TrueFoundryAgentConfig, - type UseTrueFoundryAgentRuntimeOptions, + trueForgeAttachmentAdapter, + useTrueForgeAgentRuntime, + type TrueForgeAgentConfig, + type UseTrueForgeAgentRuntimeOptions, } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useMemo, type ReactNode } from 'react'; @@ -15,7 +15,7 @@ import { ActiveSessionPermissionsProvider } from '../hooks/useResourcePermission import type { AgentUIServer } from '../server/types.js'; import { ToasterProvider, useToaster } from './ToasterContainer.js'; -type RuntimeAdapters = NonNullable; +type RuntimeAdapters = NonNullable; function ActiveSessionPermissionScope({ locallyCreatedSessionIds, @@ -59,14 +59,14 @@ function runtimeServer({ }; } -export type TrueFoundryChatProviderProps = { +export type TrueForgeChatProviderProps = { server: AgentUIServer; initialSessionId?: string; adapters?: RuntimeAdapters; onError?: (error: unknown) => void; children: ReactNode; /** Discriminated agent source. Prefer over legacy `agentName`. */ - agent?: TrueFoundryAgentConfig; + agent?: TrueForgeAgentConfig; /** Legacy named-agent shorthand. Prefer `agent: { mode: "named", agentName }`. */ agentName?: string; /** Forwarded to `listSessions({ agentId })` for history filtering. */ @@ -84,7 +84,7 @@ function ChatRuntimeScope({ children, }: { server: AgentUIServer; - agent?: TrueFoundryAgentConfig; + agent?: TrueForgeAgentConfig; agentName?: string; listSessionsAgentId?: string; initialSessionId?: string; @@ -110,7 +110,7 @@ function ChatRuntimeScope({ [reportError], ); - const runtime = useTrueFoundryAgentRuntime({ + const runtime = useTrueForgeAgentRuntime({ server: historyServer as never, agent, agentName, @@ -120,7 +120,7 @@ function ChatRuntimeScope({ onError: resolvedOnError, adapters: { ...adapters, - attachments: adapters?.attachments ?? trueFoundryAttachmentAdapter, + attachments: adapters?.attachments ?? trueForgeAttachmentAdapter, }, }); @@ -134,10 +134,10 @@ function ChatRuntimeScope({ } /** - * Chat shell: wires `useTrueFoundryAgentRuntime` from `server` and provides + * Chat shell: wires `useTrueForgeAgentRuntime` from `server` and provides * assistant-ui + error toasts. */ -export function TrueFoundryChatProvider(props: TrueFoundryChatProviderProps) { +export function TrueForgeChatProvider(props: TrueForgeChatProviderProps) { const { server, initialSessionId, adapters, onError, children, agent, agentName, listSessionsAgentId } = props; const stableServer = useMemo(() => server, [server]); diff --git a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx index ca8309399..0be2c246e 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { TrueFoundryAgentConfig, UseTrueFoundryAgentRuntimeOptions } from '@truefoundry/assistant-ui-runtime'; +import type { TrueForgeAgentConfig, UseTrueForgeAgentRuntimeOptions } from '@truefoundry/assistant-ui-runtime'; import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { ThinkingOrb } from 'thinking-orbs'; @@ -26,7 +26,7 @@ import type { AgentUIServer, CreateSessionRequest } from '../server/types.js'; import { SlotsProvider, useThemeMode, type SlotOverrides } from '../theme/SlotsProvider.js'; import type { LayoutProp, ThemeConfig } from '../theme/types.js'; import { getErrorMessage } from '../utils/getErrorMessage.js'; -import { TrueFoundryChatProvider, type TrueFoundryChatProviderProps } from './TrueFoundryChatProvider.js'; +import { TrueForgeChatProvider, type TrueForgeChatProviderProps } from './TrueForgeChatProvider.js'; import { useResolvedServer } from './useResolvedServer.js'; const SidebarLayout = lazy(() => import('../layouts/SidebarLayout.js').then(m => ({ default: m.SidebarLayout }))); @@ -37,7 +37,7 @@ const ShellRouteSync = lazy(() => import('../routing/ShellRouteSync.js').then(m export type ChatLayout = 'sidebar' | 'drawer' | 'dock' | 'widget'; -type RuntimeAdapters = NonNullable; +type RuntimeAdapters = NonNullable; export type TrueForgeUIProps = { server: TrueForgeServerConfig; @@ -92,7 +92,7 @@ function LayoutFallback({ className }: { className?: string }) { ); } -/** Shown while `type: "truefoundry"` resolves the agent UI server. */ +/** Shown while a built-in agent UI server resolves. */ export function ServerInitLoader({ className }: { className?: string }) { // Outside ThemeProvider (Suspense), useThemeMode falls back to light. const themeMode = useThemeMode(); @@ -174,7 +174,7 @@ function ChatProviderFromShell({ children: ReactNode; /** When routing, reports the active thread's remote id up to `ShellRouteSync`. */ onRemoteIdChange?: (id: string | undefined) => void; -} & Omit) { +} & Omit) { const { mode, runtimeKey, historyAgentFilter, listSessionsAgentId, pendingSessionId } = useShellMode(); const isCreateAgent = mode.status === 'active' && mode.isMutable && mode.isCreateAgent; @@ -221,7 +221,7 @@ function ChatProviderFromShell({ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on runtimeKey only }, [runtimeKey]); - const agent: TrueFoundryAgentConfig = useMemo(() => { + const agent: TrueForgeAgentConfig = useMemo(() => { // idle uses a placeholder draft so layout chrome (useAui) still mounts; // layouts render an empty CTA instead of the thread. if (mode.status === 'idle') { @@ -250,7 +250,7 @@ function ChatProviderFromShell({ return ( - : null} {children} - + ); } diff --git a/packages/trueforge-ui/src/containers/nestedApprovalBridge.ts b/packages/trueforge-ui/src/containers/nestedApprovalBridge.ts index bed8e9179..223bb439e 100644 --- a/packages/trueforge-ui/src/containers/nestedApprovalBridge.ts +++ b/packages/trueforge-ui/src/containers/nestedApprovalBridge.ts @@ -6,7 +6,7 @@ import { createContext, useContext } from 'react'; * Sub-agent tool calls render inside a read-only nested thread * (`MessagePartPrimitive.Messages`), so `part.respondToApproval` throws. * When this flag is set, `ToolApprovalSlot` routes Allow/Deny through - * `useTrueFoundryRespondToToolApproval()` using the *nested* tool's + * `useTrueForgeRespondToToolApproval()` using the *nested* tool's * approval id (not the outer `create_sub_agent` part). */ export const NestedApprovalBridgeContext = createContext(false); diff --git a/packages/trueforge-ui/src/containers/useResolvedServer.ts b/packages/trueforge-ui/src/containers/useResolvedServer.ts index dba8ec89b..870d16022 100644 --- a/packages/trueforge-ui/src/containers/useResolvedServer.ts +++ b/packages/trueforge-ui/src/containers/useResolvedServer.ts @@ -1,6 +1,5 @@ 'use client'; -import { createTrueFoundryAgentUIServer } from '@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter'; import { useEffect, useState } from 'react'; import type { TrueForgeBuiltInServerConfig, TrueForgeServerConfig } from '../server/TrueForgeServerConfig.js'; @@ -27,12 +26,7 @@ const DEFAULT_CAPABILITIES = { } satisfies AgentBuilderCapabilitiesResponse; function isBuiltInConfig(config: TrueForgeServerConfig): config is TrueForgeBuiltInServerConfig { - return ( - typeof config === 'object' && - config !== null && - 'type' in config && - (config.type === 'truefoundry' || config.type === 'trueforge') - ); + return typeof config === 'object' && config !== null && 'type' in config && config.type === 'trueforge'; } function hasGetCapabilities( @@ -87,7 +81,6 @@ function isAgentUIServer(value: object): value is AgentUIServer { /** * Resolves a {@link TrueForgeServerConfig} to an {@link AgentUIServer}. * - `AgentUIServer` — sync passthrough - * - `truefoundry` — async via `createTrueFoundryAgentUIServer` * - `trueforge` — via dynamic import of the Harness plugin adapter */ export function useResolvedServer( @@ -96,13 +89,9 @@ export function useResolvedServer( ): ResolvedServerState { const builtIn = isBuiltInConfig(config) ? config : null; const directServer: AgentUIServer | null = isBuiltInConfig(config) ? null : config; - const type = builtIn?.type; - const apiKey = builtIn?.type === 'truefoundry' ? builtIn.apiKey : ''; - const controlPlaneURL = builtIn?.type === 'truefoundry' ? builtIn.controlPlaneURL : ''; - const gatewayPlaneURL = builtIn?.type === 'truefoundry' ? (builtIn.gatewayPlaneURL ?? '') : ''; - const trueforgeBaseUrl = builtIn?.type === 'trueforge' ? (builtIn.baseUrl ?? '') : ''; - const trueforgeToken = builtIn?.type === 'trueforge' ? (builtIn.token ?? '') : ''; - const trueforgeFetch = builtIn?.type === 'trueforge' ? builtIn.fetch : undefined; + const trueforgeBaseUrl = builtIn?.baseUrl ?? ''; + const trueforgeToken = builtIn?.token ?? ''; + const trueforgeFetch = builtIn?.fetch; const catalog = builtIn?.catalog; const permissions = builtIn?.permissions; @@ -123,34 +112,23 @@ export function useResolvedServer( setState({ status: 'loading', server: null, error: null }); const resolve = async (): Promise => { - if (type === 'trueforge') { - const { createTrueForgeAgentUIServer } = await import('../plugins/trueforge-agent-server-adapter/index.js'); - return createTrueForgeAgentUIServer({ - ...(trueforgeBaseUrl ? { baseUrl: trueforgeBaseUrl } : {}), - ...(trueforgeToken ? { token: trueforgeToken } : {}), - ...(trueforgeFetch !== undefined ? { fetch: trueforgeFetch } : {}), - ...(catalog != null ? { catalog } : {}), - ...(permissions != null ? { permissions } : {}), - }); - } - - const runtimeServer = await createTrueFoundryAgentUIServer({ - apiKey, - cpURL: controlPlaneURL, - ...(gatewayPlaneURL ? { gatewayURL: gatewayPlaneURL } : {}), + const { createTrueForgeAgentUIServer } = await import('../plugins/trueforge-agent-server-adapter/index.js'); + return createTrueForgeAgentUIServer({ + ...(trueforgeBaseUrl ? { baseUrl: trueforgeBaseUrl } : {}), + ...(trueforgeToken ? { token: trueforgeToken } : {}), + ...(trueforgeFetch !== undefined ? { fetch: trueforgeFetch } : {}), + ...(catalog != null ? { catalog } : {}), + ...(permissions != null ? { permissions } : {}), }); - // TrueFoundry adapter may omit catalog; attach host-supplied catalog here. - return toAgentUIServer(runtimeServer, catalog, permissions); }; void resolve() .then(server => { if (cancelled) return; try { - // trueforge factory already includes catalog; still normalize capabilities. setState({ status: 'ready', - server: type === 'trueforge' ? toAgentUIServer(server, undefined, undefined) : server, + server: toAgentUIServer(server, undefined, undefined), error: null, }); } catch (error: unknown) { @@ -167,19 +145,7 @@ export function useResolvedServer( return () => { cancelled = true; }; - }, [ - type, - directServer, - apiKey, - controlPlaneURL, - gatewayPlaneURL, - trueforgeBaseUrl, - trueforgeToken, - trueforgeFetch, - catalog, - permissions, - onError, - ]); + }, [directServer, trueforgeBaseUrl, trueforgeToken, trueforgeFetch, catalog, permissions, onError]); return state; } diff --git a/packages/trueforge-ui/src/hooks/useApprovalNav.ts b/packages/trueforge-ui/src/hooks/useApprovalNav.ts index 7d4c2cf20..752906416 100644 --- a/packages/trueforge-ui/src/hooks/useApprovalNav.ts +++ b/packages/trueforge-ui/src/hooks/useApprovalNav.ts @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryApprovals } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeApprovals } from '@truefoundry/assistant-ui-runtime'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useOptionalApprovalFocus } from '../containers/approvalFocus.js'; @@ -23,7 +23,7 @@ export type ApprovalNavState = { * index so the next pending item fills the slot; chevrons do not wrap. */ export function useApprovalNav(): ApprovalNavState { - const { pending } = useTrueFoundryApprovals(); + const { pending } = useTrueForgeApprovals(); const focusApi = useOptionalApprovalFocus(); const [selection, setSelection] = useState<{ approvalId: string | null; index: number }>({ approvalId: null, diff --git a/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts b/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts index 5cf671f21..acd635c6e 100644 --- a/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts +++ b/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts @@ -1,6 +1,6 @@ 'use client'; -import { useTrueFoundryAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec } from '@truefoundry/assistant-ui-runtime'; import { useAuiState } from '../assistant-ui.js'; import { useOptionalShellMode } from '../server/ShellModeContext.js'; @@ -45,7 +45,7 @@ export function useNamedAgentHeaderVisible(): boolean { // Mutable New Agent / Edit with a selected model — drives Save Agent + header chrome. export function useSaveAgentVisible(): boolean { const shell = useOptionalShellMode(); - const { agentSpec } = useTrueFoundryAgentSpec(); + const { agentSpec } = useTrueForgeAgentSpec(); if (shell == null || shell.mode.status !== 'active' || !shell.mode.isMutable || !shell.mode.isCreateAgent) { return false; } diff --git a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts index 89bb35dd1..9f23efc80 100644 --- a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts +++ b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts @@ -1,7 +1,7 @@ 'use client'; import { useAuiState } from '@assistant-ui/react'; -import { useTrueFoundryApprovals, useTrueFoundryToolResponses } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeApprovals, useTrueForgeToolResponses } from '@truefoundry/assistant-ui-runtime'; import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderersContext.js'; @@ -34,8 +34,8 @@ export type ComposerPauseView = /** Shared composer pause detection for default and custom composer containers. */ export function useComposerPauseView(): ComposerPauseView { const mcpPending = useAuiState(threadHasPendingMcpAuth); - const { pending: toolResponsesPending } = useTrueFoundryToolResponses(); - const { pending: approvalsPending } = useTrueFoundryApprovals(); + const { pending: toolResponsesPending } = useTrueForgeToolResponses(); + const { pending: approvalsPending } = useTrueForgeApprovals(); const customActionRenderers = useOptionalCustomActionRenderers(); if (mcpPending) { diff --git a/packages/trueforge-ui/src/index.ts b/packages/trueforge-ui/src/index.ts index 39b9294ad..77f6ac8ee 100644 --- a/packages/trueforge-ui/src/index.ts +++ b/packages/trueforge-ui/src/index.ts @@ -227,6 +227,8 @@ export { ToolCallContainer } from './containers/ToolCallContainer.js'; export { ToolCallContentBlockContainer } from './containers/ToolCallContentBlockContainer.js'; export { ToolGroupContainer } from './containers/ToolGroupContainer.js'; export type { ThreadGroupPart } from './containers/ToolGroupContainer.js'; +export { TrueForgeChatProvider } from './containers/TrueForgeChatProvider.js'; +export type { TrueForgeChatProviderProps } from './containers/TrueForgeChatProvider.js'; export { TrueForgeUI } from './containers/TrueForgeUI.js'; export type { ChatLayout, @@ -236,8 +238,6 @@ export type { TrueForgeServerConfig, TrueForgeUIProps, } from './containers/TrueForgeUI.js'; -export { TrueFoundryChatProvider } from './containers/TrueFoundryChatProvider.js'; -export type { TrueFoundryChatProviderProps } from './containers/TrueFoundryChatProvider.js'; export { UserEditComposerContainer } from './containers/UserEditComposerContainer.js'; export { UserMessageContainer } from './containers/UserMessageContainer.js'; export { useApprovalNav } from './hooks/useApprovalNav.js'; @@ -263,24 +263,24 @@ export { useTheme } from './theme/useTheme.js'; // Runtime / server — consumer surface. export { mergeAgentSpec, - trueFoundryAttachmentAdapter, - useTrueFoundryAgentRuntime, - useTrueFoundryAgentSpec, - useTrueFoundryApprovals, - useTrueFoundryCancel, - useTrueFoundryDownloadSandboxFile, - useTrueFoundryHistoryPagination, - useTrueFoundryMcpAuth, - useTrueFoundryRespondToToolApproval, - useTrueFoundryToolResponses, - useTrueFoundryTurnId, - useTrueFoundryUpdateAgentSpec, + trueForgeAttachmentAdapter, + useTrueForgeAgentRuntime, + useTrueForgeAgentSpec, + useTrueForgeApprovals, + useTrueForgeCancel, + useTrueForgeDownloadSandboxFile, + useTrueForgeHistoryPagination, + useTrueForgeMcpAuth, + useTrueForgeRespondToToolApproval, + useTrueForgeToolResponses, + useTrueForgeTurnId, + useTrueForgeUpdateAgentSpec, } from '@truefoundry/assistant-ui-runtime'; export type { DraftAgentConfig, NamedAgentConfig, - TrueFoundryAgentConfig, - UseTrueFoundryAgentRuntimeOptions, + TrueForgeAgentConfig, + UseTrueForgeAgentRuntimeOptions, } from '@truefoundry/assistant-ui-runtime'; // Server port types + factory @@ -293,8 +293,8 @@ export { } from './atoms/lib/sessionCreateAgent.js'; export { SelectAgentEmptyState } from './atoms/SelectAgentEmptyState.js'; export { ShellActionsActionSlot } from './atoms/ShellActionsActionSlot.js'; -export { createTrueFoundryServer } from './server/createTrueFoundryServer.js'; -export type { CreateTrueFoundryServerOptions, TrueFoundryServer } from './server/createTrueFoundryServer.js'; +export { createTrueForgeServer } from './server/createTrueForgeServer.js'; +export type { CreateTrueForgeServerOptions, TrueForgeServer } from './server/createTrueForgeServer.js'; export { CustomActionRenderersProvider, useOptionalCustomActionRenderers, diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts index f00a7f7a0..bdc4bfc30 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts @@ -1,5 +1,5 @@ /** - * AgentBuilderServer callbacks for createTrueFoundryServer. + * AgentBuilderServer callbacks for createTrueForgeServer. * Composer pickers + agent library backed by the Harness agents registry. */ import type { TrueForge, TrueForgeApi } from '@truefoundry/trueforge-sdk'; diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/connectorCatalog.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/connectorCatalog.ts index a1e4c1087..8e35f2c10 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/connectorCatalog.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/connectorCatalog.ts @@ -144,7 +144,7 @@ export function toHarnessManifest(req: { }; } -/** Settings connector port for `createTrueFoundryServer`. Delete omitted; disconnect unsupported. */ +/** Settings connector port for `createTrueForgeServer`. Delete omitted; disconnect unsupported. */ export function createConnectorCatalog( client: TrueForge, ): ConnectorCatalogServer< diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts index 2f12863cc..7e2db7ccc 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/modelProviderCatalog.ts @@ -108,7 +108,7 @@ export function toHarnessModelProvider(req: { return { type: req.type, auth, models }; } -/** Settings model-catalog port for `createTrueFoundryServer`. Delete is omitted (no BE route). */ +/** Settings model-catalog port for `createTrueForgeServer`. Delete is omitted (no BE route). */ export function createModelProviderCatalog( client: TrueForge, ): ModelCatalogServer< diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts index dc2da1a1d..1c434208b 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts @@ -109,7 +109,7 @@ export function toHarnessManifest( }; } -/** Settings sandbox-catalog port for `createTrueFoundryServer`. Delete omitted (no BE route). */ +/** Settings sandbox-catalog port for `createTrueForgeServer`. Delete omitted (no BE route). */ export function createSandboxProviderCatalog(client: TrueForge): SandboxCatalogServer { async function resolveApiKey(apiKey: string | undefined): Promise { const trimmed = apiKey?.trim(); diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/skillCatalog.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/skillCatalog.ts index 221ac7554..0991e0a7e 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/skillCatalog.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/skillCatalog.ts @@ -50,7 +50,7 @@ export function toUiSkill(skill: TrueForgeApi.ConfiguredSkill, catalogNames: Rea return base; } -/** Settings skill-catalog port for `createTrueFoundryServer`. Delete omitted (no BE route). */ +/** Settings skill-catalog port for `createTrueForgeServer`. Delete omitted (no BE route). */ export function createSkillCatalog(client: TrueForge): SkillCatalogServer { return { getSkillCatalog: async () => { diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts index fb8cdbd47..d07861aa2 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts @@ -1,7 +1,7 @@ /** * Built-in Harness → AgentUIServer factory for ``. */ -import { createTrueFoundryServer } from '../../server/createTrueFoundryServer.js'; +import { createTrueForgeServer } from '../../server/createTrueForgeServer.js'; import type { CatalogServer, PermissionsServer } from '../../server/types.js'; import { createHarnessAgentMetricsServer } from './agentMetricsServer.js'; import { createHarnessAgentSessionsServer } from './agentSessionsServer.js'; @@ -84,7 +84,7 @@ export function createTrueForgeAgentUIServer(options: CreateTrueForgeAgentUIServ sandboxCatalog: createSandboxProviderCatalog(client), } satisfies CatalogServer); - return createTrueFoundryServer({ + return createTrueForgeServer({ chatServer: createHarnessChatServer({ client }), ...createHarnessBuilderServer({ client }), catalog, diff --git a/packages/trueforge-ui/src/server/ServerContext.tsx b/packages/trueforge-ui/src/server/ServerContext.tsx index b8fa31974..d41fd67f1 100644 --- a/packages/trueforge-ui/src/server/ServerContext.tsx +++ b/packages/trueforge-ui/src/server/ServerContext.tsx @@ -90,7 +90,7 @@ export function useOptionalRefreshServerCapabilities(): (() => void) | null { export function useCatalogServer(): CatalogServer { const server = useServer(); if (server.catalog == null) { - throw new Error('useCatalogServer requires AgentUIServer.catalog. Pass catalog to createTrueFoundryServer.'); + throw new Error('useCatalogServer requires AgentUIServer.catalog. Pass catalog to createTrueForgeServer.'); } return server.catalog; } @@ -126,7 +126,7 @@ export function useOptionalAgentMetricsServer(): AgentMetricsServer | null { export function useScheduleServer(): ScheduleServer { const server = useServer(); if (server.schedules == null) { - throw new Error('useScheduleServer requires AgentUIServer.schedules. Pass schedules to createTrueFoundryServer.'); + throw new Error('useScheduleServer requires AgentUIServer.schedules. Pass schedules to createTrueForgeServer.'); } return server.schedules; } diff --git a/packages/trueforge-ui/src/server/TrueForgeServerConfig.ts b/packages/trueforge-ui/src/server/TrueForgeServerConfig.ts index 1e12a587d..c9c7f375d 100644 --- a/packages/trueforge-ui/src/server/TrueForgeServerConfig.ts +++ b/packages/trueforge-ui/src/server/TrueForgeServerConfig.ts @@ -4,26 +4,17 @@ import type { AgentUIServer, CatalogServer, PermissionsServer } from './types.js * Built-in backends init inside ``. * Optional host ports are attached onto the resolved `AgentUIServer`. */ -export type TrueForgeBuiltInServerConfig = - | { - type: 'truefoundry'; - apiKey: string; - controlPlaneURL: string; - gatewayPlaneURL?: string; - catalog?: CatalogServer; - permissions?: PermissionsServer; - } - | { - type: 'trueforge'; - /** Harness API root. Defaults to `'/'` (same-origin / proxied). */ - baseUrl?: string; - /** Bearer token for `@truefoundry/trueforge-sdk` (`Authorization: Bearer …`). */ - token?: string; - /** Custom fetch (cookie sessions, auth interceptors). */ - fetch?: typeof fetch; - catalog?: CatalogServer; - permissions?: PermissionsServer; - }; +export type TrueForgeBuiltInServerConfig = { + type: 'trueforge'; + /** Harness API root. Defaults to `'/'` (same-origin / proxied). */ + baseUrl?: string; + /** Bearer token for `@truefoundry/trueforge-sdk` (`Authorization: Bearer …`). */ + token?: string; + /** Custom fetch (cookie sessions, auth interceptors). */ + fetch?: typeof fetch; + catalog?: CatalogServer; + permissions?: PermissionsServer; +}; /** * `server` prop for ``: a built-in config, or a ready diff --git a/packages/trueforge-ui/src/server/createTrueFoundryServer.ts b/packages/trueforge-ui/src/server/createTrueForgeServer.ts similarity index 95% rename from packages/trueforge-ui/src/server/createTrueFoundryServer.ts rename to packages/trueforge-ui/src/server/createTrueForgeServer.ts index 57ed55751..722562e55 100644 --- a/packages/trueforge-ui/src/server/createTrueFoundryServer.ts +++ b/packages/trueforge-ui/src/server/createTrueForgeServer.ts @@ -17,7 +17,7 @@ import type { SearchAgentsParams, } from './types.js'; -export type CreateTrueFoundryServerOptions< +export type CreateTrueForgeServerOptions< TSpec extends AgentSpec = AgentSpec, TModel extends ModelSelection = ModelSelection, TSkill extends AgentSkill = AgentSkill, @@ -54,7 +54,7 @@ export type CreateTrueFoundryServerOptions< permissions?: TPermissions; }; -export type TrueFoundryServer< +export type TrueForgeServer< TSpec extends AgentSpec = AgentSpec, TModel extends ModelSelection = ModelSelection, TSkill extends AgentSkill = AgentSkill, @@ -81,7 +81,7 @@ export type TrueFoundryServer< * and an optional settings `catalog`. * Gateway wiring lives in the host (or `@truefoundry/agent-server-adapter`). */ -export function createTrueFoundryServer< +export function createTrueForgeServer< TSpec extends AgentSpec = AgentSpec, TModel extends ModelSelection = ModelSelection, TSkill extends AgentSkill = AgentSkill, @@ -95,7 +95,7 @@ export function createTrueFoundryServer< TSchedules extends ScheduleServer = ScheduleServer, TPermissions extends PermissionsServer = PermissionsServer, >( - opts: CreateTrueFoundryServerOptions< + opts: CreateTrueForgeServerOptions< TSpec, TModel, TSkill, @@ -109,7 +109,7 @@ export function createTrueFoundryServer< TSchedules, TPermissions >, -): TrueFoundryServer< +): TrueForgeServer< TSpec, TModel, TSkill, @@ -137,11 +137,11 @@ export function createTrueFoundryServer< await opts.deleteAgent(req); return; } - throw new Error('deleteAgent is host-owned. Pass deleteAgent to createTrueFoundryServer.'); + throw new Error('deleteAgent is host-owned. Pass deleteAgent to createTrueForgeServer.'); }, }; - const server: TrueFoundryServer< + const server: TrueForgeServer< TSpec, TModel, TSkill, diff --git a/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx b/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx index 4dc129444..4ef23c900 100644 --- a/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx +++ b/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx @@ -11,7 +11,7 @@ import { RuntimeHarness } from '../containers/RuntimeHarness.js'; import { createMockAgentUIServer } from '../server/mockServer.js'; vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryAgentSpec: () => ({ + useTrueForgeAgentSpec: () => ({ agentSpec: { model: { name: 'openai-main/gpt-4.1' }, }, diff --git a/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx b/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx index a513cae99..eaffd62f3 100644 --- a/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx +++ b/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx @@ -26,10 +26,10 @@ const adoptAgentSpec = vi.fn(); const updateAgentSpec = vi.fn(); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryAgentSpec: () => ({ agentSpec, draftSessionId: 'draft-1' }), - useTrueFoundryFlushAgentSpec: () => flushAgentSpec, - useTrueFoundryAdoptAgentSpec: () => adoptAgentSpec, - useTrueFoundryUpdateAgentSpec: () => updateAgentSpec, + useTrueForgeAgentSpec: () => ({ agentSpec, draftSessionId: 'draft-1' }), + useTrueForgeFlushAgentSpec: () => flushAgentSpec, + useTrueForgeAdoptAgentSpec: () => adoptAgentSpec, + useTrueForgeUpdateAgentSpec: () => updateAgentSpec, })); beforeAll(() => { diff --git a/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx b/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx index ae7ed2cf0..a97ae42c6 100644 --- a/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx +++ b/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx @@ -15,8 +15,8 @@ let agentSpec: AgentSpec; const updateAgentSpec = vi.fn(); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryAgentSpec: () => ({ agentSpec }), - useTrueFoundryUpdateAgentSpec: () => updateAgentSpec, + useTrueForgeAgentSpec: () => ({ agentSpec }), + useTrueForgeUpdateAgentSpec: () => updateAgentSpec, })); function DraftSections({ diff --git a/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx b/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx index b8c990738..905a351c6 100644 --- a/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx +++ b/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx @@ -33,8 +33,8 @@ const sandboxCatalog: SandboxCatalogServer = { const settingsCatalog = createMockCatalog({ skillCatalog, sandboxCatalog }); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryAgentSpec: () => ({ agentSpec }), - useTrueFoundryUpdateAgentSpec: () => updateAgentSpec, + useTrueForgeAgentSpec: () => ({ agentSpec }), + useTrueForgeUpdateAgentSpec: () => updateAgentSpec, })); vi.mock('@/server/ShellModeContext.js', () => ({ diff --git a/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx b/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx index 757d56777..478e67754 100644 --- a/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx +++ b/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx @@ -13,8 +13,8 @@ let agentSpec: AgentSpec | undefined; const updateAgentSpec = vi.fn(); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryAgentSpec: () => ({ agentSpec }), - useTrueFoundryUpdateAgentSpec: () => updateAgentSpec, + useTrueForgeAgentSpec: () => ({ agentSpec }), + useTrueForgeUpdateAgentSpec: () => updateAgentSpec, })); const models: ModelSelection[] = [ diff --git a/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx b/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx index a979d5fab..8e34dad5f 100644 --- a/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx @@ -14,13 +14,13 @@ const flushAgentSpec = vi.fn(async () => undefined); const updateAgentSpec = vi.fn(); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryAgentSpec: () => ({ + useTrueForgeAgentSpec: () => ({ agentSpec: { model: { name: 'openai/gpt-4.1' } }, draftSessionId: 'draft-1', }), - useTrueFoundryFlushAgentSpec: () => flushAgentSpec, - useTrueFoundryUpdateAgentSpec: () => updateAgentSpec, - useTrueFoundryAdoptAgentSpec: () => vi.fn(), + useTrueForgeFlushAgentSpec: () => flushAgentSpec, + useTrueForgeUpdateAgentSpec: () => updateAgentSpec, + useTrueForgeAdoptAgentSpec: () => vi.fn(), })); beforeAll(() => { diff --git a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx index a7b84364c..54c4b491c 100644 --- a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx @@ -10,7 +10,7 @@ const useToolResponses = vi.hoisted(() => vi.fn()); const useThreadRunning = vi.hoisted(() => vi.fn()); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryToolResponses: () => useToolResponses(), + useTrueForgeToolResponses: () => useToolResponses(), })); vi.mock('@assistant-ui/core/react', () => ({ diff --git a/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx b/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx index cf3f0830e..002ada1e1 100644 --- a/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx @@ -6,7 +6,7 @@ import { type ThreadMessageLike, } from '@assistant-ui/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { trueFoundryExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras } from '@truefoundry/assistant-ui-runtime'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -193,7 +193,7 @@ describe('AssistantTextContainer', () => { isRunning: false, convertMessage: (m: ThreadMessageLike) => m, onNew: async () => {}, - extras: trueFoundryExtras.provide({ + extras: trueForgeExtras.provide({ pendingApprovals: [], pendingToolResponses: [], pendingMcpAuth: null, diff --git a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx index c15620707..3a43b8b62 100644 --- a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx @@ -33,10 +33,10 @@ const approvalsState = vi.hoisted(() => ({ })); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryCancel: () => vi.fn(), - useTrueFoundryToolResponses: () => toolResponsesState, - useTrueFoundryApprovals: () => approvalsState, - useTrueFoundryAgentSpec: () => ({ agentSpec: agentSpecState.agentSpec }), + useTrueForgeCancel: () => vi.fn(), + useTrueForgeToolResponses: () => toolResponsesState, + useTrueForgeApprovals: () => approvalsState, + useTrueForgeAgentSpec: () => ({ agentSpec: agentSpecState.agentSpec }), })); function SecretSelectProbe({ onSubmit }: CustomActionRendererProps) { diff --git a/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx b/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx index 584dd13a8..a4c40e469 100644 --- a/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx @@ -12,7 +12,7 @@ const useToolResponses = vi.hoisted(() => vi.fn()); const useThreadRunning = vi.hoisted(() => vi.fn()); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryToolResponses: () => useToolResponses(), + useTrueForgeToolResponses: () => useToolResponses(), })); vi.mock('@assistant-ui/core/react', () => ({ diff --git a/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx b/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx index 6aa3ed289..95519805f 100644 --- a/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { AssistantRuntimeProvider, useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { render, screen, waitFor } from '@testing-library/react'; -import { trueFoundryExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras } from '@truefoundry/assistant-ui-runtime'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HistoryLoaderContainer } from '@/containers/HistoryLoaderContainer.js'; @@ -43,7 +43,7 @@ function Harness({ isRunning: false, convertMessage: (m: ThreadMessageLike) => m, onNew: async () => {}, - extras: trueFoundryExtras.provide({ + extras: trueForgeExtras.provide({ pendingApprovals: [], pendingToolResponses: [], pendingMcpAuth: null, diff --git a/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx b/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx index 5a1cf5ec1..6ed65fb2b 100644 --- a/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { AssistantRuntimeProvider, useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { trueFoundryExtras, type TrueFoundryRuntimeExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras, type TrueForgeRuntimeExtras } from '@truefoundry/assistant-ui-runtime'; import { describe, expect, it, vi } from 'vitest'; import { McpAuthContainer } from '@/containers/McpAuthContainer.js'; @@ -17,8 +17,8 @@ function McpAuthHarness({ resumeMcpAuth, isRunning = false, }: { - pendingMcpAuth: TrueFoundryRuntimeExtras['pendingMcpAuth']; - resumeMcpAuth: TrueFoundryRuntimeExtras['resumeMcpAuth']; + pendingMcpAuth: TrueForgeRuntimeExtras['pendingMcpAuth']; + resumeMcpAuth: TrueForgeRuntimeExtras['resumeMcpAuth']; isRunning?: boolean; }) { const messages: ThreadMessageLike[] = []; @@ -27,7 +27,7 @@ function McpAuthHarness({ isRunning, convertMessage: (m: ThreadMessageLike) => m, onNew: async () => {}, - extras: trueFoundryExtras.provide({ + extras: trueForgeExtras.provide({ pendingApprovals: [], pendingToolResponses: [], pendingMcpAuth, diff --git a/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx b/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx index 8b239d193..6155c35a9 100644 --- a/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { AssistantRuntimeProvider, useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { render, screen } from '@testing-library/react'; -import { trueFoundryExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras } from '@truefoundry/assistant-ui-runtime'; import { describe, expect, it } from 'vitest'; import { ResumeUnavailableContainer } from '@/containers/ResumeUnavailableContainer.js'; @@ -14,7 +14,7 @@ function Harness({ resumeUnavailable }: { resumeUnavailable: boolean }) { isRunning: resumeUnavailable, convertMessage: (m: ThreadMessageLike) => m, onNew: async () => {}, - extras: trueFoundryExtras.provide({ + extras: trueForgeExtras.provide({ pendingApprovals: [], pendingToolResponses: [], pendingMcpAuth: null, diff --git a/packages/trueforge-ui/test/containers/Thread.test.tsx b/packages/trueforge-ui/test/containers/Thread.test.tsx index cd93d466c..261ec78d5 100644 --- a/packages/trueforge-ui/test/containers/Thread.test.tsx +++ b/packages/trueforge-ui/test/containers/Thread.test.tsx @@ -8,10 +8,10 @@ import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { RuntimeHarness } from './RuntimeHarness.js'; vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryCancel: () => vi.fn(), - useTrueFoundryToolResponses: () => ({ pending: [] }), - useTrueFoundryApprovals: () => ({ pending: [] }), - useTrueFoundryAgentSpec: () => ({ agentSpec: { model: { name: 'test/model' } } }), + useTrueForgeCancel: () => vi.fn(), + useTrueForgeToolResponses: () => ({ pending: [] }), + useTrueForgeApprovals: () => ({ pending: [] }), + useTrueForgeAgentSpec: () => ({ agentSpec: { model: { name: 'test/model' } } }), })); function WelcomeOverride({ heading }: WelcomeScreenProps) { diff --git a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx index 77c31d8c2..4a17895a5 100644 --- a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx @@ -19,7 +19,7 @@ vi.mock('@truefoundry/assistant-ui-runtime', async importOriginal => { const actual = await importOriginal(); return { ...actual, - useTrueFoundryRespondToToolApproval: () => respondToNestedApproval, + useTrueForgeRespondToToolApproval: () => respondToNestedApproval, }; }); diff --git a/packages/trueforge-ui/test/containers/TrueFoundryChatProvider.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeChatProvider.test.tsx similarity index 78% rename from packages/trueforge-ui/test/containers/TrueFoundryChatProvider.test.tsx rename to packages/trueforge-ui/test/containers/TrueForgeChatProvider.test.tsx index 81ba5803e..3503154f2 100644 --- a/packages/trueforge-ui/test/containers/TrueFoundryChatProvider.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeChatProvider.test.tsx @@ -2,19 +2,19 @@ import { useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { act, render, screen } from '@testing-library/react'; import { - trueFoundryAttachmentAdapter, - type TrueFoundryAgentConfig, - type UseTrueFoundryAgentRuntimeOptions, + trueForgeAttachmentAdapter, + type TrueForgeAgentConfig, + type UseTrueForgeAgentRuntimeOptions, } from '@truefoundry/assistant-ui-runtime'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockAgentUIServer } from '../server/mockServer.js'; -const runtimeSpy = vi.hoisted(() => vi.fn<(options: UseTrueFoundryAgentRuntimeOptions) => void>()); +const runtimeSpy = vi.hoisted(() => vi.fn<(options: UseTrueForgeAgentRuntimeOptions) => void>()); const defaultAttachmentAdapter = vi.hoisted(() => ({})); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - trueFoundryAttachmentAdapter: defaultAttachmentAdapter, - useTrueFoundryAgentRuntime: (options: UseTrueFoundryAgentRuntimeOptions) => { + trueForgeAttachmentAdapter: defaultAttachmentAdapter, + useTrueForgeAgentRuntime: (options: UseTrueForgeAgentRuntimeOptions) => { runtimeSpy(options); // Called from ChatRuntimeScope (a React component), so hooks are valid here. return useExternalStoreRuntime({ @@ -26,9 +26,9 @@ vi.mock('@truefoundry/assistant-ui-runtime', () => ({ }, })); -import { TrueFoundryChatProvider } from '@/containers/TrueFoundryChatProvider.js'; +import { TrueForgeChatProvider } from '@/containers/TrueForgeChatProvider.js'; -describe('TrueFoundryChatProvider', () => { +describe('TrueForgeChatProvider', () => { beforeEach(() => { runtimeSpy.mockClear(); }); @@ -38,9 +38,9 @@ describe('TrueFoundryChatProvider', () => { const onError = vi.fn(); render( - +
chat-child
-
, + , ); expect(screen.getByText('chat-child')).toBeInTheDocument(); @@ -86,9 +86,9 @@ describe('TrueFoundryChatProvider', () => { })); render( - +
chat-child
-
, + , ); const runtimeServer = runtimeSpy.mock.calls[0]?.[0]?.server; @@ -109,9 +109,9 @@ describe('TrueFoundryChatProvider', () => { updatedAt: '2026-09-10T00:00:00.000Z', })); render( - +
chat-child
-
, + , ); const runtimeServer = runtimeSpy.mock.calls[0]?.[0]?.server; if (runtimeServer === undefined) throw new Error('Expected runtime server'); @@ -126,31 +126,31 @@ describe('TrueFoundryChatProvider', () => { }); it('forwards a discriminated agent configuration', () => { - const agent: TrueFoundryAgentConfig = { + const agent: TrueForgeAgentConfig = { mode: 'named', agentName: 'configured-agent', }; render( - +
chat-child
-
, + , ); expect(runtimeSpy).toHaveBeenCalledWith(expect.objectContaining({ agent })); }); it('preserves a consumer-provided attachment adapter', () => { - const attachmentAdapter = { ...trueFoundryAttachmentAdapter }; + const attachmentAdapter = { ...trueForgeAttachmentAdapter }; render( -
chat-child
-
, + , ); expect(runtimeSpy).toHaveBeenCalledWith( @@ -162,9 +162,9 @@ describe('TrueFoundryChatProvider', () => { it('uses the toaster as the default runtime error handler', async () => { render( - +
chat-child
-
, + , ); const options = runtimeSpy.mock.calls[0]?.[0]; if (options?.onError === undefined) { diff --git a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx index d338d07c6..ce59ca6a9 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx @@ -6,39 +6,30 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createMockAgentUIServer, createMockCatalog } from '../server/mockServer.js'; vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - trueFoundryAttachmentAdapter: {}, - useTrueFoundryAgentRuntime: () => + trueForgeAttachmentAdapter: {}, + useTrueForgeAgentRuntime: () => useExternalStoreRuntime({ messages: [], isRunning: false, convertMessage: (message: ThreadMessageLike) => message, onNew: async () => {}, }), - useTrueFoundryCancel: () => vi.fn(), - useTrueFoundryToolResponses: () => ({ pending: [] }), - useTrueFoundryApprovals: () => ({ pending: [] }), - useTrueFoundryRespondToToolApproval: () => vi.fn(), - useTrueFoundryMcpAuth: () => ({ pending: [], connect: vi.fn(), continue: vi.fn() }), - useTrueFoundryHistoryPagination: () => ({ + useTrueForgeCancel: () => vi.fn(), + useTrueForgeToolResponses: () => ({ pending: [] }), + useTrueForgeApprovals: () => ({ pending: [] }), + useTrueForgeRespondToToolApproval: () => vi.fn(), + useTrueForgeMcpAuth: () => ({ pending: [], connect: vi.fn(), continue: vi.fn() }), + useTrueForgeHistoryPagination: () => ({ isLoadingMore: false, hasMore: false, loadMore: vi.fn(), }), - useTrueFoundryAgentSpec: () => ({ + useTrueForgeAgentSpec: () => ({ agentSpec: { model: { name: 'openai-main/gpt-4.1' } }, }), - useTrueFoundryFlushAgentSpec: () => async () => {}, - useTrueFoundryAdoptAgentSpec: () => vi.fn(), - useTrueFoundryUpdateAgentSpec: () => vi.fn(), -})); - -vi.mock('@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter', () => ({ - createTrueFoundryAgentUIServer: vi.fn( - () => - new Promise(() => { - /* never resolves — keep init loader visible */ - }), - ), + useTrueForgeFlushAgentSpec: () => async () => {}, + useTrueForgeAdoptAgentSpec: () => vi.fn(), + useTrueForgeUpdateAgentSpec: () => vi.fn(), })); vi.mock('thinking-orbs', () => ({ @@ -353,23 +344,6 @@ describe('TrueForgeUI', () => { }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - - it('shows a loader while truefoundry server init is pending', () => { - render( - , - ); - - expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument(); - expect(screen.getByTestId('thinking-orb')).toBeInTheDocument(); - }); }); describe('StackChatPanel', () => { diff --git a/packages/trueforge-ui/test/containers/useResolvedServer.test.tsx b/packages/trueforge-ui/test/containers/useResolvedServer.test.tsx index 488284c55..25e8e4576 100644 --- a/packages/trueforge-ui/test/containers/useResolvedServer.test.tsx +++ b/packages/trueforge-ui/test/containers/useResolvedServer.test.tsx @@ -32,25 +32,6 @@ const mockCreateTrueForgeAgentUIServer = vi.fn((options?: { permissions?: Permis ), ); -vi.mock('@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter', () => ({ - createTrueFoundryAgentUIServer: vi.fn(async () => ({ - createSession: vi.fn(), - listSessions: vi.fn(), - getSession: vi.fn(), - updateSession: vi.fn(), - createTurn: vi.fn(), - cancelSession: vi.fn(), - listTurns: vi.fn(), - getTurn: vi.fn(), - listEvents: vi.fn(), - getModels: vi.fn(async () => []), - getSkills: vi.fn(async () => []), - getMcp: vi.fn(async () => []), - searchAgents: vi.fn(async () => []), - saveAgent: vi.fn(async () => ({})), - })), -})); - vi.mock('@/plugins/trueforge-agent-server-adapter/index.js', () => ({ createTrueForgeAgentUIServer: (options?: Parameters[0]) => mockCreateTrueForgeAgentUIServer(options), @@ -142,70 +123,4 @@ describe('useResolvedServer', () => { }); expect(result.current.server?.catalog).toBe(catalog); }); - - it('loads truefoundry via createTrueFoundryAgentUIServer', async () => { - const { createTrueFoundryAgentUIServer } = - await import('@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter'); - const { result } = renderHook(() => - useResolvedServer({ - type: 'truefoundry', - apiKey: 'k', - controlPlaneURL: 'https://cp.example', - gatewayPlaneURL: 'https://gw.example', - }), - ); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - }); - expect(createTrueFoundryAgentUIServer).toHaveBeenCalledWith({ - apiKey: 'k', - cpURL: 'https://cp.example', - gatewayURL: 'https://gw.example', - }); - expect(result.current.server?.permissions).toBeUndefined(); - expect(result.current.server?.getCapabilities).toEqual(expect.any(Function)); - await expect(result.current.server?.getCapabilities()).resolves.toEqual({ - data: { - sandbox: { enabled: true }, - skill: { enabled: true }, - settings: { enabled: true }, - webSearch: { enabled: false }, - }, - }); - }); - - it('attaches an explicit permissions port onto the truefoundry server', async () => { - const { result } = renderHook(() => - useResolvedServer({ - type: 'truefoundry', - apiKey: 'k', - controlPlaneURL: 'https://cp.example', - permissions, - }), - ); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - }); - expect(result.current.server?.permissions).toBe(permissions); - }); - - it('attaches an optional catalog onto the truefoundry server', async () => { - const catalog = createMockCatalog(); - const { result } = renderHook(() => - useResolvedServer({ - type: 'truefoundry', - apiKey: 'k', - controlPlaneURL: 'https://cp.example', - catalog, - }), - ); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - }); - expect(result.current.server?.catalog).toBe(catalog); - expect(result.current.server?.getCapabilities).toEqual(expect.any(Function)); - }); }); diff --git a/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx b/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx index a34e02a52..7335394d9 100644 --- a/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx +++ b/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx @@ -6,7 +6,7 @@ const useApprovals = vi.hoisted(() => vi.fn()); const focus = vi.hoisted(() => vi.fn()); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryApprovals: useApprovals, + useTrueForgeApprovals: useApprovals, })); vi.mock('@/containers/approvalFocus.js', () => ({ diff --git a/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx b/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx index ac4d24eb0..d1d4b9a2a 100644 --- a/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx +++ b/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx @@ -13,7 +13,7 @@ import { ShellModeProvider, useShellMode } from '@/server/ShellModeContext.js'; import { RuntimeHarness } from '../containers/RuntimeHarness.js'; vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryAgentSpec: () => ({ + useTrueForgeAgentSpec: () => ({ agentSpec: {}, }), })); diff --git a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx index 2d520dbf4..3a71fd734 100644 --- a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx +++ b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx @@ -12,8 +12,8 @@ vi.mock('@assistant-ui/react', () => ({ })); vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - useTrueFoundryToolResponses: useToolResponses, - useTrueFoundryApprovals: useApprovals, + useTrueForgeToolResponses: useToolResponses, + useTrueForgeApprovals: useApprovals, })); import { threadHasPendingMcpAuth, type ThreadPauseState, useComposerPauseView } from '@/hooks/useComposerPauseView.js'; diff --git a/packages/trueforge-ui/test/packageExports.smoke.mjs b/packages/trueforge-ui/test/packageExports.smoke.mjs index 3991d12d4..c6eb6a205 100644 --- a/packages/trueforge-ui/test/packageExports.smoke.mjs +++ b/packages/trueforge-ui/test/packageExports.smoke.mjs @@ -61,7 +61,7 @@ const assistantUi = await import('@truefoundry/trueforge-ui/assistant-ui'); const trueforgeAdapter = await import('@truefoundry/trueforge-ui/plugins/trueforge-agent-server-adapter'); assert.equal(typeof sdk.TrueForgeUI, 'function'); -assert.equal(typeof sdk.createTrueFoundryServer, 'function'); +assert.equal(typeof sdk.createTrueForgeServer, 'function'); assert.equal(typeof sdk.useMCPAuth, 'function'); assert.equal(typeof assistantUi.useAui, 'function'); assert.equal(typeof assistantUi.useAuiState, 'function'); diff --git a/packages/trueforge-ui/test/publicUiExports.test.ts b/packages/trueforge-ui/test/publicUiExports.test.ts index 1520be1fe..7ed02616a 100644 --- a/packages/trueforge-ui/test/publicUiExports.test.ts +++ b/packages/trueforge-ui/test/publicUiExports.test.ts @@ -155,7 +155,7 @@ const expectedRuntimeExports: Array = [ 'ToolCallContentBlockContainer', 'ToolGroupCard', 'ToolGroupContainer', - 'TrueFoundryChatProvider', + 'TrueForgeChatProvider', 'TrueForgeUI', 'USER_MESSAGE_ATTACHMENT_PREVIEW_REM', 'UserAvatar', @@ -166,7 +166,7 @@ const expectedRuntimeExports: Array = [ 'UserMessageEdit', 'WelcomeScreen', 'computeAgentStepsSplit', - 'createTrueFoundryServer', + 'createTrueForgeServer', 'defaultSlots', 'findSubAgentAncestorsForApproval', 'getErrorMessage', @@ -181,7 +181,7 @@ const expectedRuntimeExports: Array = [ 'shellIsCreateAgent', 'shellIsMutable', 'threadHasPendingMcpAuth', - 'trueFoundryAttachmentAdapter', + 'trueForgeAttachmentAdapter', 'useApprovalNav', 'useAgentMetricsServer', 'useAgentSessionsServer', @@ -217,17 +217,17 @@ const expectedRuntimeExports: Array = [ 'useTheme', 'useThemeIcons', 'useThemeMode', - 'useTrueFoundryAgentRuntime', - 'useTrueFoundryAgentSpec', - 'useTrueFoundryApprovals', - 'useTrueFoundryCancel', - 'useTrueFoundryDownloadSandboxFile', - 'useTrueFoundryHistoryPagination', - 'useTrueFoundryMcpAuth', - 'useTrueFoundryRespondToToolApproval', - 'useTrueFoundryToolResponses', - 'useTrueFoundryTurnId', - 'useTrueFoundryUpdateAgentSpec', + 'useTrueForgeAgentRuntime', + 'useTrueForgeAgentSpec', + 'useTrueForgeApprovals', + 'useTrueForgeCancel', + 'useTrueForgeDownloadSandboxFile', + 'useTrueForgeHistoryPagination', + 'useTrueForgeMcpAuth', + 'useTrueForgeRespondToToolApproval', + 'useTrueForgeToolResponses', + 'useTrueForgeTurnId', + 'useTrueForgeUpdateAgentSpec', ]; describe('public runtime exports', () => { diff --git a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx index 588023db8..e6e733166 100644 --- a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx +++ b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx @@ -13,8 +13,8 @@ const threadState = { }; vi.mock('@truefoundry/assistant-ui-runtime', () => ({ - trueFoundryAttachmentAdapter: {}, - useTrueFoundryAgentRuntime: () => { + trueForgeAttachmentAdapter: {}, + useTrueForgeAgentRuntime: () => { const [threadId, setThreadId] = useState(threadState.threadId); threadState.setThreadId = setThreadId; return useExternalStoreRuntime({ @@ -36,22 +36,22 @@ vi.mock('@truefoundry/assistant-ui-runtime', () => ({ }, }); }, - useTrueFoundryCancel: () => vi.fn(), - useTrueFoundryToolResponses: () => ({ pending: [] }), - useTrueFoundryApprovals: () => ({ pending: [] }), - useTrueFoundryRespondToToolApproval: () => vi.fn(), - useTrueFoundryMcpAuth: () => ({ pending: [], connect: vi.fn(), continue: vi.fn() }), - useTrueFoundryHistoryPagination: () => ({ + useTrueForgeCancel: () => vi.fn(), + useTrueForgeToolResponses: () => ({ pending: [] }), + useTrueForgeApprovals: () => ({ pending: [] }), + useTrueForgeRespondToToolApproval: () => vi.fn(), + useTrueForgeMcpAuth: () => ({ pending: [], connect: vi.fn(), continue: vi.fn() }), + useTrueForgeHistoryPagination: () => ({ isLoadingMore: false, hasMore: false, loadMore: vi.fn(), }), - useTrueFoundryAgentSpec: () => ({ + useTrueForgeAgentSpec: () => ({ agentSpec: { model: { name: 'openai-main/gpt-4.1' } }, }), - useTrueFoundryFlushAgentSpec: () => async () => {}, - useTrueFoundryAdoptAgentSpec: () => vi.fn(), - useTrueFoundryUpdateAgentSpec: () => vi.fn(), + useTrueForgeFlushAgentSpec: () => async () => {}, + useTrueForgeAdoptAgentSpec: () => vi.fn(), + useTrueForgeUpdateAgentSpec: () => vi.fn(), })); import { CompactLayoutProvider } from '@/atoms/lib/CompactLayoutContext.js'; diff --git a/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts b/packages/trueforge-ui/test/server/createTrueForgeServer.test.ts similarity index 96% rename from packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts rename to packages/trueforge-ui/test/server/createTrueForgeServer.test.ts index 6ad692421..0d318af9d 100644 --- a/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts +++ b/packages/trueforge-ui/test/server/createTrueForgeServer.test.ts @@ -2,10 +2,10 @@ import type { SaveAgentResult } from '@/index.js'; import type { CatalogServer } from '@/server/types.js'; import { describe, expect, it, vi } from 'vitest'; -import { createTrueFoundryServer } from '@/server/createTrueFoundryServer.js'; +import { createTrueForgeServer } from '@/server/createTrueForgeServer.js'; import { createMockAgentUIServer } from './mockServer.js'; -describe('createTrueFoundryServer', () => { +describe('createTrueForgeServer', () => { it('composes chat server with builder callbacks', async () => { const chatServer = createMockAgentUIServer({ createSession: vi.fn(), @@ -48,7 +48,7 @@ describe('createTrueFoundryServer', () => { listSessionEvents: vi.fn(async () => ({ data: [] })), }; - const server = createTrueFoundryServer({ + const server = createTrueForgeServer({ chatServer, getCapabilities, getModels, @@ -183,7 +183,7 @@ describe('createTrueFoundryServer', () => { }, }; - const server = createTrueFoundryServer({ + const server = createTrueForgeServer({ chatServer, getCapabilities: async () => ({ data: { sandbox: { enabled: false }, skill: { enabled: false } }, diff --git a/packages/trueforge-ui/test/testSetup.ts b/packages/trueforge-ui/test/testSetup.ts index 534705e26..c2024ebb2 100644 --- a/packages/trueforge-ui/test/testSetup.ts +++ b/packages/trueforge-ui/test/testSetup.ts @@ -43,6 +43,10 @@ vi.mock('@openuidev/react-ui', () => ({ openuiLibrary: {}, })); +vi.mock('thinking-orbs', () => ({ + ThinkingOrb: () => createElement('div', { 'data-testid': 'thinking-orb' }), +})); + // jsdom does not implement ResizeObserver; assistant-ui's viewport/scroll // tracking primitives use it, so tests need a no-op stand-in. class ResizeObserverStub { diff --git a/packages/trueforge-ui/test/tsconfig.json b/packages/trueforge-ui/test/tsconfig.json index 202762480..6b414e70b 100644 --- a/packages/trueforge-ui/test/tsconfig.json +++ b/packages/trueforge-ui/test/tsconfig.json @@ -7,7 +7,7 @@ "moduleResolution": "Bundler", "customConditions": ["trueforge-dev"], "rootDir": "..", - "types": ["node", "vitest/globals"] + "types": ["node", "vitest/globals", "@testing-library/jest-dom/vitest"] }, "include": ["../src", ".", "../vitest.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d15ba059..742223b48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,40 @@ importers: specifier: ^8.68.0 version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + packages/assistant-ui-runtime: + dependencies: + '@assistant-ui/core': + specifier: 0.2.22 + version: 0.2.22(@assistant-ui/store@0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41(redis@5.12.1(@opentelemetry/api@1.9.1)))(react@19.2.8)(redis@5.12.1(@opentelemetry/api@1.9.1))(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) + '@assistant-ui/store': + specifier: 0.2.21 + version: 0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) + devDependencies: + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/node': + specifier: ^20 + version: 20.19.43 + '@types/react': + specifier: ^19.2.17 + version: 19.2.18 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + react: + specifier: ^19.2.4 + version: 19.2.8 + tsup: + specifier: ^8.5.0 + version: 8.5.1(@swc/core@1.16.1)(jiti@2.7.0)(postcss@8.5.26)(supports-color@8.1.1)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.9 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0)) + packages/frontend: dependencies: '@assistant-ui/core': @@ -69,8 +103,8 @@ importers: specifier: 0.2.21 version: 0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) '@truefoundry/assistant-ui-runtime': - specifier: 0.1.43 - version: 0.1.43(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41(redis@5.12.1(@opentelemetry/api@1.9.1)))(react@19.2.8)(redis@5.12.1(@opentelemetry/api@1.9.1))(truefoundry-gateway-sdk@0.4.2)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) + specifier: workspace:* + version: link:../assistant-ui-runtime '@truefoundry/trueforge-sdk': specifier: workspace:* version: link:../trueforge-sdk @@ -349,7 +383,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.1 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0)) webpack: specifier: ^5.105.4 version: 5.109.2(@swc/core@1.16.1)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.26) @@ -372,8 +406,8 @@ importers: specifier: ^0.13.2 version: 0.13.3(@openuidev/react-headless@0.9.4(react@19.2.8)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))))(@openuidev/react-lang@0.2.9(@modelcontextprotocol/sdk@1.30.0(supports-color@8.1.1)(zod@4.4.3))(react@19.2.8)(zod@4.4.3))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(zod@4.4.3)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) '@truefoundry/assistant-ui-runtime': - specifier: 0.1.43 - version: 0.1.43(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41(redis@5.12.1(@opentelemetry/api@1.9.1)))(react@19.2.8)(redis@5.12.1(@opentelemetry/api@1.9.1))(truefoundry-gateway-sdk@0.4.2)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) + specifier: workspace:* + version: link:../assistant-ui-runtime '@truefoundry/trueforge-sdk': specifier: workspace:* version: link:../trueforge-sdk @@ -419,9 +453,6 @@ importers: thinking-orbs: specifier: ^0.3.1 version: 0.3.1(react@19.2.8) - truefoundry-gateway-sdk: - specifier: ^0.4.2 - version: 0.4.2 zod: specifier: ^4.4.3 version: 4.4.3 @@ -436,8 +467,8 @@ importers: specifier: ^10.0.0 version: 10.4.1 '@testing-library/jest-dom': - specifier: ^6.6.3 - version: 6.10.0(@testing-library/dom@10.4.1) + specifier: 6.6.3 + version: 6.6.3 '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -567,6 +598,21 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@assistant-ui/core@0.2.22': resolution: {integrity: sha512-6BzW6yFEMpojaS6Oj4VSLIb0hMaRh+chZzv4e08SuNmJ+ed+UTsPbVHlvJT/EY587AlNLB2DRSKW21+vrsfUzA==} peerDependencies: @@ -943,6 +989,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} @@ -1026,6 +1076,10 @@ packages: resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + '@csstools/css-calc@2.1.4': resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} engines: {node: '>=18'} @@ -1033,6 +1087,13 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-calc@3.4.0': + resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-color-parser@3.1.0': resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} engines: {node: '>=18'} @@ -1040,16 +1101,41 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-color-parser@4.2.3': + resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} peerDependencies: '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@csstools/css-tokenizer@4.0.1': + resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==} + engines: {node: '>=20.19.0'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -1083,12 +1169,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -1107,12 +1187,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} @@ -1131,12 +1205,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} @@ -1155,12 +1223,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} @@ -1179,12 +1241,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} @@ -1203,12 +1259,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} @@ -1227,12 +1277,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} @@ -1251,12 +1295,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} @@ -1275,12 +1313,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} @@ -1299,12 +1331,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} @@ -1323,12 +1349,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} @@ -1347,12 +1367,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} @@ -1371,12 +1385,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} @@ -1395,12 +1403,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} @@ -1419,12 +1421,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} @@ -1443,12 +1439,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} @@ -1467,12 +1457,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} @@ -1491,12 +1475,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} @@ -1515,12 +1493,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} @@ -1539,12 +1511,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} @@ -1563,12 +1529,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} @@ -1587,12 +1547,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} @@ -1611,12 +1565,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} @@ -1635,12 +1583,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} @@ -1659,12 +1601,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} @@ -1683,12 +1619,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -1811,6 +1741,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -3632,12 +3571,9 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.10.0': - resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} - engines: {node: '>=22', npm: '>=6', yarn: '>=1'} - deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. - peerDependencies: - '@testing-library/dom': '>=10 <11' + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} @@ -3654,18 +3590,6 @@ packages: '@types/react-dom': optional: true - '@truefoundry/assistant-ui-runtime@0.1.43': - resolution: {integrity: sha512-Jlqyjs9S60Az6o/gVWX4k7v7O0e5z+6+dke6cdOSdDcJNv4ChCnoepPqZ5nYzGWL0bwg5udar6WhQZMUqQ6Idw==} - peerDependencies: - '@types/react': '*' - react: ^18 || ^19 - truefoundry-gateway-sdk: ^0.4.0-rc.6 - peerDependenciesMeta: - '@types/react': - optional: true - truefoundry-gateway-sdk: - optional: true - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -4287,6 +4211,9 @@ packages: resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} engines: {node: '>=22'} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + birecord@0.1.2: resolution: {integrity: sha512-5PAPTTmMpMEb+GuMb5DebfBkipRGyIW9+gtwEBSoDA9xkhHILm04+hZQ702pMksu3d8YAuGkmgTzQWcKqTPScA==} @@ -4374,6 +4301,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -4582,6 +4513,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -4640,6 +4575,10 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} @@ -4785,6 +4724,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + env-paths@4.0.0: resolution: {integrity: sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==} engines: {node: '>=20'} @@ -4834,11 +4777,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -5315,6 +5253,10 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -5685,6 +5627,15 @@ packages: canvas: optional: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -5889,6 +5840,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5981,6 +5936,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} @@ -6376,6 +6334,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7196,6 +7157,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -7210,10 +7175,6 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - truefoundry-gateway-sdk@0.4.2: - resolution: {integrity: sha512-HmMj9LHWFzg4fpumQ7LxH/tHBGvSU6NNJkZgNkFS+UfI/EzQzfgkM9jSWw/j++yFevnQbvk/iQeYf0dwZp73Fw==} - engines: {node: '>=18.0.0'} - ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -7561,6 +7522,10 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.5.1: resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} engines: {node: '>=10.13.0'} @@ -7584,10 +7549,18 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7809,6 +7782,26 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1) + '@csstools/css-color-parser': 4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1) + '@csstools/css-tokenizer': 4.0.1 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@assistant-ui/core@0.2.22(@assistant-ui/store@0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41(redis@5.12.1(@opentelemetry/api@1.9.1)))(react@19.2.8)(redis@5.12.1(@opentelemetry/api@1.9.1))(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)))': dependencies: '@assistant-ui/store': 0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) @@ -8288,6 +8281,10 @@ snapshots: '@biomejs/cli-win32-x64@2.4.10': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bundled-es-modules/cookie@2.0.1': dependencies: cookie: 0.7.2 @@ -8416,11 +8413,18 @@ snapshots: '@csstools/color-helpers@5.1.0': {} + '@csstools/color-helpers@6.1.1': {} + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-calc@3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1) + '@csstools/css-tokenizer': 4.0.1 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/color-helpers': 5.1.0 @@ -8428,12 +8432,29 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-color-parser@4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1) + '@csstools/css-tokenizer': 4.0.1 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1)': + dependencies: + '@csstools/css-tokenizer': 4.0.1 + + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + '@csstools/css-tokenizer@3.0.4': {} + '@csstools/css-tokenizer@4.0.1': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -8506,9 +8527,6 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/aix-ppc64@0.28.1': - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true @@ -8518,9 +8536,6 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm64@0.28.2': optional: true @@ -8530,9 +8545,6 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-arm@0.28.2': optional: true @@ -8542,9 +8554,6 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/android-x64@0.28.2': optional: true @@ -8554,9 +8563,6 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.28.2': optional: true @@ -8566,9 +8572,6 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.28.2': optional: true @@ -8578,9 +8581,6 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.28.2': optional: true @@ -8590,9 +8590,6 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.28.2': optional: true @@ -8602,9 +8599,6 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.28.2': optional: true @@ -8614,9 +8608,6 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-arm@0.28.2': optional: true @@ -8626,9 +8617,6 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-ia32@0.28.2': optional: true @@ -8638,9 +8626,6 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-loong64@0.28.2': optional: true @@ -8650,9 +8635,6 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.28.2': optional: true @@ -8662,9 +8644,6 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.28.2': optional: true @@ -8674,9 +8653,6 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.28.2': optional: true @@ -8686,9 +8662,6 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-s390x@0.28.2': optional: true @@ -8698,9 +8671,6 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - '@esbuild/linux-x64@0.28.2': optional: true @@ -8710,9 +8680,6 @@ snapshots: '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-arm64@0.28.2': optional: true @@ -8722,9 +8689,6 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.28.2': optional: true @@ -8734,9 +8698,6 @@ snapshots: '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-arm64@0.28.2': optional: true @@ -8746,9 +8707,6 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.28.2': optional: true @@ -8758,9 +8716,6 @@ snapshots: '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/openharmony-arm64@0.28.2': optional: true @@ -8770,9 +8725,6 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.28.2': optional: true @@ -8782,9 +8734,6 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.28.2': optional: true @@ -8794,9 +8743,6 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-ia32@0.28.2': optional: true @@ -8806,9 +8752,6 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@esbuild/win32-x64@0.28.2': optional: true @@ -8976,6 +8919,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -10897,14 +10842,14 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + '@testing-library/jest-dom@6.6.3': dependencies: '@adobe/css-tools': 4.5.0 - '@testing-library/dom': 10.4.1 aria-query: 5.3.2 + chalk: 3.0.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 + lodash: 4.18.1 redent: 3.0.0 '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': @@ -10917,21 +10862,6 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@truefoundry/assistant-ui-runtime@0.1.43(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41(redis@5.12.1(@opentelemetry/api@1.9.1)))(react@19.2.8)(redis@5.12.1(@opentelemetry/api@1.9.1))(truefoundry-gateway-sdk@0.4.2)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)))': - dependencies: - '@assistant-ui/core': 0.2.22(@assistant-ui/store@0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.41(redis@5.12.1(@opentelemetry/api@1.9.1)))(react@19.2.8)(redis@5.12.1(@opentelemetry/api@1.9.1))(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) - '@assistant-ui/store': 0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - optionalDependencies: - '@types/react': 19.2.18 - truefoundry-gateway-sdk: 0.4.2 - transitivePeerDependencies: - - '@assistant-ui/tap' - - assistant-cloud - - ioredis - - redis - - zustand - '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -11107,10 +11037,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/type-utils': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) @@ -11321,7 +11251,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -11656,6 +11586,10 @@ snapshots: dependencies: node-addon-api: 8.9.2 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + birecord@0.1.2: {} body-parser@2.3.0(supports-color@8.1.1): @@ -11743,6 +11677,11 @@ snapshots: chai@6.2.2: {} + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -11917,6 +11856,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} cssstyle@4.6.0: @@ -11969,6 +11913,13 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns-jalali@4.1.0-0: {} date-fns@4.4.0: {} @@ -12082,6 +12033,8 @@ snapshots: entities@6.0.1: {} + entities@8.1.0: {} + env-paths@4.0.0: dependencies: is-safe-filename: 0.1.1 @@ -12178,35 +12131,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -12235,7 +12159,6 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 - optional: true escalade@3.2.0: {} @@ -12876,6 +12799,12 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-url-attributes@3.0.1: {} @@ -13453,6 +13382,32 @@ snapshots: - supports-color - utf-8-validate + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -13624,6 +13579,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -13830,6 +13787,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -14289,6 +14248,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} partial-json@0.1.7: {} @@ -15227,6 +15190,10 @@ snapshots: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -15235,8 +15202,6 @@ snapshots: trough@2.2.0: {} - truefoundry-gateway-sdk@0.4.2: {} - ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -15315,7 +15280,7 @@ snapshots: tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -15350,7 +15315,7 @@ snapshots: typescript-eslint@8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.68.0(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) @@ -15629,6 +15594,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.0)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 20.19.43 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -15645,6 +15640,8 @@ snapshots: webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.5.1: {} webpack@5.109.2(@swc/core@1.16.1)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.26): @@ -15689,11 +15686,21 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + whatwg-url@14.2.0: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c8ad9525b..2909f600d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,11 +14,6 @@ allowBuilds: msw: true protobufjs: true -# Silence peer mismatches between trueforge-ui peers and the versions we pin. -peerDependencyRules: - allowedVersions: - '@truefoundry/assistant-ui-runtime': '0.1.39' - # One Context module for assistant-ui (avoids "requires an AuiProvider"). overrides: '@assistant-ui/store': '0.2.21' From d848b771c02e909ac0d18a2cd4420a8abc28f37b Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Mon, 21 Sep 2026 16:16:19 +0530 Subject: [PATCH 2/6] rename assistant-ui runtime package to trueforge-assistant-ui-runtime and update references throughout the codebase. This includes changes in package.json, documentation, and various components to reflect the new naming convention. --- .changeset/migrate-assistant-runtime.md | 6 +- .cursor/BUGBOT.md | 2 +- .cursor/rules/server-types-runtime-only.mdc | 2 +- docs/ui-sdk/reference/events.mdx | 2 +- package.json | 6 +- packages/assistant-ui-runtime/LICENSE | 201 ++++++++++++++++++ packages/assistant-ui-runtime/README.md | 20 +- packages/assistant-ui-runtime/package.json | 3 +- .../src/foldPeerThreads.ts | 4 +- packages/frontend/package.json | 2 +- packages/trueforge-ui/NOTICE | 2 +- packages/trueforge-ui/docs/compatibility.md | 2 +- packages/trueforge-ui/docs/server.md | 2 +- packages/trueforge-ui/package.json | 4 +- .../src/atoms/SaveAgentButton.tsx | 2 +- .../draft/AgentConfigInstructionsContext.tsx | 2 +- .../atoms/draft/DraftCompositeSelector.tsx | 2 +- .../src/atoms/draft/DraftModelSelector.tsx | 2 +- .../draft/DraftReasoningEffortSelector.tsx | 2 +- .../atoms/draft/DraftSpecPreferenceBridge.tsx | 2 +- .../src/atoms/draft/reasoningEffort.ts | 2 +- .../containers/AgentConfigDrawerContainer.tsx | 2 +- .../AgentSessionTimelineContainer.tsx | 2 +- .../src/containers/AskUserContainer.tsx | 2 +- .../containers/AssistantMessageContainer.tsx | 2 +- .../src/containers/AssistantTextContainer.tsx | 2 +- .../src/containers/ComposerContainer.tsx | 2 +- .../src/containers/CustomActionContainer.tsx | 2 +- .../src/containers/HistoryLoaderContainer.tsx | 2 +- .../src/containers/McpAuthContainer.tsx | 2 +- .../containers/ResumeUnavailableContainer.tsx | 2 +- .../src/containers/ToolCallContainer.tsx | 2 +- .../src/containers/TrueForgeChatProvider.tsx | 2 +- .../src/containers/TrueForgeUIShell.tsx | 2 +- .../trueforge-ui/src/hooks/useApprovalNav.ts | 2 +- .../src/hooks/useChatChromeActionsVisible.ts | 2 +- .../src/hooks/useComposerPauseView.ts | 2 +- packages/trueforge-ui/src/index.ts | 4 +- packages/trueforge-ui/src/server/types.ts | 4 +- .../test/atoms/ClearChatButton.test.tsx | 2 +- .../test/atoms/SaveAgentButton.test.tsx | 2 +- .../draft/DraftComposerSections.test.tsx | 2 +- .../draft/DraftCompositeSelector.test.tsx | 2 +- .../atoms/draft/DraftModelSelector.test.tsx | 2 +- .../AgentConfigDrawerContainer.test.tsx | 2 +- .../test/containers/AskUserContainer.test.tsx | 2 +- .../AssistantTextContainer.test.tsx | 2 +- .../containers/ComposerContainer.test.tsx | 2 +- .../containers/CustomActionContainer.test.tsx | 2 +- .../HistoryLoaderContainer.test.tsx | 2 +- .../test/containers/McpAuthContainer.test.tsx | 2 +- .../ResumeUnavailableContainer.test.tsx | 2 +- .../test/containers/Thread.test.tsx | 2 +- .../containers/ToolCallContainer.test.tsx | 4 +- .../containers/TrueForgeChatProvider.test.tsx | 4 +- .../test/containers/TrueForgeUI.test.tsx | 2 +- .../test/hooks/useApprovalNav.test.tsx | 2 +- .../useChatChromeActionsVisible.test.tsx | 2 +- .../test/hooks/useComposerPauseView.test.tsx | 2 +- .../routing/withRouter.integration.test.tsx | 2 +- packages/trueforge-ui/tsup.config.ts | 2 +- pnpm-lock.yaml | 4 +- 62 files changed, 283 insertions(+), 81 deletions(-) create mode 100644 packages/assistant-ui-runtime/LICENSE diff --git a/.changeset/migrate-assistant-runtime.md b/.changeset/migrate-assistant-runtime.md index 30c0eeb1f..033484834 100644 --- a/.changeset/migrate-assistant-runtime.md +++ b/.changeset/migrate-assistant-runtime.md @@ -1,7 +1,7 @@ --- -'@truefoundry/assistant-ui-runtime': minor -'@truefoundry/trueforge-ui': minor +'@truefoundry/trueforge-assistant-ui-runtime': major +'@truefoundry/trueforge-ui': major '@truefoundry/trueforge': patch --- -Move the assistant-ui runtime into the TrueForge workspace, rename its public runtime APIs to TrueForge, and remove the legacy TrueFoundry server adapter and server configuration. +Rename the published runtime package to `@truefoundry/trueforge-assistant-ui-runtime`, move it into the TrueForge workspace, rename its public runtime APIs to TrueForge, and remove the legacy TrueFoundry server adapter and server configuration. diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 5d0aa58f2..9614893b5 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -36,7 +36,7 @@ If a `catch` throws a new error, flag unless the new error sets `{ cause: caught Flag duplicate type, schema, helper, or contract definitions and forwarding shims that hide ownership. Each must have one canonical owner. -`AgentChatServer`, `AgentBuilderServer`, catalog ports, session/turn DTOs, and stream events must be defined only in `@truefoundry/assistant-ui-runtime` (`src/server/types.ts` / `events.ts`). Flag hand-written parallel definitions in `@truefoundry/trueforge-ui`. That package may only re-export them from `src/server/types.ts`. React wiring (`ServerContext`, `ShellModeContext`, `TrueForgeServerConfig`) may import those types; it must not redefine ports. +`AgentChatServer`, `AgentBuilderServer`, catalog ports, session/turn DTOs, and stream events must be defined only in `@truefoundry/trueforge-assistant-ui-runtime` (`src/server/types.ts` / `events.ts`). Flag hand-written parallel definitions in `@truefoundry/trueforge-ui`. That package may only re-export them from `src/server/types.ts`. React wiring (`ServerContext`, `ShellModeContext`, `TrueForgeServerConfig`) may import those types; it must not redefine ports. Flag runtime types backed by Zod schemas that are duplicated as hand-written interfaces or indirect utility-type chains. They must be named `z.infer` aliases. diff --git a/.cursor/rules/server-types-runtime-only.mdc b/.cursor/rules/server-types-runtime-only.mdc index 621199101..0594c53d3 100644 --- a/.cursor/rules/server-types-runtime-only.mdc +++ b/.cursor/rules/server-types-runtime-only.mdc @@ -13,7 +13,7 @@ Canonical `AgentUIServer` / chat / builder / catalog port types live only in ## Rules - [`packages/trueforge-ui/src/server/types.ts`](packages/trueforge-ui/src/server/types.ts) - is the host-facing barrel: re-export only from `@truefoundry/assistant-ui-runtime` + is the host-facing barrel: re-export only from `@truefoundry/trueforge-assistant-ui-runtime` (prefer `/server` when avoiding root name collisions). Do **not** put hand-written server port/DTO interfaces there or elsewhere in this monorepo. - Hosts import types from `@truefoundry/trueforge-ui` only — they must not need the diff --git a/docs/ui-sdk/reference/events.mdx b/docs/ui-sdk/reference/events.mdx index 2178cd0a8..69a5f0cde 100644 --- a/docs/ui-sdk/reference/events.mdx +++ b/docs/ui-sdk/reference/events.mdx @@ -18,7 +18,7 @@ interface TurnStreamData { page for you — emit an event whose fields do not match and it compiles cleanly, then fails to render. Treat these shapes as required regardless. - The named event interfaces below are declared in `@truefoundry/assistant-ui-runtime`. Import + The named event interfaces below are declared in `@truefoundry/trueforge-assistant-ui-runtime`. Import them from there if you want compile-time checking when building a server. diff --git a/package.json b/package.json index 5d25e81e1..16f982356 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "node": ">=22.14.0" }, "scripts": { - "build": "pnpm --filter @truefoundry/trueforge-core build && pnpm --filter @truefoundry/trueforge-sdk build && pnpm --filter @truefoundry/assistant-ui-runtime build && pnpm --filter @truefoundry/trueforge-ui build && pnpm --filter frontend build && pnpm --filter @truefoundry/trueforge build", + "build": "pnpm --filter @truefoundry/trueforge-core build && pnpm --filter @truefoundry/trueforge-sdk build && pnpm --filter @truefoundry/trueforge-assistant-ui-runtime build && pnpm --filter @truefoundry/trueforge-ui build && pnpm --filter frontend build && pnpm --filter @truefoundry/trueforge build", "chart:deps": "helm dependency build charts/trueforge", "chart:lint": "helm lint charts/trueforge --values charts/trueforge/ci/lint-values.yaml", "chart:package": "helm package charts/trueforge --destination dist", @@ -40,7 +40,7 @@ "start:controller": "cross-env STANDALONE=false NODE_ENV=production pnpm --filter @truefoundry/trueforge start:controller", "push-sandbox-image-to-daytona": "pnpm --filter @truefoundry/trueforge push-sandbox-image-to-daytona", "test:frontend": "pnpm --filter frontend test", - "test:assistant-ui-runtime": "pnpm --filter @truefoundry/assistant-ui-runtime test", + "test:assistant-ui-runtime": "pnpm --filter @truefoundry/trueforge-assistant-ui-runtime test", "test:chart-version": "bash tests/scripts/resolve-chart-version.test.sh", "test:trueforge-core": "pnpm --filter @truefoundry/trueforge-core test", "test:trueforge": "pnpm --filter @truefoundry/trueforge test", @@ -52,7 +52,7 @@ "test:store:sqlite": "pnpm --filter @truefoundry/trueforge test:store:sqlite", "test:trueforge-ui": "pnpm --filter @truefoundry/trueforge-ui test", "test": "pnpm test:trueforge-core && pnpm test:assistant-ui-runtime && pnpm test:trueforge-ui && pnpm test:trueforge && pnpm test:frontend", - "typecheck": "pnpm --filter @truefoundry/trueforge-core typecheck && pnpm --filter @truefoundry/assistant-ui-runtime typecheck && pnpm --filter @truefoundry/trueforge-ui typecheck && pnpm --filter @truefoundry/trueforge typecheck && pnpm --filter frontend typecheck", + "typecheck": "pnpm --filter @truefoundry/trueforge-core typecheck && pnpm --filter @truefoundry/trueforge-assistant-ui-runtime typecheck && pnpm --filter @truefoundry/trueforge-ui typecheck && pnpm --filter @truefoundry/trueforge typecheck && pnpm --filter frontend typecheck", "version": "node scripts/version.mjs" }, "devDependencies": { diff --git a/packages/assistant-ui-runtime/LICENSE b/packages/assistant-ui-runtime/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/packages/assistant-ui-runtime/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/assistant-ui-runtime/README.md b/packages/assistant-ui-runtime/README.md index 830539025..a00459a37 100644 --- a/packages/assistant-ui-runtime/README.md +++ b/packages/assistant-ui-runtime/README.md @@ -1,4 +1,4 @@ -# @truefoundry/assistant-ui-runtime +# @truefoundry/trueforge-assistant-ui-runtime Headless React runtime that maps TrueForge sessions, turns, streaming events, tool approvals, and sub-agent threads onto assistant-ui's external-store runtime. @@ -7,7 +7,7 @@ The package accepts a ready `AgentChatServer`. It does not construct backend cli ## Installation ```bash -pnpm add @truefoundry/assistant-ui-runtime @assistant-ui/react +pnpm add @truefoundry/trueforge-assistant-ui-runtime @assistant-ui/react ``` React 18 and 19 are supported. `@assistant-ui/core` and `@assistant-ui/store` are installed with the runtime. @@ -18,7 +18,7 @@ React 18 and 19 are supported. `@assistant-ui/core` and `@assistant-ui/store` ar 'use client'; import { AssistantRuntimeProvider } from '@assistant-ui/react'; -import { useTrueForgeAgentRuntime, type AgentChatServer } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentRuntime, type AgentChatServer } from '@truefoundry/trueforge-assistant-ui-runtime'; function Chat({ server }: { server: AgentChatServer }) { const runtime = useTrueForgeAgentRuntime({ @@ -41,7 +41,7 @@ For the complete TrueForge UI, use `@truefoundry/trueforge-ui`. It supplies the Named agents use an existing server-side agent: ```ts -import type { TrueForgeAgentConfig } from '@truefoundry/assistant-ui-runtime'; +import type { TrueForgeAgentConfig } from '@truefoundry/trueforge-assistant-ui-runtime'; const agent = { mode: 'named', @@ -52,7 +52,7 @@ const agent = { Draft agents run from an inline spec and synchronize edits through the server: ```ts -import type { TrueForgeAgentConfig } from '@truefoundry/assistant-ui-runtime'; +import type { TrueForgeAgentConfig } from '@truefoundry/trueforge-assistant-ui-runtime'; const agent = { mode: 'draft', @@ -114,7 +114,7 @@ import type { Session, Turn, TurnStreamingEvent, -} from '@truefoundry/assistant-ui-runtime/server'; +} from '@truefoundry/trueforge-assistant-ui-runtime/server'; ``` Important invariants: @@ -131,17 +131,17 @@ Important invariants: ## Package exports -- `@truefoundry/assistant-ui-runtime` -- `@truefoundry/assistant-ui-runtime/server` +- `@truefoundry/trueforge-assistant-ui-runtime` +- `@truefoundry/trueforge-assistant-ui-runtime/server` ## Development Run from the trueforge repository root: ```bash -pnpm --filter @truefoundry/assistant-ui-runtime build +pnpm --filter @truefoundry/trueforge-assistant-ui-runtime build pnpm test:assistant-ui-runtime -pnpm --filter @truefoundry/assistant-ui-runtime typecheck +pnpm --filter @truefoundry/trueforge-assistant-ui-runtime typecheck ``` ## License diff --git a/packages/assistant-ui-runtime/package.json b/packages/assistant-ui-runtime/package.json index c3dc65467..6b4a2c0be 100644 --- a/packages/assistant-ui-runtime/package.json +++ b/packages/assistant-ui-runtime/package.json @@ -1,8 +1,9 @@ { - "name": "@truefoundry/assistant-ui-runtime", + "name": "@truefoundry/trueforge-assistant-ui-runtime", "version": "0.1.43", "description": "TrueForge agent runtime adapter for assistant-ui", "author": "TrueFoundry", + "license": "Apache-2.0", "repository": { "type": "git", "url": "git+https://github.com/truefoundry/trueforge.git", diff --git a/packages/assistant-ui-runtime/src/foldPeerThreads.ts b/packages/assistant-ui-runtime/src/foldPeerThreads.ts index f02240fa3..4fdfc2bbd 100644 --- a/packages/assistant-ui-runtime/src/foldPeerThreads.ts +++ b/packages/assistant-ui-runtime/src/foldPeerThreads.ts @@ -76,7 +76,7 @@ function warnUnexpectedRootThread(threadId: string, eventType: string): void { return; } console.warn( - `[@truefoundry/assistant-ui-runtime] Expected root thread "${ROOT_THREAD_ID}" but received "${threadId}" on ${eventType}.`, + `[@truefoundry/trueforge-assistant-ui-runtime] Expected root thread "${ROOT_THREAD_ID}" but received "${threadId}" on ${eventType}.`, ); } @@ -86,7 +86,7 @@ function assertRootThreadEvent(threadId: string, eventType: string): void { } if (isDev) { throw new Error( - `[@truefoundry/assistant-ui-runtime] Root-looking event ${eventType} arrived on thread "${threadId}" instead of "${ROOT_THREAD_ID}".`, + `[@truefoundry/trueforge-assistant-ui-runtime] Root-looking event ${eventType} arrived on thread "${threadId}" instead of "${ROOT_THREAD_ID}".`, ); } warnUnexpectedRootThread(threadId, eventType); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 4d854de3d..79c64dd69 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -26,7 +26,7 @@ "@assistant-ui/core": "0.2.22", "@assistant-ui/react": "0.14.27", "@assistant-ui/store": "0.2.21", - "@truefoundry/assistant-ui-runtime": "workspace:*", + "@truefoundry/trueforge-assistant-ui-runtime": "workspace:*", "@truefoundry/trueforge-sdk": "workspace:*", "@truefoundry/trueforge-ui": "workspace:*", "monaco-editor": "^0.52.0", diff --git a/packages/trueforge-ui/NOTICE b/packages/trueforge-ui/NOTICE index 0d5e6e730..818585f88 100644 --- a/packages/trueforge-ui/NOTICE +++ b/packages/trueforge-ui/NOTICE @@ -8,7 +8,7 @@ dependencies and their licenses include (non-exhaustive): - @assistant-ui/react / @assistant-ui/core — see their respective LICENSE files - @openuidev/react-ui and related OpenUI packages — see their LICENSE files -- @truefoundry/assistant-ui-runtime — see its LICENSE +- @truefoundry/trueforge-assistant-ui-runtime — see its LICENSE - lucide-react — ISC / Lucide license - monaco-editor — see its LICENSE - react-markdown / remark-gfm / react-syntax-highlighter — see their respective LICENSE files diff --git a/packages/trueforge-ui/docs/compatibility.md b/packages/trueforge-ui/docs/compatibility.md index a095f20b2..0f5eabf62 100644 --- a/packages/trueforge-ui/docs/compatibility.md +++ b/packages/trueforge-ui/docs/compatibility.md @@ -19,7 +19,7 @@ Install once at the app root. | ----------------------------------- | ----------------- | | `@assistant-ui/core` | `^0.2.22` | | `@assistant-ui/react` | `^0.14.24` | -| `@truefoundry/assistant-ui-runtime` | workspace release | +| `@truefoundry/trueforge-assistant-ui-runtime` | workspace release | | `lucide-react` | `^0.562.0` | `@assistant-ui/*` are also listed as **optional** peers so hosts that install diff --git a/packages/trueforge-ui/docs/server.md b/packages/trueforge-ui/docs/server.md index 746c0bd37..cf05e3c4f 100644 --- a/packages/trueforge-ui/docs/server.md +++ b/packages/trueforge-ui/docs/server.md @@ -32,6 +32,6 @@ const server: AgentUIServer = { ; ``` -The canonical contracts live in `@truefoundry/assistant-ui-runtime/server` and are re-exported by `@truefoundry/trueforge-ui`. Hosts should import them from the UI package. +The canonical contracts live in `@truefoundry/trueforge-assistant-ui-runtime/server` and are re-exported by `@truefoundry/trueforge-ui`. Hosts should import them from the UI package. Use `createTrueForgeServer` to compose separately implemented chat, builder, catalog, session, metrics, schedule, and permissions ports into one `AgentUIServer`. diff --git a/packages/trueforge-ui/package.json b/packages/trueforge-ui/package.json index 4e1e47461..64c3c8f7e 100644 --- a/packages/trueforge-ui/package.json +++ b/packages/trueforge-ui/package.json @@ -57,7 +57,7 @@ "src/icons/registerAgentIcons.ts" ], "scripts": { - "runtime:build": "pnpm --filter @truefoundry/assistant-ui-runtime build", + "runtime:build": "pnpm --filter @truefoundry/trueforge-assistant-ui-runtime build", "prebuild": "pnpm run runtime:build && pnpm -w run sdk:types", "build": "node ../../scripts/rm-path.mjs dist && tailwindcss --input src/build-styles.css --output dist/styles.css --minify && tsup", "dev:watch": "pnpm run \"/^(dev:watch:js|dev:watch:css)$/\"", @@ -97,7 +97,7 @@ "@openuidev/react-headless": "^0.9.4", "@openuidev/react-lang": "^0.2.9", "@openuidev/react-ui": "^0.13.2", - "@truefoundry/assistant-ui-runtime": "workspace:*", + "@truefoundry/trueforge-assistant-ui-runtime": "workspace:*", "@truefoundry/trueforge-sdk": "workspace:*", "chart.js": "^4.5.1", "clsx": "^2.1.1", diff --git a/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx b/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx index bacbaeb4a..128947c5f 100644 --- a/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx +++ b/packages/trueforge-ui/src/atoms/SaveAgentButton.tsx @@ -4,7 +4,7 @@ import { useTrueForgeAdoptAgentSpec, useTrueForgeAgentSpec, useTrueForgeFlushAgentSpec, -} from '@truefoundry/assistant-ui-runtime'; +} from '@truefoundry/trueforge-assistant-ui-runtime'; import { useRef, useState } from 'react'; import { useCanCreateAgent } from '../hooks/useCanCreateAgent.js'; diff --git a/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx b/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx index ee7636fc1..1a5788b6c 100644 --- a/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx +++ b/packages/trueforge-ui/src/atoms/draft/AgentConfigInstructionsContext.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/trueforge-assistant-ui-runtime'; import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'; import { useDebouncedAgentInstructions } from '../../hooks/useDebouncedAgentInstructions.js'; diff --git a/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx b/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx index c31154225..77dea5902 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftCompositeSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import { useMCPAuth } from '../../hooks/useMcpAuth.js'; diff --git a/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx b/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx index c5431d09f..504465d05 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftModelSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useEffect, useId, useRef, useState } from 'react'; import { Icon } from '../../icons/Icon.js'; diff --git a/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx b/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx index 58a02edf8..2c254b508 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftReasoningEffortSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useEffect, useId, useRef, useState } from 'react'; import { Icon } from '../../icons/Icon.js'; diff --git a/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx b/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx index 8cdbf5c3e..9f68df2ca 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeUpdateAgentSpec } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useEffect } from 'react'; import { diff --git a/packages/trueforge-ui/src/atoms/draft/reasoningEffort.ts b/packages/trueforge-ui/src/atoms/draft/reasoningEffort.ts index de6c4d34e..7c0b7fe73 100644 --- a/packages/trueforge-ui/src/atoms/draft/reasoningEffort.ts +++ b/packages/trueforge-ui/src/atoms/draft/reasoningEffort.ts @@ -18,7 +18,7 @@ export function resolveReasoningEffort(efforts: ReasoningEfforts, current: strin * Draft model patch: preserve other params, set or clear `reasoningEffort`. * * When clearing, set `reasoningEffort: undefined` (do not omit the key). - * `mergeAgentSpec` in `@truefoundry/assistant-ui-runtime` shallow-merges + * `mergeAgentSpec` in `@truefoundry/trueforge-assistant-ui-runtime` shallow-merges * `model.params`, so omitting the key leaves a sticky prior effort and the * server then 422s on models that do not advertise `reasoning_efforts`. */ diff --git a/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx b/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx index 1a3b525ef..7a9dec4b9 100644 --- a/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx +++ b/packages/trueforge-ui/src/containers/AgentConfigDrawerContainer.tsx @@ -4,7 +4,7 @@ import { useTrueForgeAgentSpec, useTrueForgeFlushAgentSpec, useTrueForgeUpdateAgentSpec, -} from '@truefoundry/assistant-ui-runtime'; +} from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useEffect, useState } from 'react'; import type { AgentConfigEditor } from '../atoms/draft/AgentConfigEditors.js'; diff --git a/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx b/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx index 3cfd790e0..2d0d614c0 100644 --- a/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx +++ b/packages/trueforge-ui/src/containers/AgentSessionTimelineContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { ThreadPrimitive, type ThreadMessageLike } from '@assistant-ui/react'; -import { convertTurnsToThreadMessages } from '@truefoundry/assistant-ui-runtime'; +import { convertTurnsToThreadMessages } from '@truefoundry/trueforge-assistant-ui-runtime'; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from 'react'; import { Markdown, type MarkdownProps } from '../atoms/Markdown.js'; diff --git a/packages/trueforge-ui/src/containers/AskUserContainer.tsx b/packages/trueforge-ui/src/containers/AskUserContainer.tsx index c98199a8e..9141a121e 100644 --- a/packages/trueforge-ui/src/containers/AskUserContainer.tsx +++ b/packages/trueforge-ui/src/containers/AskUserContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useThreadIsRunning } from '@assistant-ui/core/react'; -import { useTrueForgeToolResponses } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeToolResponses } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ASK_USER_CUSTOM_OPTION, diff --git a/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx b/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx index 1d5b684df..772b637a7 100644 --- a/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx +++ b/packages/trueforge-ui/src/containers/AssistantMessageContainer.tsx @@ -2,7 +2,7 @@ import { useActionBarCopy, useMessageError, useThreadIsRunning, type PartState } from '@assistant-ui/core/react'; import { MessagePrimitive, useAuiState, type EnrichedPartState, type GroupByContext } from '@assistant-ui/react'; -import { useTrueForgeResumeUnavailable } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeResumeUnavailable } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useSlot } from '../theme/SlotsProvider.js'; import { computeAgentStepsSplit } from '../utils/computeAgentStepsSplit.js'; diff --git a/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx b/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx index 29ed8f6ca..01e588f84 100644 --- a/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx +++ b/packages/trueforge-ui/src/containers/AssistantTextContainer.tsx @@ -7,7 +7,7 @@ import { type ReasoningMessagePart, type TextMessagePart, } from '@assistant-ui/react'; -import { useTrueForgeDownloadSandboxFile } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeDownloadSandboxFile } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useMemo, useRef } from 'react'; import { MARKDOWN_SMOOTH_BACKLOG_CHARS, useThrottledMarkdownText } from '../hooks/useThrottledMarkdownText.js'; diff --git a/packages/trueforge-ui/src/containers/ComposerContainer.tsx b/packages/trueforge-ui/src/containers/ComposerContainer.tsx index 71088cdaf..a8db0b14b 100644 --- a/packages/trueforge-ui/src/containers/ComposerContainer.tsx +++ b/packages/trueforge-ui/src/containers/ComposerContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'; -import { useTrueForgeAgentSpec, useTrueForgeCancel } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec, useTrueForgeCancel } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useRef } from 'react'; import { DraftCatalogProvider } from '../atoms/draft/DraftCatalogProvider.js'; diff --git a/packages/trueforge-ui/src/containers/CustomActionContainer.tsx b/packages/trueforge-ui/src/containers/CustomActionContainer.tsx index 26e84da20..d06f14689 100644 --- a/packages/trueforge-ui/src/containers/CustomActionContainer.tsx +++ b/packages/trueforge-ui/src/containers/CustomActionContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useThreadIsRunning } from '@assistant-ui/core/react'; -import { useTrueForgeToolResponses } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeToolResponses } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback } from 'react'; import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderersContext.js'; diff --git a/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx b/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx index 64e937b30..8c9cfda4d 100644 --- a/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx +++ b/packages/trueforge-ui/src/containers/HistoryLoaderContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeHistoryPagination } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeHistoryPagination } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useSlot } from '../theme/SlotsProvider.js'; diff --git a/packages/trueforge-ui/src/containers/McpAuthContainer.tsx b/packages/trueforge-ui/src/containers/McpAuthContainer.tsx index 892bd4ed3..e86dbd313 100644 --- a/packages/trueforge-ui/src/containers/McpAuthContainer.tsx +++ b/packages/trueforge-ui/src/containers/McpAuthContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useThreadIsRunning } from '@assistant-ui/core/react'; -import { useTrueForgeMcpAuth } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeMcpAuth } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useEffect, useRef, useState } from 'react'; import { useDraftCatalog } from '@/atoms/draft/DraftCatalogProvider.js'; diff --git a/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx b/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx index 4d5f88bd9..ecb8d2234 100644 --- a/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx +++ b/packages/trueforge-ui/src/containers/ResumeUnavailableContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeResumeUnavailable } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeResumeUnavailable } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useSlot } from '../theme/SlotsProvider.js'; diff --git a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx index f1343f1b2..4dff89780 100644 --- a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx +++ b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx @@ -8,7 +8,7 @@ import { type ToolCallMessagePartComponent, type ToolCallMessagePartProps, } from '@assistant-ui/react'; -import { useTrueForgeRespondToToolApproval } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeRespondToToolApproval } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useState } from 'react'; import { useSlot } from '../theme/SlotsProvider.js'; diff --git a/packages/trueforge-ui/src/containers/TrueForgeChatProvider.tsx b/packages/trueforge-ui/src/containers/TrueForgeChatProvider.tsx index 8e4bd2a90..ae8901039 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeChatProvider.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeChatProvider.tsx @@ -6,7 +6,7 @@ import { useTrueForgeAgentRuntime, type TrueForgeAgentConfig, type UseTrueForgeAgentRuntimeOptions, -} from '@truefoundry/assistant-ui-runtime'; +} from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useMemo, type ReactNode } from 'react'; import { sessionIsCreateAgent } from '../atoms/lib/sessionCreateAgent.js'; diff --git a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx index 0be2c246e..d52652c40 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { TrueForgeAgentConfig, UseTrueForgeAgentRuntimeOptions } from '@truefoundry/assistant-ui-runtime'; +import type { TrueForgeAgentConfig, UseTrueForgeAgentRuntimeOptions } from '@truefoundry/trueforge-assistant-ui-runtime'; import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { ThinkingOrb } from 'thinking-orbs'; diff --git a/packages/trueforge-ui/src/hooks/useApprovalNav.ts b/packages/trueforge-ui/src/hooks/useApprovalNav.ts index 752906416..f0baad93a 100644 --- a/packages/trueforge-ui/src/hooks/useApprovalNav.ts +++ b/packages/trueforge-ui/src/hooks/useApprovalNav.ts @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeApprovals } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeApprovals } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useOptionalApprovalFocus } from '../containers/approvalFocus.js'; diff --git a/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts b/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts index acd635c6e..76e5eb93d 100644 --- a/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts +++ b/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts @@ -1,6 +1,6 @@ 'use client'; -import { useTrueForgeAgentSpec } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeAgentSpec } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useAuiState } from '../assistant-ui.js'; import { useOptionalShellMode } from '../server/ShellModeContext.js'; diff --git a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts index 9f23efc80..2651cd739 100644 --- a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts +++ b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts @@ -1,7 +1,7 @@ 'use client'; import { useAuiState } from '@assistant-ui/react'; -import { useTrueForgeApprovals, useTrueForgeToolResponses } from '@truefoundry/assistant-ui-runtime'; +import { useTrueForgeApprovals, useTrueForgeToolResponses } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderersContext.js'; diff --git a/packages/trueforge-ui/src/index.ts b/packages/trueforge-ui/src/index.ts index 77f6ac8ee..651daed6a 100644 --- a/packages/trueforge-ui/src/index.ts +++ b/packages/trueforge-ui/src/index.ts @@ -275,13 +275,13 @@ export { useTrueForgeToolResponses, useTrueForgeTurnId, useTrueForgeUpdateAgentSpec, -} from '@truefoundry/assistant-ui-runtime'; +} from '@truefoundry/trueforge-assistant-ui-runtime'; export type { DraftAgentConfig, NamedAgentConfig, TrueForgeAgentConfig, UseTrueForgeAgentRuntimeOptions, -} from '@truefoundry/assistant-ui-runtime'; +} from '@truefoundry/trueforge-assistant-ui-runtime'; // Server port types + factory export { ClearChatButton } from './atoms/ClearChatButton.js'; diff --git a/packages/trueforge-ui/src/server/types.ts b/packages/trueforge-ui/src/server/types.ts index 3d539bb9d..f7c7dc6ea 100644 --- a/packages/trueforge-ui/src/server/types.ts +++ b/packages/trueforge-ui/src/server/types.ts @@ -1,5 +1,5 @@ /** - * Host-facing server contract — re-exported from `@truefoundry/assistant-ui-runtime/server` + * Host-facing server contract — re-exported from `@truefoundry/trueforge-assistant-ui-runtime/server` * so hosts never import the runtime package directly. * * Canonical definitions live in the runtime; this module is aliases + pass-through only. @@ -130,4 +130,4 @@ export type { UserMessageContent, UserToolApprovalEvent, UserToolResponseEvent, -} from '@truefoundry/assistant-ui-runtime/server'; +} from '@truefoundry/trueforge-assistant-ui-runtime/server'; diff --git a/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx b/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx index 4ef23c900..8bea6b0bc 100644 --- a/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx +++ b/packages/trueforge-ui/test/atoms/ClearChatButton.test.tsx @@ -10,7 +10,7 @@ import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { RuntimeHarness } from '../containers/RuntimeHarness.js'; import { createMockAgentUIServer } from '../server/mockServer.js'; -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeAgentSpec: () => ({ agentSpec: { model: { name: 'openai-main/gpt-4.1' }, diff --git a/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx b/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx index eaffd62f3..0fc83643d 100644 --- a/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx +++ b/packages/trueforge-ui/test/atoms/SaveAgentButton.test.tsx @@ -25,7 +25,7 @@ const flushAgentSpec = vi.fn(async () => undefined); const adoptAgentSpec = vi.fn(); const updateAgentSpec = vi.fn(); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeAgentSpec: () => ({ agentSpec, draftSessionId: 'draft-1' }), useTrueForgeFlushAgentSpec: () => flushAgentSpec, useTrueForgeAdoptAgentSpec: () => adoptAgentSpec, diff --git a/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx b/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx index a97ae42c6..369f32d25 100644 --- a/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx +++ b/packages/trueforge-ui/test/atoms/draft/DraftComposerSections.test.tsx @@ -14,7 +14,7 @@ import { createMockAgentUIServer } from '../../server/mockServer.js'; let agentSpec: AgentSpec; const updateAgentSpec = vi.fn(); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeAgentSpec: () => ({ agentSpec }), useTrueForgeUpdateAgentSpec: () => updateAgentSpec, })); diff --git a/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx b/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx index 905a351c6..abf2d418a 100644 --- a/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx +++ b/packages/trueforge-ui/test/atoms/draft/DraftCompositeSelector.test.tsx @@ -32,7 +32,7 @@ const sandboxCatalog: SandboxCatalogServer = { const settingsCatalog = createMockCatalog({ skillCatalog, sandboxCatalog }); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeAgentSpec: () => ({ agentSpec }), useTrueForgeUpdateAgentSpec: () => updateAgentSpec, })); diff --git a/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx b/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx index 478e67754..d9cf85537 100644 --- a/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx +++ b/packages/trueforge-ui/test/atoms/draft/DraftModelSelector.test.tsx @@ -12,7 +12,7 @@ import { createMockAgentUIServer, createMockCatalog } from '../../server/mockSer let agentSpec: AgentSpec | undefined; const updateAgentSpec = vi.fn(); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeAgentSpec: () => ({ agentSpec }), useTrueForgeUpdateAgentSpec: () => updateAgentSpec, })); diff --git a/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx b/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx index 8e34dad5f..ac5623683 100644 --- a/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AgentConfigDrawerContainer.test.tsx @@ -13,7 +13,7 @@ import { createMockAgentUIServer } from '../server/mockServer.js'; const flushAgentSpec = vi.fn(async () => undefined); const updateAgentSpec = vi.fn(); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeAgentSpec: () => ({ agentSpec: { model: { name: 'openai/gpt-4.1' } }, draftSessionId: 'draft-1', diff --git a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx index 54c4b491c..186860f68 100644 --- a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx @@ -9,7 +9,7 @@ import { SlotsProvider } from '@/theme/SlotsProvider.js'; const useToolResponses = vi.hoisted(() => vi.fn()); const useThreadRunning = vi.hoisted(() => vi.fn()); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeToolResponses: () => useToolResponses(), })); diff --git a/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx b/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx index 002ada1e1..de32f7160 100644 --- a/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AssistantTextContainer.test.tsx @@ -6,7 +6,7 @@ import { type ThreadMessageLike, } from '@assistant-ui/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { trueForgeExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; diff --git a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx index 3a43b8b62..01be1f138 100644 --- a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx @@ -32,7 +32,7 @@ const approvalsState = vi.hoisted(() => ({ }>, })); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeCancel: () => vi.fn(), useTrueForgeToolResponses: () => toolResponsesState, useTrueForgeApprovals: () => approvalsState, diff --git a/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx b/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx index a4c40e469..604c30f98 100644 --- a/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/CustomActionContainer.test.tsx @@ -11,7 +11,7 @@ import { const useToolResponses = vi.hoisted(() => vi.fn()); const useThreadRunning = vi.hoisted(() => vi.fn()); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeToolResponses: () => useToolResponses(), })); diff --git a/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx b/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx index 95519805f..c8760869f 100644 --- a/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/HistoryLoaderContainer.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { AssistantRuntimeProvider, useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { render, screen, waitFor } from '@testing-library/react'; -import { trueForgeExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras } from '@truefoundry/trueforge-assistant-ui-runtime'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HistoryLoaderContainer } from '@/containers/HistoryLoaderContainer.js'; diff --git a/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx b/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx index 0feb38c54..c10320364 100644 --- a/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/McpAuthContainer.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { AssistantRuntimeProvider, useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { trueForgeExtras, type TrueForgeRuntimeExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras, type TrueForgeRuntimeExtras } from '@truefoundry/trueforge-assistant-ui-runtime'; import { describe, expect, it, vi } from 'vitest'; import { DraftCatalogProvider } from '@/atoms/draft/DraftCatalogProvider.js'; diff --git a/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx b/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx index 6155c35a9..9b7d97841 100644 --- a/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ResumeUnavailableContainer.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { AssistantRuntimeProvider, useExternalStoreRuntime, type ThreadMessageLike } from '@assistant-ui/react'; import { render, screen } from '@testing-library/react'; -import { trueForgeExtras } from '@truefoundry/assistant-ui-runtime'; +import { trueForgeExtras } from '@truefoundry/trueforge-assistant-ui-runtime'; import { describe, expect, it } from 'vitest'; import { ResumeUnavailableContainer } from '@/containers/ResumeUnavailableContainer.js'; diff --git a/packages/trueforge-ui/test/containers/Thread.test.tsx b/packages/trueforge-ui/test/containers/Thread.test.tsx index 261ec78d5..e43eb013b 100644 --- a/packages/trueforge-ui/test/containers/Thread.test.tsx +++ b/packages/trueforge-ui/test/containers/Thread.test.tsx @@ -7,7 +7,7 @@ import { Thread } from '@/containers/Thread.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { RuntimeHarness } from './RuntimeHarness.js'; -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeCancel: () => vi.fn(), useTrueForgeToolResponses: () => ({ pending: [] }), useTrueForgeApprovals: () => ({ pending: [] }), diff --git a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx index 4a17895a5..793d80da5 100644 --- a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx @@ -15,8 +15,8 @@ const { respondToNestedApproval } = vi.hoisted(() => ({ respondToNestedApproval: vi.fn(), })); -vi.mock('@truefoundry/assistant-ui-runtime', async importOriginal => { - const actual = await importOriginal(); +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', async importOriginal => { + const actual = await importOriginal(); return { ...actual, useTrueForgeRespondToToolApproval: () => respondToNestedApproval, diff --git a/packages/trueforge-ui/test/containers/TrueForgeChatProvider.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeChatProvider.test.tsx index 3503154f2..988ba5a87 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeChatProvider.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeChatProvider.test.tsx @@ -5,14 +5,14 @@ import { trueForgeAttachmentAdapter, type TrueForgeAgentConfig, type UseTrueForgeAgentRuntimeOptions, -} from '@truefoundry/assistant-ui-runtime'; +} from '@truefoundry/trueforge-assistant-ui-runtime'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockAgentUIServer } from '../server/mockServer.js'; const runtimeSpy = vi.hoisted(() => vi.fn<(options: UseTrueForgeAgentRuntimeOptions) => void>()); const defaultAttachmentAdapter = vi.hoisted(() => ({})); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ trueForgeAttachmentAdapter: defaultAttachmentAdapter, useTrueForgeAgentRuntime: (options: UseTrueForgeAgentRuntimeOptions) => { runtimeSpy(options); diff --git a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx index ce59ca6a9..e112e0f86 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx @@ -5,7 +5,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createMockAgentUIServer, createMockCatalog } from '../server/mockServer.js'; -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ trueForgeAttachmentAdapter: {}, useTrueForgeAgentRuntime: () => useExternalStoreRuntime({ diff --git a/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx b/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx index 7335394d9..2a2325d39 100644 --- a/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx +++ b/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const useApprovals = vi.hoisted(() => vi.fn()); const focus = vi.hoisted(() => vi.fn()); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeApprovals: useApprovals, })); diff --git a/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx b/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx index d1d4b9a2a..54de9fc9f 100644 --- a/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx +++ b/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx @@ -12,7 +12,7 @@ import { import { ShellModeProvider, useShellMode } from '@/server/ShellModeContext.js'; import { RuntimeHarness } from '../containers/RuntimeHarness.js'; -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeAgentSpec: () => ({ agentSpec: {}, }), diff --git a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx index 3a71fd734..f2a918180 100644 --- a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx +++ b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx @@ -11,7 +11,7 @@ vi.mock('@assistant-ui/react', () => ({ useAuiState, })); -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ useTrueForgeToolResponses: useToolResponses, useTrueForgeApprovals: useApprovals, })); diff --git a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx index e6e733166..4000f0644 100644 --- a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx +++ b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx @@ -12,7 +12,7 @@ const threadState = { setThreadId: (_id: string) => {}, }; -vi.mock('@truefoundry/assistant-ui-runtime', () => ({ +vi.mock('@truefoundry/trueforge-assistant-ui-runtime', () => ({ trueForgeAttachmentAdapter: {}, useTrueForgeAgentRuntime: () => { const [threadId, setThreadId] = useState(threadState.threadId); diff --git a/packages/trueforge-ui/tsup.config.ts b/packages/trueforge-ui/tsup.config.ts index a72dd3855..7337a6eb1 100644 --- a/packages/trueforge-ui/tsup.config.ts +++ b/packages/trueforge-ui/tsup.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ '@openuidev/react-lang', '@openuidev/react-ui', '@openuidev/react-ui/genui-lib', - '@truefoundry/assistant-ui-runtime', + '@truefoundry/trueforge-assistant-ui-runtime', 'lucide-react', 'react-markdown', 'remark-gfm', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 742223b48..94cb62fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,7 +102,7 @@ importers: '@assistant-ui/store': specifier: 0.2.21 version: 0.2.21(@assistant-ui/tap@0.9.5(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) - '@truefoundry/assistant-ui-runtime': + '@truefoundry/trueforge-assistant-ui-runtime': specifier: workspace:* version: link:../assistant-ui-runtime '@truefoundry/trueforge-sdk': @@ -405,7 +405,7 @@ importers: '@openuidev/react-ui': specifier: ^0.13.2 version: 0.13.3(@openuidev/react-headless@0.9.4(react@19.2.8)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))))(@openuidev/react-lang@0.2.9(@modelcontextprotocol/sdk@1.30.0(supports-color@8.1.1)(zod@4.4.3))(react@19.2.8)(zod@4.4.3))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(zod@4.4.3)(zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) - '@truefoundry/assistant-ui-runtime': + '@truefoundry/trueforge-assistant-ui-runtime': specifier: workspace:* version: link:../assistant-ui-runtime '@truefoundry/trueforge-sdk': From c395ef6cef64fc81e7e9e5dfe10f749adffa26ea Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Mon, 21 Sep 2026 17:23:17 +0530 Subject: [PATCH 3/6] Update compatibility documentation formatting and refactor TrueForgeUIShell component imports for improved readability. Enhance AgentsLibrary test to handle schedule loading more effectively. --- packages/trueforge-ui/docs/compatibility.md | 10 +++++----- .../src/containers/TrueForgeUIShell.tsx | 5 ++++- .../test/atoms/AgentsLibrary.test.tsx | 17 +++++++++-------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/trueforge-ui/docs/compatibility.md b/packages/trueforge-ui/docs/compatibility.md index 0f5eabf62..e5e0d6a6a 100644 --- a/packages/trueforge-ui/docs/compatibility.md +++ b/packages/trueforge-ui/docs/compatibility.md @@ -15,12 +15,12 @@ Install once at the app root. ## Bundled dependencies (installed with the SDK) -| Package | Range | -| ----------------------------------- | ----------------- | -| `@assistant-ui/core` | `^0.2.22` | -| `@assistant-ui/react` | `^0.14.24` | +| Package | Range | +| --------------------------------------------- | ----------------- | +| `@assistant-ui/core` | `^0.2.22` | +| `@assistant-ui/react` | `^0.14.24` | | `@truefoundry/trueforge-assistant-ui-runtime` | workspace release | -| `lucide-react` | `^0.562.0` | +| `lucide-react` | `^0.562.0` | `@assistant-ui/*` are also listed as **optional** peers so hosts that install them directly for customization stay on a compatible range. diff --git a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx index d52652c40..8c7c74699 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx @@ -1,6 +1,9 @@ 'use client'; -import type { TrueForgeAgentConfig, UseTrueForgeAgentRuntimeOptions } from '@truefoundry/trueforge-assistant-ui-runtime'; +import type { + TrueForgeAgentConfig, + UseTrueForgeAgentRuntimeOptions, +} from '@truefoundry/trueforge-assistant-ui-runtime'; import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { ThinkingOrb } from 'thinking-orbs'; diff --git a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx index 19f1b8cb9..c02976bdd 100644 --- a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx @@ -662,13 +662,14 @@ describe('AgentsLibraryButton', () => { }); it('does not show an empty-schedules action before schedule counts load', async () => { - let resolveSchedules: (value: { data: [] }) => void = () => undefined; - const listSchedules = vi.fn( - () => - new Promise<{ data: [] }>(resolve => { - resolveSchedules = resolve; - }), - ); + let releaseSchedules!: () => void; + const schedulesGate = new Promise(resolve => { + releaseSchedules = resolve; + }); + const listSchedules = vi.fn(async () => { + await schedulesGate; + return { data: [] as const }; + }); const server = createMockAgentUIServer({ searchAgents: vi.fn(async () => [{ name: 'alpha-agent', agentId: 'alpha-agent' }]), schedules: { @@ -689,7 +690,7 @@ describe('AgentsLibraryButton', () => { expect(screen.getByLabelText('Schedule count unavailable for alpha-agent')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add schedule for alpha-agent' })).not.toBeInTheDocument(); - resolveSchedules({ data: [] }); + releaseSchedules(); expect(await screen.findByRole('button', { name: 'Add schedule for alpha-agent' })).toBeInTheDocument(); }); From f018276535ac5bca84ede53ad489366b62eb52c1 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Mon, 21 Sep 2026 17:26:12 +0530 Subject: [PATCH 4/6] Enhance Dockerfile and CI configuration to include assistant-ui-runtime package. Update build and workflow settings to ensure proper integration and testing of the new package. --- .github/workflows/ci.yml | 11 +++++++++++ Dockerfile.dev | 2 ++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8992ebf6a..f89a36777 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,12 +56,21 @@ jobs: - 'packages/frontend/**' # trueforge-sdk is workspace:* (no version bump); SDK-only PRs must still run FE checks. - 'packages/trueforge-sdk/**' + # trueforge-ui depends on the runtime workspace package. + - 'packages/assistant-ui-runtime/**' - 'package.json' - 'pnpm-workspace.yaml' - 'tsconfig.base.json' - '.github/workflows/ci.yml' trueforge-ui: - 'packages/trueforge-ui/**' + - 'packages/assistant-ui-runtime/**' + - 'package.json' + - 'pnpm-workspace.yaml' + - 'tsconfig.base.json' + - '.github/workflows/ci.yml' + assistant-ui-runtime: + - 'packages/assistant-ui-runtime/**' - 'package.json' - 'pnpm-workspace.yaml' - 'tsconfig.base.json' @@ -96,6 +105,7 @@ jobs: TRUEFORGE: ${{ steps.filter.outputs.trueforge }} FRONTEND: ${{ steps.filter.outputs.frontend }} TRUEFORGE_UI: ${{ steps.filter.outputs['trueforge-ui'] }} + ASSISTANT_UI_RUNTIME: ${{ steps.filter.outputs['assistant-ui-runtime'] }} run: | node <<'EOF' >> "$GITHUB_OUTPUT" const changedPackages = []; @@ -103,6 +113,7 @@ jobs: if (process.env.TRUEFORGE === 'true') changedPackages.push('trueforge'); if (process.env.FRONTEND === 'true') changedPackages.push('frontend'); if (process.env.TRUEFORGE_UI === 'true') changedPackages.push('trueforge-ui'); + if (process.env.ASSISTANT_UI_RUNTIME === 'true') changedPackages.push('assistant-ui-runtime'); console.log(`list=${JSON.stringify(changedPackages)}`); EOF diff --git a/Dockerfile.dev b/Dockerfile.dev index 60ac1b891..3e44d270b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -41,6 +41,7 @@ COPY packages/trueforge/package.json packages/trueforge/package.json COPY packages/trueforge-sdk/package.json packages/trueforge-sdk/package.json COPY packages/frontend/package.json packages/frontend/package.json COPY packages/trueforge-ui/package.json packages/trueforge-ui/package.json +COPY packages/assistant-ui-runtime/package.json packages/assistant-ui-runtime/package.json COPY packages/trueforge-core/scripts packages/trueforge-core/scripts COPY packages/trueforge-core/src/core/sandbox/scripts packages/trueforge-core/src/core/sandbox/scripts @@ -65,6 +66,7 @@ RUN pnpm --filter @truefoundry/trueforge-core build && pnpm --filter @truefoundr FROM workspace AS frontend-builder RUN pnpm install --frozen-lockfile --offline --filter frontend... COPY packages/trueforge-sdk packages/trueforge-sdk +COPY packages/assistant-ui-runtime packages/assistant-ui-runtime COPY packages/trueforge-ui packages/trueforge-ui RUN pnpm --filter @truefoundry/trueforge-ui build COPY packages/frontend packages/frontend From 957b1c1ffde804c85cbe9b33f7ce25412d7bed63 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Mon, 21 Sep 2026 17:28:04 +0530 Subject: [PATCH 5/6] Remove CHANGELOG.md and downgrade version to 0.1.0 in package.json for assistant-ui-runtime package. --- packages/assistant-ui-runtime/CHANGELOG.md | 201 --------------------- packages/assistant-ui-runtime/package.json | 2 +- 2 files changed, 1 insertion(+), 202 deletions(-) delete mode 100644 packages/assistant-ui-runtime/CHANGELOG.md diff --git a/packages/assistant-ui-runtime/CHANGELOG.md b/packages/assistant-ui-runtime/CHANGELOG.md deleted file mode 100644 index 348deec1c..000000000 --- a/packages/assistant-ui-runtime/CHANGELOG.md +++ /dev/null @@ -1,201 +0,0 @@ -# Changelog - -## 0.1.43 - -### Added - -- `AgentRuntimeConfig.webSearch` and `AgentBuilderCapabilitiesResponse.data.webSearch` for host web-search capability gating. - -## 0.1.42 - -### Added - -- Optional `AgentChatServer.renameSession`. When present, thread-list adapters persist rename through it. - -## 0.1.41 - -### Breaking - -- `AgentChatServer.downloadSandboxFile` now receives `sandboxId?: string` (optional) instead of `sandboxId: string`. Host implementations must handle a missing `sandboxId`; turn-scoped hosts resolve the sandbox from `(sessionId, turnId)`. - -### Fixed - -- **Continuation turns no longer leak a local turn id** — approval / ask-user / MCP-auth resume turns now rename their placeholder id to the gateway-assigned id (`turn.created`), and fall back to the last committed turn's id instead of minting a fresh local one. Previously the fabricated id (e.g. `Vu3Hyio`) reached hosts via `custom.turnId`, breaking turn-scoped sandbox downloads ("Turn not found") and edit/retry. -- **Sandbox artifact download after resume** — `downloadSandboxFile` no longer hard-requires a client-known `sandboxId`. Turn-scoped hosts resolve the sandbox from `(sessionId, turnId)`; `sandboxId` is optional on the request and passed best-effort. Hydration forward-propagates the session-scoped `sandboxId` across turns in the loaded window (and backfills tip turns when older pages reveal `sandbox.created`), and downloads resolve the sandbox that was current as of the artifact's turn, paging in older history when its reference predates the loaded window. -- **Session switch no longer corrupts sandbox history page-in** — stale `loadOlderHistory` / `resolveSandboxIdForTurn` closures abort when the live session id changes, so a post-switch iteration cannot merge another session's pages onto the current snapshot. - -## 0.1.39 - -### Breaking - -- `ListPermissionsResponse.data` is now `{ type, permissions }` instead of a flat id→grants map. -- `PermissionResourceType` includes `tenant`; `ResourcePermission` includes `CREATE` (tenant entity-kind grants). - -### Fixed - -- Assistant messages use the turn completion time instead of the turn start time. -- Detaching from a turn stream (session switch, reload, remount) no longer cancels the backend run; only an explicit cancel does. - -## 0.1.37 - -### Added - -- Optional `description` on `AgentSelectorEntry` / `AgentLibraryEntry`, `AgentDetail`, and `SaveAgentRequest`. - -### Changed - -- The bundled Control Plane adapter maps description as a top-level library/save field and still writes it onto the CP `manifest.description` wire field. - -## 0.1.36 - -### Added - -- Optional `CreatedBySubject` on `AgentSelectorEntry` / `AgentLibraryEntry` and `Schedule` so hosts can surface a Created-by column. - -## 0.1.33 - -### Breaking - -- Removed `AgentBuilderServer.listMcp`; `getMcp` returns the complete MCP catalog. - -## 0.1.30 - -### Changed - -- `SessionListMetrics.totalCostInUsd` is optional so hosts can omit unavailable session costs. - -## 0.1.29 - -### Added - -- **`AgentBuilderServer.listMcp?(req?: PageParams): Promise>`** — optional token-paginated MCP catalog for picker infinite scroll. Hosts that omit it keep using `getMcp()`. - -## 0.1.28 - -### Added - -- Optional `PermissionsServer` port for batch MANAGE/DELETE grants on agents, schedules, and sessions. - -## 0.1.25 - -### Breaking - -- **`ScheduleServer.listSchedules` returns `ListResult`** instead of `TSchedule[]` (token pagination via `limit` / `pageToken` / `nextPageToken`). -- **`ListSchedulesParams`** adds optional `agentIds`, `limit`, and `pageToken` (still accepts single `agentId`). - -### Added - -- **`ScheduleRun` / `ScheduleRunStatus` / `CreateScheduleRunRequest`** — flat run DTOs for last-run chips and manual “run now”. -- **`ScheduleServer.listScheduleRuns` / `createScheduleRun`** — optional schedule-run surface for hosts that support manual triggers. -- Advanced agent configuration contracts for model limits, model parameters, runtime settings, and lazy MCP tool selection (`McpToolSelection`, optional `AgentBuilderServer.getMcpTools`). -- TrueForge builder adapter enrichment for model metadata and MCP tool discovery. -- `AgentInputTokensCompactionTrigger` and `AgentCompactionConfig` types. - -## 0.1.18 - -### Added - -- **`getModels` reasoning efforts** — `listEnabledModels` also fetches `/api/svc/v1/provider-accounts/providers` and sets `properties.reasoningEfforts` for thinking-capable models (same visibility rules as ai.tf: skip exact `openai` slug, require `thinking: true`, honor `removeParams`). - -### Fixed - -- **Extras ancestor walk vs RootAssistantClient** — `tryGetTrueForgeExtras` / `useTrueForgeRuntimeExtras` try/catch Proxy gets when walking `Object.create(parent)` AUI clients, so hitting the store’s RootAssistantClient prototype no longer throws `The current scope does not have a "subscribe" property`. `trueForgeExtras.get` / `.use` use the same walk (no nested-UI footgun). -- **`getModels` latency** — enabled-models and provider-metadata CP fetches run in parallel; providers soft-fails without blocking the model list. -- **Model selector `name` is model_fqn** — `normalizeEnabledModels` sets `ModelSelectorEntry.name` (and `id`) to `model_fqn`, so trueforge-ui’s DraftModelSelector writes `AgentSpec.model.name` as `account/model-id` instead of the short CP display label. -- **Gateway mount sanitization** — `normalizeMcpMount` / `normalizeSkillMount` rebuild allowlisted registry/git/inline shapes instead of blind-passthrough, so FE `id` / display `name` / registry `url` never leak onto draft create/update. Registry MCP mounts default `enableTools` to `["@all"]` when missing (narrow lists still preserved). - -## 0.1.12 - -### Breaking - -- **`SandboxCatalogServer` method renames (sandbox → sandbox provider)** - `getSandboxCatalog` → `getSandboxProviderCatalog`, `listSandboxes` → `listSandboxProviders`, `createSandbox` → `createSandboxProvider`, `updateSandbox` → `updateSandboxProvider`, `deleteSandbox` → `deleteSandboxProvider?` (now optional). Host aliases (`SandboxProviderConfig`, `SandboxProviderCatalogEntry`, `SandboxProviderBase`, `CreateSandboxProviderRequest`, `UpdateSandboxProviderRequest`) map to the existing DTO names. - -- **`SandboxBase` now extends `SandboxConfig`** - Connected rows always carry last-saved provider settings so update forms can show previous values. - -- **`UpdateSandboxRequest.apiKey` is optional** - Omit to keep the existing key; send a value to rotate. - -- **`ConnectorBase.tools` removed** - Tools are no longer embedded on the connector row. Fetch with new required `ConnectorCatalogServer.getToolsByConnectorId({ id })`. `ConnectorBase` is no longer generic in `TTool`. - -- **`ConnectorCatalogServer` adds required `getConnector` / `getToolsByConnectorId`** - Implement both on every catalog adapter. - -- **`authenticateConnector` request/return changed** - Request is `AuthenticateConnectorRequest` (`id` + optional `returnTo`). Return is `TConnector | ConnectorAuthenticationResult` — either an authenticated connector (or one carrying `auth.authUrl`) or a result with `authorization_endpoint` for the popup flow. - -- **`ConnectorAuthPublicOAuth.authUrl` is optional** - Public dcr rows may omit `authUrl` when auth is started via `authorization_endpoint` instead. (Supersedes the 0.1.8 note that public dcr required `authUrl`.) - -### Added - -- **Sole ownership of server-port types** — this package is the canonical home for `AgentChatServer` / `AgentBuilderServer` / catalog ports and DTOs. Hosts (e.g. trueforge-ui) should re-export, not fork. -- **`AgentUIServer`** — host-facing alias of `AgentUIServerPort`. -- **Selector / compose aliases** — `ModelSelection`, `AgentSkill`, `ConnectorState`, `AgentLibraryEntry`, `SearchAgentsParams` (aliases of the `*SelectorEntry` / `SearchAgentSelectorParams` names). -- **Root exports** for builder/catalog types previously internal to `server/` (`SaveAgentRequest`, `AgentBuilderCapabilitiesResponse`, `AuthenticateConnectorRequest`, `ConnectorAuthenticationResult`, mounts, selector entries, sandbox-provider aliases, etc.). -- **`ModelSelectorEntry.providerLogo?`**, **`ConnectorSelectorEntry.requiresAuth?` / `authenticated?`**, **`ModelProviderCatalogEntry.supportedReasoningEfforts?` / `logo?`**, **`ConnectorCatalogEntry.logo?`**. -- **`SaveAgentRequest`** — named request body for `AgentBuilderServer.saveAgent`. -- **`createTrueForgeAgentUIServer.getCapabilities`** — returns sandbox / skill / settings enabled (stub; always `true` for this pack). -- **`PreviousTurnIdInput` documents `"none"`** alongside `"auto"` and turn ids. - -### Fixed - -- **`sessionToThreadMetadata`** — always stamps `custom.isMutable` from the session (plus `agentName` when present). History UIs can trust mutability without inferring it from `agentName`, so ref sessions whose agent was deleted stay immutable. - -## 0.1.10 - -### Breaking - -- **`ListSessionsParams.agentName` → `agentId`** - Thread-list adapters and the TFY chat server forward the optional filter as `agentId`. Hosts that previously keyed list filters by name should pass that same value as `agentId` (or via `listSessionsAgentId` on the runtime / adapters). - -- **Draft thread list no longer filters `isMutable` client-side** - `createTrueForgeDraftThreadListAdapter.list` returns every session from `listSessions`. Title named (immutable) rows with `title → agentName → id`, not the draft model name. - -### Added - -- **`listSessionsAgentId?: string`** on `useTrueForgeAgentRuntime` and all three thread-list adapters (`createTrueForgeThreadListAdapter`, `createTrueForgeDraftThreadListAdapter`, `createTrueForgeOwnedSessionsThreadListAdapter`). Omit to list all chats; set to filter `listSessions({ agentId })`. -- **`AgentSelectorEntry.agentId?` / `agentSpec?`** — CP `normalizeAgents` fills these from agent id + `latestVersionDetails.manifest` so hosts can offer Edit when a published spec is present. -- **`agentSpecFromCpManifest` / `toCamelCaseDeep`** — map CP AgentManifest (snake_case) → FE `TfyAgentSpec` for Edit seeding, preserving agent `config` and mount `enableTools` / `preload` / `config`. -- **`sessionDisplayTitle` / `sessionToThreadMetadata`** — shared session → thread-list metadata helpers; named sessions expose `custom.agentName` when present. - -### Fixed - -- **Edit → Save round-trip** — catalog mounts from CP keep runtime fields; `normalizeMcpMount` / `normalizeSkillMount` forward `enableTools`, `preload`, and `config` so save no longer defaults MCP tools to `@all`, forces skill `preload: false`, or drops config. -- **Untitled named sessions in draft history** — mixed lists use `sessionDisplayTitle` so immutable rows fall back to `agentName` / `id` instead of `defaultAgentSpec.model.name`. - -### Changed - -- Bump `@assistant-ui/core` to `^0.2.22` and `@assistant-ui/store` to `^0.2.21`. - -## 0.1.9 - -### Added - -- **Sandboxes catalog** — new `SandboxCatalogServer` port and DTOs (`SandboxConfig`, `SandboxCatalogEntry`, `SandboxBase`, `CreateSandboxRequest`, `UpdateSandboxRequest`). Exposed as the optional `CatalogServer.sandboxCatalog` sub-port. `SandboxConfig` holds the mutable settings (`snapshotName`, `execTimeoutMs`, `autoStopIntervalInMinutes`, `autoArchiveIntervalInMinutes`, `autoDeleteIntervalInMinutes`) shared by catalog rows, create, and update; create adds `apiKey`. -- **Skills catalog** — `SkillCatalogServer.getSkillCatalog()` plus registry/github skill DTOs (`RegistrySkill`, `GithubSkill`, `DefinedSkill`, `SkillConfigBase`, `SkillCatalogEntry`, `CreateSkillRequestBase`, `SelectRegistrySkillRequest`, `ImportGithubSkillRequest`). Registry vs github is distinguished by `catalogId` presence, not a `type` field. -- **`ModelSelectorEntry.reasoningEfforts?: string[]`** and **`ToolBase.description: string`**. - -## 0.1.8 - -### Breaking - -- **`AgentChatServer.prepareAndExecuteTurn` → `createTurn`** - Rename the method on every `AgentChatServer` implementation and mock. Signature and return type are unchanged. - -- **`ConnectorAuthType` is now `"dcr" | "header" | "none"`** - Previously an open `string` with documented display labels (`"None"`, `"OAuth"`, `"API Key"`). Map host/wire values to these literals. - -- **`ConnectorAuth` / `ConnectorAuthPublic` are discriminated unions** - - Write: `{ type: "dcr"; authUrl?: string } | { type: "header"; apiKey?: string; headerName?: string } | { type: "none" }` - - Public: dcr branch requires `authUrl: string` (no secrets). - Branch types (`ConnectorAuthOAuth`, `ConnectorAuthPublicOAuth`, …) are exported so hosts can intersect extras and re-union, e.g. `ConnectorAuthPublicOAuth & { custom: string }`. - -- **`ConnectorBase.requiresAuth: boolean`** - Required alongside existing `authenticated`. When `requiresAuth` is true, UI should not show Disconnect. - -### Notes - -- After `authenticateConnector`, the authorize URL is on `connector.auth.authUrl` when `auth.type === "dcr"` (not a separate widened return field). diff --git a/packages/assistant-ui-runtime/package.json b/packages/assistant-ui-runtime/package.json index 6b4a2c0be..033045aad 100644 --- a/packages/assistant-ui-runtime/package.json +++ b/packages/assistant-ui-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@truefoundry/trueforge-assistant-ui-runtime", - "version": "0.1.43", + "version": "0.1.0", "description": "TrueForge agent runtime adapter for assistant-ui", "author": "TrueFoundry", "license": "Apache-2.0", From 0d4d691a940996aa537fcf62e7ed7bb2eb595304 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Mon, 21 Sep 2026 17:38:02 +0530 Subject: [PATCH 6/6] Refactor AgentsLibrary test to return a more explicit data structure for schedules, improving clarity in test behavior. --- packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx index c02976bdd..346bed645 100644 --- a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx @@ -668,7 +668,7 @@ describe('AgentsLibraryButton', () => { }); const listSchedules = vi.fn(async () => { await schedulesGate; - return { data: [] as const }; + return { data: [] }; }); const server = createMockAgentUIServer({ searchAgents: vi.fn(async () => [{ name: 'alpha-agent', agentId: 'alpha-agent' }]),