From bbd972b367cf6f0dc4f778b7bac8afc706d797dc Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 22 Sep 2026 08:32:44 +0800 Subject: [PATCH 1/3] Integrate Agents Core Web into monorepo --- .env.example | 54 + .github/workflows/check.yml | 4 + .gitignore | 5 + AGENTS.md | 7 +- CONTRIBUTING.md | 12 +- Makefile | 12 +- README.md | 25 +- apps/web/e2e/agents-lifecycle.spec.ts | 3205 +++++++++ apps/web/e2e/core-connection.spec.ts | 456 ++ apps/web/e2e/fixture-core.mjs | 1648 +++++ apps/web/e2e/vault-credentials.spec.ts | 244 + apps/web/index.html | 34 + apps/web/package.json | 27 + apps/web/public/favicon.png | Bin 0 -> 7963 bytes apps/web/public/parsar-mark-dark.png | Bin 0 -> 28899 bytes apps/web/public/parsar-mark-light.png | Bin 0 -> 32993 bytes apps/web/src/App.tsx | 2227 +++++++ apps/web/src/components/ConnectionModal.css | 432 ++ .../src/components/ConnectionModal.test.tsx | 243 + apps/web/src/components/ConnectionModal.tsx | 464 ++ apps/web/src/components/CreateMenu.test.tsx | 25 + apps/web/src/components/CreateMenu.tsx | 122 + apps/web/src/components/ErrorState.tsx | 48 + apps/web/src/components/MessageMarkdown.tsx | 11 + apps/web/src/components/Modal.tsx | 127 + .../src/components/ProductNavigation.test.tsx | 31 + apps/web/src/components/ProductNavigation.tsx | 42 + apps/web/src/components/Skeleton.tsx | 5 + apps/web/src/components/StatusIcon.tsx | 69 + apps/web/src/components/ThemeMenu.tsx | 32 + apps/web/src/components/Toast.test.ts | 49 + apps/web/src/components/Toast.tsx | 237 + .../features/CoreCollectionStates.test.tsx | 489 ++ apps/web/src/features/agents/AgentCatalog.css | 263 + .../src/features/agents/AgentCatalog.test.tsx | 72 + apps/web/src/features/agents/AgentCatalog.tsx | 227 + apps/web/src/features/agents/AgentDialog.tsx | 9 + apps/web/src/features/agents/AgentForm.tsx | 355 + .../features/agents/AgentSetupView.test.tsx | 124 + .../src/features/agents/AgentSetupView.tsx | 219 + .../src/features/agents/AgentsView.test.tsx | 147 + apps/web/src/features/agents/AgentsView.tsx | 427 ++ .../src/features/agents/agent-actions.test.ts | 64 + apps/web/src/features/agents/agent-actions.ts | 30 + .../src/features/agents/agent-form.test.ts | 280 + apps/web/src/features/agents/agent-form.ts | 369 ++ .../src/features/agents/agent-preview.test.ts | 104 + apps/web/src/features/agents/agent-preview.ts | 77 + .../features/agents/agent-templates.test.ts | 41 + .../src/features/agents/agent-templates.ts | 58 + .../features/agents/session-admission.test.ts | 210 + .../src/features/agents/session-admission.ts | 202 + .../src/features/dashboard/DashboardView.css | 724 ++ .../features/dashboard/DashboardView.test.tsx | 247 + .../src/features/dashboard/DashboardView.tsx | 424 ++ .../dashboard/dashboard-model.test.ts | 231 + .../src/features/dashboard/dashboard-model.ts | 231 + .../src/features/sessions/SessionsView.tsx | 920 +++ .../sessions/actions/FunctionActionPanel.css | 198 + .../actions/FunctionActionPanel.test.tsx | 177 + .../sessions/actions/FunctionActionPanel.tsx | 385 ++ .../actions/SessionActionsDialog.test.tsx | 75 + .../sessions/actions/SessionActionsDialog.tsx | 346 + .../sessions/actions/session-actions.test.ts | 341 + .../sessions/actions/session-actions.ts | 444 ++ .../create/SessionInitialInputEditor.css | 178 + .../create/SessionInitialInputEditor.test.tsx | 83 + .../create/SessionInitialInputEditor.tsx | 270 + .../sessions/create/SessionStartDialog.css | 159 + .../create/SessionStartDialog.test.tsx | 219 + .../sessions/create/SessionStartDialog.tsx | 740 +++ .../sessions/create/SessionToolsEditor.tsx | 141 + .../create/session-create-attempt.test.ts | 195 + .../sessions/create/session-create-attempt.ts | 66 + .../create/session-environment.test.ts | 128 + .../sessions/create/session-environment.ts | 122 + .../create/session-initial-input.test.ts | 241 + .../sessions/create/session-initial-input.ts | 204 + .../create/session-start-draft.test.ts | 254 + .../sessions/create/session-start-draft.ts | 341 + .../environment/EnvironmentDialog.test.tsx | 66 + .../environment/EnvironmentDialog.tsx | 53 + .../EnvironmentFileCreatePanel.css | 121 + .../EnvironmentFileCreatePanel.test.tsx | 154 + .../EnvironmentFileCreatePanel.tsx | 172 + .../environment/EnvironmentFilesPanel.css | 163 + .../EnvironmentFilesPanel.test.tsx | 68 + .../environment/EnvironmentFilesPanel.tsx | 245 + .../environment/EnvironmentPanel.test.tsx | 559 ++ .../sessions/environment/EnvironmentPanel.tsx | 562 ++ .../environment/environment-file-create.ts | 141 + .../environment/environment-launcher.test.ts | 78 + .../environment/environment-launcher.ts | 177 + .../environment/environment-state.test.ts | 410 ++ .../sessions/environment/environment-state.ts | 363 + .../environment/environment-templates.test.ts | 96 + .../environment/environment-templates.ts | 76 + .../sessions/items/ApplyPatchDiffViewer.tsx | 59 + .../features/sessions/items/ItemRenderers.tsx | 151 + .../sessions/items/apply-patch.test.tsx | 89 + .../features/sessions/items/apply-patch.ts | 73 + .../features/sessions/pending-message.test.ts | 51 + .../src/features/sessions/pending-message.ts | 42 + .../sessions/trace/TraceView.test.tsx | 142 + .../src/features/sessions/trace/TraceView.tsx | 546 ++ .../sessions/trace/trace-model.test.ts | 423 ++ .../features/sessions/trace/trace-model.ts | 738 +++ .../sessions/turns/TurnTimeline.test.tsx | 118 + .../features/sessions/turns/TurnTimeline.tsx | 234 + .../sessions/turns/turn-state.test.ts | 136 + .../src/features/sessions/turns/turn-state.ts | 120 + .../src/features/system/SourceFilesPanel.css | 222 + .../features/system/SourceFilesPanel.test.tsx | 60 + .../src/features/system/SourceFilesPanel.tsx | 487 ++ apps/web/src/features/system/SystemView.css | 74 + .../src/features/system/SystemView.test.tsx | 111 + apps/web/src/features/system/SystemView.tsx | 152 + .../features/vaults/CredentialDialog.test.tsx | 24 + .../src/features/vaults/CredentialDialog.tsx | 157 + .../src/features/vaults/VaultCreateForm.css | 20 + .../features/vaults/VaultCreateForm.test.tsx | 70 + .../src/features/vaults/VaultCreateForm.tsx | 154 + .../src/features/vaults/VaultsView.test.tsx | 49 + apps/web/src/features/vaults/VaultsView.tsx | 203 + .../src/features/vaults/vault-catalog.test.ts | 141 + apps/web/src/features/vaults/vault-catalog.ts | 250 + .../features/vaults/vault-metadata.test.ts | 57 + .../web/src/features/vaults/vault-metadata.ts | 66 + .../features/vaults/vault-operations.test.ts | 43 + .../src/features/vaults/vault-operations.ts | 26 + apps/web/src/lib/ThemeProvider.tsx | 53 + apps/web/src/lib/collection-load.test.ts | 37 + apps/web/src/lib/collection-load.ts | 11 + .../web/src/lib/collection-pagination.test.ts | 103 + apps/web/src/lib/collection-pagination.ts | 60 + apps/web/src/lib/connection.test.ts | 175 + apps/web/src/lib/connection.ts | 101 + apps/web/src/lib/core-probe.test.ts | 383 ++ apps/web/src/lib/core-probe.ts | 220 + apps/web/src/lib/core-readiness.test.ts | 18 + apps/web/src/lib/core-readiness.ts | 24 + apps/web/src/lib/docker-guide-config.test.ts | 95 + apps/web/src/lib/docker-guide-config.ts | 103 + apps/web/src/lib/model-options.test.ts | 50 + apps/web/src/lib/model-options.ts | 64 + .../src/lib/pending-function-result.test.ts | 75 + apps/web/src/lib/pending-function-result.ts | 67 + apps/web/src/lib/pending-send.test.ts | 75 + apps/web/src/lib/pending-send.ts | 54 + apps/web/src/lib/session-items.test.ts | 134 + apps/web/src/lib/session-items.ts | 108 + apps/web/src/lib/session-recovery.test.ts | 98 + apps/web/src/lib/session-recovery.ts | 134 + apps/web/src/lib/stream-reconnect.test.ts | 129 + apps/web/src/lib/stream-reconnect.ts | 86 + apps/web/src/lib/theme.ts | 20 + apps/web/src/lib/use-thread-scroll.ts | 73 + apps/web/src/lib/vite-auth.test.ts | 75 + apps/web/src/main.tsx | 18 + apps/web/src/style.css | 5879 +++++++++++++++++ apps/web/src/vite-env.d.ts | 28 + apps/web/tsconfig.json | 10 + apps/web/vite-auth.ts | 80 + apps/web/vite.config.ts | 60 + docs/web/README.md | 162 + docs/web/README.zh-CN.md | 152 + docs/web/architecture.md | 353 + docs/web/core-connection.md | 869 +++ docs/web/images/agents.png | Bin 0 -> 107555 bytes docs/web/images/dashboard.png | Bin 0 -> 94969 bytes docs/web/images/sessions.png | Bin 0 -> 67703 bytes docs/web/images/system.png | Bin 0 -> 51861 bytes docs/web/protocol-coverage.md | 677 ++ docs/web/roadmap.md | 40 + package.json | 10 +- packages/agents-client/package.json | 18 + packages/agents-client/src/client.test.ts | 2388 +++++++ packages/agents-client/src/client.ts | 2434 +++++++ .../src/environment-templates.test.ts | 248 + .../environment-resources.json | 21 + .../parsar-0438880a/turn-resources.json | 92 + .../src/fixtures/parsar-182d333d/files.json | 41 + .../parsar-182d333d/vault-credentials.json | 73 + .../parsar-2b34ea46/tool-profiles.json | 71 + .../parsar-8cc2898c/environment-protocol.json | 126 + .../parsar-c31f8167/environment-files.json | 32 + .../environment-files-self-hosted.json | 21 + .../parsar-dadf64a7/openai-hosted.json | 87 + .../parsar-dadf64a7/session-event-batch.json | 36 + packages/agents-client/src/index.ts | 5 + .../agents-client/src/protocol-types.test.ts | 373 ++ packages/agents-client/src/sse.test.ts | 52 + packages/agents-client/src/sse.ts | 98 + packages/agents-client/src/types.ts | 730 ++ packages/agents-client/tsconfig.json | 8 + playwright.config.ts | 44 + pnpm-lock.yaml | 2036 +++++- pnpm-workspace.yaml | 3 +- provenance/README.md | 19 +- provenance/web-import-files.json | 1000 +++ provenance/web-source.json | 92 + provenance/web-working-tree.patch.b64 | 1 + scripts/core-doctor.mjs | 943 +++ scripts/core-doctor.test.mjs | 758 +++ scripts/fixtures/core-doctor/agents-list.json | 7 + .../core-doctor/daemon-status-absent.txt | 4 + .../core-doctor/daemon-status-paired.txt | 7 + .../fixtures/core-doctor/keys-matched.json | 10 + .../fixtures/core-doctor/keys-mismatch.json | 10 + .../core-doctor/redaction-corpus.json | 11 + scripts/verify-web-source.py | 116 + tsconfig.base.json | 17 + 212 files changed, 53467 insertions(+), 68 deletions(-) create mode 100644 .env.example create mode 100644 apps/web/e2e/agents-lifecycle.spec.ts create mode 100644 apps/web/e2e/core-connection.spec.ts create mode 100644 apps/web/e2e/fixture-core.mjs create mode 100644 apps/web/e2e/vault-credentials.spec.ts create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.png create mode 100644 apps/web/public/parsar-mark-dark.png create mode 100644 apps/web/public/parsar-mark-light.png create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/ConnectionModal.css create mode 100644 apps/web/src/components/ConnectionModal.test.tsx create mode 100644 apps/web/src/components/ConnectionModal.tsx create mode 100644 apps/web/src/components/CreateMenu.test.tsx create mode 100644 apps/web/src/components/CreateMenu.tsx create mode 100644 apps/web/src/components/ErrorState.tsx create mode 100644 apps/web/src/components/MessageMarkdown.tsx create mode 100644 apps/web/src/components/Modal.tsx create mode 100644 apps/web/src/components/ProductNavigation.test.tsx create mode 100644 apps/web/src/components/ProductNavigation.tsx create mode 100644 apps/web/src/components/Skeleton.tsx create mode 100644 apps/web/src/components/StatusIcon.tsx create mode 100644 apps/web/src/components/ThemeMenu.tsx create mode 100644 apps/web/src/components/Toast.test.ts create mode 100644 apps/web/src/components/Toast.tsx create mode 100644 apps/web/src/features/CoreCollectionStates.test.tsx create mode 100644 apps/web/src/features/agents/AgentCatalog.css create mode 100644 apps/web/src/features/agents/AgentCatalog.test.tsx create mode 100644 apps/web/src/features/agents/AgentCatalog.tsx create mode 100644 apps/web/src/features/agents/AgentDialog.tsx create mode 100644 apps/web/src/features/agents/AgentForm.tsx create mode 100644 apps/web/src/features/agents/AgentSetupView.test.tsx create mode 100644 apps/web/src/features/agents/AgentSetupView.tsx create mode 100644 apps/web/src/features/agents/AgentsView.test.tsx create mode 100644 apps/web/src/features/agents/AgentsView.tsx create mode 100644 apps/web/src/features/agents/agent-actions.test.ts create mode 100644 apps/web/src/features/agents/agent-actions.ts create mode 100644 apps/web/src/features/agents/agent-form.test.ts create mode 100644 apps/web/src/features/agents/agent-form.ts create mode 100644 apps/web/src/features/agents/agent-preview.test.ts create mode 100644 apps/web/src/features/agents/agent-preview.ts create mode 100644 apps/web/src/features/agents/agent-templates.test.ts create mode 100644 apps/web/src/features/agents/agent-templates.ts create mode 100644 apps/web/src/features/agents/session-admission.test.ts create mode 100644 apps/web/src/features/agents/session-admission.ts create mode 100644 apps/web/src/features/dashboard/DashboardView.css create mode 100644 apps/web/src/features/dashboard/DashboardView.test.tsx create mode 100644 apps/web/src/features/dashboard/DashboardView.tsx create mode 100644 apps/web/src/features/dashboard/dashboard-model.test.ts create mode 100644 apps/web/src/features/dashboard/dashboard-model.ts create mode 100644 apps/web/src/features/sessions/SessionsView.tsx create mode 100644 apps/web/src/features/sessions/actions/FunctionActionPanel.css create mode 100644 apps/web/src/features/sessions/actions/FunctionActionPanel.test.tsx create mode 100644 apps/web/src/features/sessions/actions/FunctionActionPanel.tsx create mode 100644 apps/web/src/features/sessions/actions/SessionActionsDialog.test.tsx create mode 100644 apps/web/src/features/sessions/actions/SessionActionsDialog.tsx create mode 100644 apps/web/src/features/sessions/actions/session-actions.test.ts create mode 100644 apps/web/src/features/sessions/actions/session-actions.ts create mode 100644 apps/web/src/features/sessions/create/SessionInitialInputEditor.css create mode 100644 apps/web/src/features/sessions/create/SessionInitialInputEditor.test.tsx create mode 100644 apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx create mode 100644 apps/web/src/features/sessions/create/SessionStartDialog.css create mode 100644 apps/web/src/features/sessions/create/SessionStartDialog.test.tsx create mode 100644 apps/web/src/features/sessions/create/SessionStartDialog.tsx create mode 100644 apps/web/src/features/sessions/create/SessionToolsEditor.tsx create mode 100644 apps/web/src/features/sessions/create/session-create-attempt.test.ts create mode 100644 apps/web/src/features/sessions/create/session-create-attempt.ts create mode 100644 apps/web/src/features/sessions/create/session-environment.test.ts create mode 100644 apps/web/src/features/sessions/create/session-environment.ts create mode 100644 apps/web/src/features/sessions/create/session-initial-input.test.ts create mode 100644 apps/web/src/features/sessions/create/session-initial-input.ts create mode 100644 apps/web/src/features/sessions/create/session-start-draft.test.ts create mode 100644 apps/web/src/features/sessions/create/session-start-draft.ts create mode 100644 apps/web/src/features/sessions/environment/EnvironmentDialog.test.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentDialog.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.css create mode 100644 apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentFilesPanel.css create mode 100644 apps/web/src/features/sessions/environment/EnvironmentFilesPanel.test.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentFilesPanel.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentPanel.test.tsx create mode 100644 apps/web/src/features/sessions/environment/EnvironmentPanel.tsx create mode 100644 apps/web/src/features/sessions/environment/environment-file-create.ts create mode 100644 apps/web/src/features/sessions/environment/environment-launcher.test.ts create mode 100644 apps/web/src/features/sessions/environment/environment-launcher.ts create mode 100644 apps/web/src/features/sessions/environment/environment-state.test.ts create mode 100644 apps/web/src/features/sessions/environment/environment-state.ts create mode 100644 apps/web/src/features/sessions/environment/environment-templates.test.ts create mode 100644 apps/web/src/features/sessions/environment/environment-templates.ts create mode 100644 apps/web/src/features/sessions/items/ApplyPatchDiffViewer.tsx create mode 100644 apps/web/src/features/sessions/items/ItemRenderers.tsx create mode 100644 apps/web/src/features/sessions/items/apply-patch.test.tsx create mode 100644 apps/web/src/features/sessions/items/apply-patch.ts create mode 100644 apps/web/src/features/sessions/pending-message.test.ts create mode 100644 apps/web/src/features/sessions/pending-message.ts create mode 100644 apps/web/src/features/sessions/trace/TraceView.test.tsx create mode 100644 apps/web/src/features/sessions/trace/TraceView.tsx create mode 100644 apps/web/src/features/sessions/trace/trace-model.test.ts create mode 100644 apps/web/src/features/sessions/trace/trace-model.ts create mode 100644 apps/web/src/features/sessions/turns/TurnTimeline.test.tsx create mode 100644 apps/web/src/features/sessions/turns/TurnTimeline.tsx create mode 100644 apps/web/src/features/sessions/turns/turn-state.test.ts create mode 100644 apps/web/src/features/sessions/turns/turn-state.ts create mode 100644 apps/web/src/features/system/SourceFilesPanel.css create mode 100644 apps/web/src/features/system/SourceFilesPanel.test.tsx create mode 100644 apps/web/src/features/system/SourceFilesPanel.tsx create mode 100644 apps/web/src/features/system/SystemView.css create mode 100644 apps/web/src/features/system/SystemView.test.tsx create mode 100644 apps/web/src/features/system/SystemView.tsx create mode 100644 apps/web/src/features/vaults/CredentialDialog.test.tsx create mode 100644 apps/web/src/features/vaults/CredentialDialog.tsx create mode 100644 apps/web/src/features/vaults/VaultCreateForm.css create mode 100644 apps/web/src/features/vaults/VaultCreateForm.test.tsx create mode 100644 apps/web/src/features/vaults/VaultCreateForm.tsx create mode 100644 apps/web/src/features/vaults/VaultsView.test.tsx create mode 100644 apps/web/src/features/vaults/VaultsView.tsx create mode 100644 apps/web/src/features/vaults/vault-catalog.test.ts create mode 100644 apps/web/src/features/vaults/vault-catalog.ts create mode 100644 apps/web/src/features/vaults/vault-metadata.test.ts create mode 100644 apps/web/src/features/vaults/vault-metadata.ts create mode 100644 apps/web/src/features/vaults/vault-operations.test.ts create mode 100644 apps/web/src/features/vaults/vault-operations.ts create mode 100644 apps/web/src/lib/ThemeProvider.tsx create mode 100644 apps/web/src/lib/collection-load.test.ts create mode 100644 apps/web/src/lib/collection-load.ts create mode 100644 apps/web/src/lib/collection-pagination.test.ts create mode 100644 apps/web/src/lib/collection-pagination.ts create mode 100644 apps/web/src/lib/connection.test.ts create mode 100644 apps/web/src/lib/connection.ts create mode 100644 apps/web/src/lib/core-probe.test.ts create mode 100644 apps/web/src/lib/core-probe.ts create mode 100644 apps/web/src/lib/core-readiness.test.ts create mode 100644 apps/web/src/lib/core-readiness.ts create mode 100644 apps/web/src/lib/docker-guide-config.test.ts create mode 100644 apps/web/src/lib/docker-guide-config.ts create mode 100644 apps/web/src/lib/model-options.test.ts create mode 100644 apps/web/src/lib/model-options.ts create mode 100644 apps/web/src/lib/pending-function-result.test.ts create mode 100644 apps/web/src/lib/pending-function-result.ts create mode 100644 apps/web/src/lib/pending-send.test.ts create mode 100644 apps/web/src/lib/pending-send.ts create mode 100644 apps/web/src/lib/session-items.test.ts create mode 100644 apps/web/src/lib/session-items.ts create mode 100644 apps/web/src/lib/session-recovery.test.ts create mode 100644 apps/web/src/lib/session-recovery.ts create mode 100644 apps/web/src/lib/stream-reconnect.test.ts create mode 100644 apps/web/src/lib/stream-reconnect.ts create mode 100644 apps/web/src/lib/theme.ts create mode 100644 apps/web/src/lib/use-thread-scroll.ts create mode 100644 apps/web/src/lib/vite-auth.test.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/style.css create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite-auth.ts create mode 100644 apps/web/vite.config.ts create mode 100644 docs/web/README.md create mode 100644 docs/web/README.zh-CN.md create mode 100644 docs/web/architecture.md create mode 100644 docs/web/core-connection.md create mode 100644 docs/web/images/agents.png create mode 100644 docs/web/images/dashboard.png create mode 100644 docs/web/images/sessions.png create mode 100644 docs/web/images/system.png create mode 100644 docs/web/protocol-coverage.md create mode 100644 docs/web/roadmap.md create mode 100644 packages/agents-client/package.json create mode 100644 packages/agents-client/src/client.test.ts create mode 100644 packages/agents-client/src/client.ts create mode 100644 packages/agents-client/src/environment-templates.test.ts create mode 100644 packages/agents-client/src/fixtures/parsar-0438880a/environment-resources.json create mode 100644 packages/agents-client/src/fixtures/parsar-0438880a/turn-resources.json create mode 100644 packages/agents-client/src/fixtures/parsar-182d333d/files.json create mode 100644 packages/agents-client/src/fixtures/parsar-182d333d/vault-credentials.json create mode 100644 packages/agents-client/src/fixtures/parsar-2b34ea46/tool-profiles.json create mode 100644 packages/agents-client/src/fixtures/parsar-8cc2898c/environment-protocol.json create mode 100644 packages/agents-client/src/fixtures/parsar-c31f8167/environment-files.json create mode 100644 packages/agents-client/src/fixtures/parsar-dadf64a7/environment-files-self-hosted.json create mode 100644 packages/agents-client/src/fixtures/parsar-dadf64a7/openai-hosted.json create mode 100644 packages/agents-client/src/fixtures/parsar-dadf64a7/session-event-batch.json create mode 100644 packages/agents-client/src/index.ts create mode 100644 packages/agents-client/src/protocol-types.test.ts create mode 100644 packages/agents-client/src/sse.test.ts create mode 100644 packages/agents-client/src/sse.ts create mode 100644 packages/agents-client/src/types.ts create mode 100644 packages/agents-client/tsconfig.json create mode 100644 playwright.config.ts create mode 100644 provenance/web-import-files.json create mode 100644 provenance/web-source.json create mode 100644 provenance/web-working-tree.patch.b64 create mode 100644 scripts/core-doctor.mjs create mode 100644 scripts/core-doctor.test.mjs create mode 100644 scripts/fixtures/core-doctor/agents-list.json create mode 100644 scripts/fixtures/core-doctor/daemon-status-absent.txt create mode 100644 scripts/fixtures/core-doctor/daemon-status-paired.txt create mode 100644 scripts/fixtures/core-doctor/keys-matched.json create mode 100644 scripts/fixtures/core-doctor/keys-mismatch.json create mode 100644 scripts/fixtures/core-doctor/redaction-corpus.json create mode 100644 scripts/verify-web-source.py create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b58a813f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# Server-side Vite development proxy target. This value is never exposed to +# browser JavaScript. Keep the browser-facing API base at /v1. +AGENTS_API_PROXY_TARGET=http://127.0.0.1:8091 + +# Preferred: plaintext execution-tenant bearer file read only by the local +# Vite server. If unset, Web also checks this conventional path automatically. +AGENTS_API_PROXY_TOKEN_FILE=~/.parsar/agents-api/web-token + +# Alternative server-side value. Never use a VITE_ prefix, and do not set this +# together with AGENTS_API_PROXY_TOKEN_FILE. +# AGENTS_API_PROXY_TOKEN= + +# Public, non-secret opt-in for the reviewed Codex self_hosted Session profile. +# Leave unset unless Core execution, its executor registry, and executor origin +# are configured. This flag is presentation policy, not capability discovery. +# AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 + +# Public, non-secret opt-in for the operator-qualified basic Codex +# openai_hosted Session profile. Leave unset unless Core was started with a +# qualified managed Runtime provider. This flag does not probe runtime readiness. +# AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1 + +# Public, non-secret opt-in for the complete Environment Files profile. Leave +# unset for older Core revisions. Enable only after the connected Core has been +# qualified for Files.list and managed Files.create; self_hosted reads use its +# exact workspace_directory root. +# AGENTS_CORE_WEB_ENVIRONMENT_FILES=1 + +# Optional local-only Docker backend recovery guide shown in the connection +# panel. Web renders copyable `docker start` and loopback health commands; it +# never accesses the Docker socket or executes them. Values are compiled into +# the browser bundle and must contain non-secret container names only. +# AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE=1 +# AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER=parsar-agents-api-web-smoke-db +# AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api +# AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER=agents-core-web-daemon +# AGENTS_CORE_WEB_DOCKER_CORE_PORT=8091 + +# Optional local-only Docker connection recipe. This renders a copyable command; +# it never gives the browser Docker access or reads the credential file. Every +# value below is compiled into the browser bundle, so values must be non-secret. +# Enable only for the matching operator-controlled local stack. +# AGENTS_CORE_WEB_DOCKER_GUIDE=1 +# AGENTS_CORE_WEB_DOCKER_IMAGE=agents-core-web-executor:2b34ea46-codex-0.153.4 +# AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api +# AGENTS_CORE_WEB_DOCKER_USER=501:20 +# AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH=.parsar/agents-api-web-smoke/executor-key.json +# AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH=.parsar/agents-api-web-smoke/executors + +# Public, non-secret suggestions shown by the Create Agent model picker. The +# first entry is the default unless VITE_AGENT_DEFAULT_MODEL overrides it. These +# do not claim live availability; the connected runtime remains authoritative. +VITE_AGENT_MODEL_PRESETS=gpt-6-astra,gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna,gpt-5.5,gpt-5.3-codex-spark +VITE_AGENT_DEFAULT_MODEL=gpt-5.6-sol diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c806fa626..de1461dce 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -54,6 +54,10 @@ jobs: ~/.parsar/cache/executor-cargo ~/.parsar/cache/executor-target key: core-executor-${{ runner.os }}-${{ hashFiles('packages/codex-executor/Cargo.lock') }} + - name: Install Web acceptance browser + run: | + pnpm install --frozen-lockfile + pnpm exec playwright install --with-deps chrome - name: Check standalone repository env: PARSAR_AGENTS_API_TEST_DATABASE_URL: postgres://agents_api:core_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/parsar_agents_api_core_ci_tests?sslmode=disable diff --git a/.gitignore b/.gitignore index da6269529..4b69c8cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ __pycache__/ /state/ /cache/ /target/ +/playwright-report/ +/test-results/ +/.agents/ +/.issue-agent/ +/ISSUE_AGENT.md diff --git a/AGENTS.md b/AGENTS.md index 441f5c7ca..a5ad47173 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,11 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing code. Work in an isolate Git worktree and submit a PR. Keep product business code in the Parsar repository. Preserve the pinned public Agent API and documented extensions. Do not add product -database dependencies or bypass Core execution ownership. Update architecture and -generated contracts with changes. Run `make check` before reporting completion. +database dependencies or bypass Core execution ownership. Core Web must consume +that public contract through `packages/agents-client`; it must not reproduce Core +execution truth or expose server-side credentials in the browser. Update +architecture and generated contracts with changes. Run `make check` before +reporting completion. After implementation and verification, request an independent blind review with only requirements, acceptance criteria, boundaries, repository path and baseline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e5674371..d1fc9ff9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,10 +4,11 @@ This repository is the standalone execution substrate copied from Parsar at the revision in `provenance/source.json`. Keep the API, its migrations, protocol, -execution daemon, runtime adapters, shared execution packages and build/test tools -here. Product users, workspaces, model catalogs, business assets, web UI, product -API and product migrations remain in Parsar. Do not import `server/`, `apps/web/`, -`apps/parsar/`, product CLI/plugin packages or their deployment stack. +execution daemon, runtime adapters, shared execution packages, the standalone Core +Web console and build/test tools here. Product users, workspaces, model catalogs, +business assets, the Parsar product Web, product API and product migrations remain +in Parsar. Do not import `server/`, `apps/parsar/`, product CLI/plugin packages or +their deployment stack. Preserve copied runtime and protocol behavior. Existing Go import paths, binary names and runtime environment variables remain unchanged for this copy; they do @@ -33,7 +34,8 @@ split oversized components before extending them. Use `internal/obs/log` for log ## Required checks Run `make check` before completion. The standalone gate includes all daemon/shared -Go tests, Core contract/client/service tests, a real dedicated PostgreSQL test +Go tests, Core contract/client/service tests, Core Web and TypeScript client +checks (including fixture-only Playwright acceptance), a real dedicated PostgreSQL test database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK tests and packaging, MiniMax companion checks, Rust executor tests/format/Clippy, and Codex Harness packaging checks. It intentionally has no product Web/server/ diff --git a/Makefile b/Makefile index 3254dd3ad..340d5a63b 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,12 @@ SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) SWAG_VERSION ?= v1.16.4 -.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-executor check-agents-executor build-agents-harness check-agents-harness check-agents-harness-native build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-executor check-agents-executor build-agents-harness check-agents-harness check-agents-harness-native build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime help: @printf '%s\n' 'make build-agents-api Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' -check: check-database check-sqlc check-go check-agents-api check-claude-sdk check-mcode-harness check-agents-executor check-agents-harness +check: check-database check-sqlc check-go check-agents-api check-claude-sdk check-web check-mcode-harness check-agents-executor check-agents-harness @printf 'Parsar Core checks passed.\n' check-database: @@ -61,6 +61,14 @@ check-claude-sdk: node-deps pnpm --filter @parsar/claude-sdk-adapter test $(MAKE) build-claude-sdk-runtime +check-web: node-deps + python3 scripts/verify-web-source.py + pnpm typecheck + pnpm test:core-doctor + pnpm test:web + pnpm --filter @agents-core-web/web build + pnpm test:web:acceptance + build-claude-sdk-runtime: ./scripts/build-claude-sdk-runtime.sh diff --git a/README.md b/README.md index 7b3f98bb4..99a3c96f6 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ The source repository retains both its product and its existing Core copy. This repository contains the API service, PostgreSQL migrations, pinned public protocol, execution daemon, Docker/E2B providers, native Harness adapters, -runtime image builders, client library, tests and operator documentation. -It does not contain the Parsar web application, product backend, product database, -business CLI or product deployment stack. +runtime image builders, Go and TypeScript client libraries, the standalone Core +Web console, tests and operator documentation. It does not contain the Parsar +product application, product backend, product database, business CLI or product +deployment stack. ## Start here @@ -18,12 +19,16 @@ business CLI or product deployment stack. - [Docker Runtime](services/agents-api/deploy/codex/README.md) - [Protocol coverage and known gaps](contracts/agents-api/README.md) - [Harness selection](contracts/agents-api/harness-selection.md) +- [Core Web overview](docs/web/README.md) +- [Core Web 中文说明](docs/web/README.zh-CN.md) +- [Connect Core Web to Core](docs/web/core-connection.md) - [Contributor rules](CONTRIBUTING.md) - [Copy provenance and validation](provenance/README.md) ```sh make build-agents-api make build-daemon +pnpm dev:web ``` These builds require the Go version pinned in `go.mod`. Output goes under @@ -32,6 +37,12 @@ Provision a dedicated Core PostgreSQL database and caller credentials using the operator guide before starting the service. Native execution also needs the appropriate Runtime image and provider configuration. +Core Web lives in `apps/web` and talks only to the public `/v1/agents/**` +HTTP/SSE contract through the TypeScript implementation in +`packages/agents-client`. The Go client remains in +`packages/agents-client/v1`; both clients live next to the contract they consume +without coupling browser state to Core execution internals. + The copied Go module/import paths, executable names and `PARSAR_*` environment variables intentionally retain their existing names. They resolve to source in this checkout, not a dependency on the Parsar product repository. This extraction @@ -41,13 +52,19 @@ sources and packages remain pinned dependencies, not vendored binaries. ## Validate On Linux with Go, Node 22, pnpm 10.30.3, Python 3.9+, Rust 1.95.0 (including -rustfmt/Clippy), OpenSSL development libraries and a dedicated test PostgreSQL: +rustfmt/Clippy), OpenSSL development libraries, Chrome for Playwright, and a +dedicated test PostgreSQL: ```sh export PARSAR_AGENTS_API_TEST_DATABASE_URL='postgres://.../parsar_agents_api_core_tests?sslmode=disable' make check ``` +Install the pinned Playwright browser on a fresh validation host with +`pnpm exec playwright install --with-deps chrome`. Fixture acceptance uses +loopback ports `18092` and `4174`; set `AGENTS_FIXTURE_PORT` and +`AGENTS_WEB_PORT` to unused ports when either default is occupied. + The full gate requires the test database rather than silently skipping persistence tests. Native model/provider fixtures remain explicit, credential-dependent acceptance checks; see the [native tests](services/agents-api/tests/native/README.md). diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts new file mode 100644 index 000000000..6725b2fa8 --- /dev/null +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -0,0 +1,3205 @@ +import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test"; + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; + +interface FixtureRequest { + method: string; + path: string; + query?: string; + beta: string | null; + authorizationPresent: boolean; + idempotencyKeyPresent: boolean; + idempotencyKey: string | null; + body?: Record; +} + +async function resetFixture(request: APIRequestContext) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function controlFixture(request: APIRequestContext, control: Record) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/control`, { data: control }); + expect(response.ok()).toBe(true); +} + +interface FixtureState { + sessions: Array<{ id: string; metadata: Record }>; + aborts: { sessionListReads: number; sessionReads: number; itemReads: number; turnReads: number; streams: number; environmentFileReads: number }; + openStreams: string[]; +} + +async function fixtureState(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/state`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function setFixtureSessionMetadata( + request: APIRequestContext, + id: string, + metadata: Record, +) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/session-metadata`, { data: { id, metadata } }); + expect(response.ok()).toBe(true); +} + +async function removeFixtureSession(request: APIRequestContext, id: string) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/remove-session`, { data: { id } }); + expect(response.ok()).toBe(true); +} + +async function emitTurnFixture(request: APIRequestContext, status: "completed" | "failed" | "cancelled") { + const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-turn`, { data: { status } }); + expect(response.ok()).toBe(true); +} + +async function emitSessionFixture(request: APIRequestContext, status: "in_progress" | "idle" | "failed") { + const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-session`, { data: { status } }); + expect(response.ok()).toBe(true); +} + +async function fixtureRequests(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function createFixtureSession(request: APIRequestContext, label: string) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { + headers: { + "OpenAI-Beta": "agents=v1", + "Idempotency-Key": `fixture-filter-${label}`, + }, + data: { + agent_id: "agent_b", + environment: { type: "none" }, + metadata: { filter_fixture: label }, + stream: false, + vault_ids: [], + }, + }); + expect(response.status()).toBe(201); +} + +async function expectSelectedDeleteAbortsSessionRead( + page: Page, + request: APIRequestContext, + startRead: () => Promise, +) { + const path = "/v1/agents/sessions/session_snapshot"; + const failedReads = new Map(); + page.on("requestfailed", (failedRequest) => { + const failedPath = new URL(failedRequest.url()).pathname; + if (failedRequest.method() === "GET") { + failedReads.set(failedPath, failedRequest.failure()?.errorText ?? "unknown failure"); + } + }); + const before = await fixtureState(request); + const previousReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length; + + await startRead(); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length).toBeGreaterThan(previousReads); + + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + + await expect.poll(() => failedReads.get(path)).toContain("ERR_ABORTED"); + await expect.poll(async () => (await fixtureState(request)).aborts.sessionReads) + .toBeGreaterThan(before.aborts.sessionReads); + expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); +} + +/** + * Single-Environment reads only. The reusable Template collection shares this + * path prefix but is durable configuration, not an Environment resource read. + */ +function isEnvironmentResourcePath(path: string): boolean { + return path.startsWith("/v1/agents/environments/") && !path.startsWith("/v1/agents/environments/templates"); +} + +async function openAgents(page: Page, request: APIRequestContext) { + await resetFixture(request); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents" }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); +} + +async function startSessionWithSecondAgent(page: Page) { + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Create Session" }).click(); +} + +async function openAdvancedSessionSettings(dialog: Locator) { + const toggle = dialog.getByRole("button", { name: /Advanced settings/ }); + if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(dialog.getByRole("region", { name: "Advanced Session settings" })).toBeVisible(); +} + +function environmentTrigger(page: Page) { + return page.getByRole("button", { name: /Environment|Connect environment/i }); +} + +function connectedLiveEvents(page: Page) { + return page.getByRole("status", { name: "Session live events: connected" }); +} + +async function openEnvironmentDialog(page: Page) { + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByRole("region", { name: "Environment and Workspace status" })).toHaveCount(0); + + const trigger = environmentTrigger(page); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + await trigger.click(); + + const dialog = page.getByRole("dialog", { name: "Environment" }); + await expect(dialog).toBeVisible(); + const panel = dialog.getByRole("region", { name: "Environment and Workspace status" }); + await expect(panel).toBeVisible(); + return { dialog, panel, trigger }; +} + +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { + await testInfo.attach(name, { + body: await page.screenshot({ fullPage: true, animations: "disabled" }), + contentType: "image/png", + }); +} + +async function attachElementScreenshot(locator: Locator, testInfo: TestInfo, name: string) { + await testInfo.attach(name, { + body: await locator.screenshot({ animations: "disabled" }), + contentType: "image/png", + }); +} + +async function openSessionsFromHome(page: Page) { + await page.goto("/"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); +} + +test("opens Dashboard as the default landing page", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Dashboard", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); + await expect(page.locator(".dashboard-page")).toBeVisible(); +}); + +test("explains a 502 Core backend failure and opens copyable Docker recovery steps", async ({ page }) => { + await page.route("**/v1/agents**", async (route) => { + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ + error: { message: "Agent core request failed (502).", type: "gateway_error" }, + }), + }); + }); + await page.goto("/"); + + const recovery = page.getByRole("button", { + name: "Agent Core backend is not ready. Open Docker startup guide", + }); + await expect(recovery).toBeVisible(); + await expect(recovery).toContainText("HTTP 502"); + await expect(page.getByText("Agents: Agent core request failed (502).", { exact: true })).toHaveCount(0); + await expect(page.getByText( + "Agent Core backend is not ready. Open the Dashboard startup guide.", + { exact: true }, + )).toHaveCount(1); + await expect(page.getByText("Agent core request failed (502).", { exact: true })).toHaveCount(0); + await recovery.click(); + + const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(dialog).toContainText("Start a local Docker backend"); + await expect(dialog).toContainText("docker start parsar-agents-api-web-smoke-db"); + await expect(dialog).toContainText("docker start agents-core-web-api agents-core-web-daemon"); + await expect(dialog).toContainText("http://127.0.0.1:8091/healthz"); + await expect(dialog).toContainText("First time on this computer"); + await expect(dialog).toContainText("make docker-build-agents-api"); + await expect(dialog.getByRole("link", { name: "Core container setup" })).toHaveAttribute( + "href", + /parsar-core\/blob\/main\/services\/agents-api\/CONTAINER\.md$/, + ); + await expect(dialog.getByRole("button", { name: "Copy database start command" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Copy Core image build command" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Test connection" })).toBeVisible(); +}); + +test("retrieves the latest Agent and opens its validated edit setup directly", async ({ page, request }, testInfo) => { + const browserErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") browserErrors.push(message.text()); + }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + + await openAgents(page, request); + await expect(page.getByText("Lifecycle Agent · stale list", { exact: true })).toBeVisible(); + + await controlFixture(request, { retrieveDelayMs: 250 }); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); + const setup = page.locator(".agent-setup-page"); + await expect(setup.getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(setup).not.toContainText("stale list"); + await expect(setup).toContainText("agent_a"); + await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); + await expect(setup).toContainText("Start Session is unavailable. Current Core Session admission requires"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(setup.getByRole("button", { name: "Save changes" })).toBeInViewport(); + expect(await setup.locator(".agent-setup-actions").evaluate((element) => getComputedStyle(element).position)).toBe("sticky"); + await attachScreenshot(page, testInfo, "desktop-light-agent-edit-setup"); + + const name = page.getByLabel("Name"); + await expect(name).toBeFocused(); + await expect(setup).toContainText("Web-side suggestions, not a discovered Core catalog"); + await expect(setup).toContainText("Never store secrets in Agent metadata"); + + await page.getByLabel("Metadata").fill('{"retries":3}'); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Every metadata value must be a string.")).toBeVisible(); + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); + + await page.getByLabel("Metadata").fill(JSON.stringify(Object.fromEntries( + Array.from({ length: 17 }, (_, index) => [`key-${index}`, "value"]), + ))); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Agent metadata supports at most 16 pairs.")).toBeVisible(); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); + + await name.fill(""); + await page.getByLabel("Instructions").fill(""); + await page.getByLabel("Metadata").fill('{"team":"acceptance"}'); + await page.getByLabel("Reasoning effort").selectOption(""); + await page.getByLabel("Reasoning summary").selectOption(""); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition updated"); + await expect(name).toBeFocused(); + + requests = await fixtureRequests(request); + const updates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a"); + expect(updates).toHaveLength(1); + expect(updates[0]?.body).toMatchObject({ + name: null, + instructions: null, + metadata: { team: "acceptance" }, + reasoning: { effort: null, summary: null }, + }); + expect(updates[0]?.body).not.toHaveProperty("tools"); + expect(browserErrors).toEqual([]); +}); + +test("fails closed when the latest Agent response has a different identity", async ({ page, request }) => { + await openAgents(page, request); + await page.route("**/v1/agents/agent_a", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const upstream = await route.fetch(); + const payload = await upstream.json() as Record; + await route.fulfill({ response: upstream, json: { ...payload, id: "agent_other" } }); + }); + + const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await editTrigger.click(); + await expect(page.getByRole("alert")).toContainText("returned a different Agent than the one requested"); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(editTrigger).toBeFocused(); + + const reads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/agent_a" + )); + expect(reads).toHaveLength(1); +}); + +test("creates, previews, edits, and removes bounded Function and anonymous HTTP MCP tools", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Create agent/ }).click(); + await page.getByLabel("Name").fill("Tool Agent"); + await page.getByLabel("Instructions").fill("Use only the configured tools."); + await page.getByLabel("Model").selectOption({ label: "Custom model ID…" }); + await page.getByLabel("Custom model ID").fill("fixture/tool-model"); + + await page.getByRole("button", { name: "Add Function" }).click(); + const createFunction = page.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); + await createFunction.getByLabel("Name", { exact: true }).fill("lookup_customer"); + await createFunction.getByLabel("Description", { exact: true }).fill("Look up a customer."); + await createFunction.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); + + await page.getByRole("button", { name: "Add HTTP MCP" }).click(); + const createMcp = page.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); + await createMcp.getByLabel("Server label").fill("docs"); + await createMcp.getByLabel("Server URL").fill("https://mcp.example/tools"); + await createMcp.getByLabel("Only the listed tools").check(); + await createMcp.getByLabel("Allowed tools for docs").fill("search\nread_document"); + await createMcp.getByLabel("Require this server for Core execution").check(); + + const previewBody = page.getByRole("region", { name: "Request preview" }) + .locator(".agent-preview-block") + .filter({ has: page.getByText("agent.json", { exact: true }) }) + .locator("pre"); + await expect(previewBody).toContainText('"type": "function"'); + await expect(previewBody).toContainText('"name": "lookup_customer"'); + await expect(previewBody).toContainText('"type": "mcp"'); + await expect(previewBody).toContainText('"required": true'); + + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + let requests = await fixtureRequests(request); + const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toEqual({ + model: "fixture/tool-model", + name: "Tool Agent", + instructions: "Use only the configured tools.", + metadata: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [ + { + type: "function", + name: "lookup_customer", + description: "Look up a customer.", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }, + { + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: "https://mcp.example/tools" }, + allowed_tools: ["search", "read_document"], + connection_origin: "service", + required: true, + }, + ], + }); + + await page.getByRole("button", { name: "Back to Agents" }).click(); + await page.getByRole("button", { name: /^Edit Tool Agent/ }).click(); + const setup = page.locator(".agent-setup-page"); + await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const editFunction = setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); + await editFunction.getByLabel("Name", { exact: true }).fill("lookup_customer_v2"); + const editMcp = setup.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); + await editMcp.getByRole("button", { name: "Remove" }).click(); + await setup.getByRole("button", { name: "Save changes" }).click(); + await expect(setup.getByRole("status")).toContainText("Agent definition updated"); + + requests = await fixtureRequests(request); + let updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); + expect(updates).toHaveLength(1); + expect(updates[0]?.body?.tools).toEqual([ + { + type: "function", + name: "lookup_customer_v2", + description: "Look up a customer.", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }, + ]); + + await setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first().getByRole("button", { name: "Remove" }).click(); + await setup.getByRole("button", { name: "Save changes" }).click(); + await expect(setup.getByRole("status")).toContainText("Agent definition updated"); + + requests = await fixtureRequests(request); + updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); + expect(updates).toHaveLength(2); + expect(updates[1]?.body?.tools).toEqual([]); +}); + +test("keeps Source Files controls out of the System status page", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "System", exact: true }).click(); + await expect(page.getByRole("region", { name: "Source Files" })).toHaveCount(0); + await expect(page.locator(".system-page")).not.toContainText("Source Files"); +}); + +test("supports global Create keyboard navigation and consumes setup requests once", async ({ page, request }) => { + await openAgents(page, request); + const sidebar = page.locator(".app-sidebar"); + const productNavigation = sidebar.getByRole("navigation", { name: "Agents product" }); + await expect(productNavigation).toBeVisible(); + await expect(productNavigation.getByRole("button", { name: "Agents", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".product-header").getByRole("navigation", { name: "Agents product" })).toHaveCount(0); + const createMenu = page.getByRole("button", { name: "Create", exact: true }); + await createMenu.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("menuitem", { name: /^Agent\b/ })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(createMenu).toBeFocused(); + await page.keyboard.press("ArrowDown"); + const createAgentItem = page.getByRole("menuitem", { name: /^Agent\b/ }); + const startSessionItem = page.getByRole("menuitem", { name: /^Start Session\b/ }); + await expect(createAgentItem).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(startSessionItem).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await expect(createAgentItem).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByLabel("Name")).toBeFocused(); + await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); + + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "New Agent" })).toHaveCount(0); + + const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await editTrigger.focus(); + await editTrigger.click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(editTrigger).toBeFocused(); + await expect(page.locator(".modal-backdrop")).toHaveCount(0); + + await createMenu.click(); + await startSessionItem.click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_a"); + await expect(sessionDialog.locator('option[value="agent_a"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_a"]')).toContainText("Lifecycle Agent"); + await expect(sessionDialog.locator('option[value="agent_a"]')).not.toContainText("Session requires changes"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toContainText("Saved-only Tool Agent"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).not.toContainText("Session requires changes"); + const sessionPostsBeforeCancel = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await sessionDialog.getByRole("button", { name: "Cancel" }).click(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(sessionPostsBeforeCancel); + await expect(createMenu).toBeFocused(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Create a Session" })).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await createMenu.click(); + await createAgentItem.click(); + const createMetadata = page.locator(".agent-metadata-input"); + const createName = page.locator('input[data-agent-initial-focus="true"]'); + await expect(createName).toBeFocused(); + await createMetadata.fill('{"owner":"local-test"}'); + await expect(createMetadata).toHaveValue('{"owner":"local-test"}'); + await expect(createName).toHaveValue(""); + await createName.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + + await createMenu.click(); + await createAgentItem.click(); + await expect(page.getByLabel("Name")).toHaveValue(""); + await expect(page.getByLabel("Name")).toBeEnabled(); + await expect(page.getByRole("status")).toHaveCount(0); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(createMenu).toBeFocused(); + + const catalogCreate = page.getByRole("button", { name: /^Create agent/ }); + await catalogCreate.click(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(catalogCreate).toBeFocused(); + + const requests = await fixtureRequests(request); + const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toMatchObject({ + name: null, + instructions: null, + metadata: { owner: "local-test" }, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + }); + expect(creates[0]?.body).not.toHaveProperty("reasoning"); +}); + +test("continues from a default Agent definition into an admitted idle Session", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Create agent/ }).click(); + await page.getByLabel("Name").fill("Session-safe Agent"); + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + + const requestsAfterSave = await fixtureRequests(request); + const create = requestsAfterSave.find((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(create?.body).toMatchObject({ + name: "Session-safe Agent", + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + }); + expect(create?.body).not.toHaveProperty("reasoning"); + + await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); + await page.getByRole("button", { name: "Start Session" }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/); + await expect(sessionDialog.getByRole("button", { name: /Advanced settings/ })).toHaveAttribute("aria-expanded", "false"); + await expect(sessionDialog.getByRole("textbox", { name: /^Additional metadata\b/u })).toHaveCount(0); + await expect(sessionDialog.getByRole("radio", { name: /No environment/ })).toBeChecked(); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(environmentTrigger(page)).toHaveCount(0); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByRole("region", { name: "Environment and Workspace status" }), + ).toHaveCount(0); + + const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(sessionCreates).toHaveLength(1); + expect(sessionCreates[0]?.body).toMatchObject({ + agent_id: expect.stringMatching(/^agent_created_/), + environment: { type: "none" }, + stream: false, + }); +}); + +test("opens repair setup for a saved response while blocking an invalid Session write", async ({ page, request }) => { + await openAgents(page, request); + await controlFixture(request, { createAgentResponseVariant: "reasoning" }); + await page.getByRole("button", { name: /^Create agent/ }).click(); + await page.getByLabel("Name").fill("Core-adjusted Agent"); + await page.getByRole("button", { name: "Save Agent definition" }).click(); + + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + await expect(page.locator("#created-agent-session-blocker")).toContainText("Start Session is unavailable"); + const startSession = page.getByRole("button", { name: "Start Session" }); + await expect(startSession).toBeDisabled(); + + // Bypass the setup view's disabled control to inspect the explicit repair + // surface. The dialog remains fail-closed until the saved-only field is reset. + await startSession.evaluate((button) => { + const propsKey = Object.getOwnPropertyNames(button).find((key) => key.startsWith("__reactProps$")); + if (!propsKey) throw new Error("React event props were not found on the Start Session button."); + const props = (button as unknown as Record void }>)[propsKey]; + if (!props?.onClick) throw new Error("Start Session does not have an onClick handler."); + props.onClick(); + }); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/u); + await expect(sessionDialog.getByRole("alert")).toContainText("explicit reasoning options are saved-only"); + await expect(sessionDialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(0); +}); + +test("starts only Agents that pass known Session admission", async ({ page, request }) => { + await openAgents(page, request); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); + + const blockedStart = page.getByRole("button", { name: /^Start a Session with Lifecycle Agent/ }); + await expect(blockedStart).toContainText("Unavailable"); + await expect(blockedStart).toHaveAttribute("aria-disabled", "true"); + await blockedStart.focus(); + await expect(blockedStart.locator("xpath=..").getByRole("tooltip")).toBeVisible(); + const blockedSessionCount = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await page.keyboard.press("Enter"); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(blockedSessionCount); + + const toolOnlyStart = page.getByRole("button", { name: /^Start a Session with Saved-only Tool Agent/ }); + await expect(toolOnlyStart).toHaveAttribute("aria-disabled", "true"); + await toolOnlyStart.focus(); + await expect(toolOnlyStart.locator("xpath=..").getByRole("tooltip")).toContainText("tool_search is saved-only"); + await page.keyboard.press("Enter"); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(blockedSessionCount); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toBeEnabled(); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); + + const before = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + + const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(sessionCreates).toHaveLength(before + 1); + expect(sessionCreates.at(-1)?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + stream: false, + }); +}); + +test("serializes complete saved-Agent overrides while keeping idle creation unstreamed", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.getByRole("button", { name: "New Session" }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_a"); + await expect(dialog.getByRole("alert")).toContainText("multi-agent execution is not supported"); + + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); + await dialog.getByRole("checkbox", { name: "Replace model" }).check(); + await dialog.getByLabel("Session model").fill("fixture/session-model"); + await dialog.getByRole("checkbox", { name: "Replace instructions" }).check(); + await dialog.getByLabel("Session instructions").fill(" "); + await dialog.getByRole("checkbox", { name: /Replace text configuration/ }).check(); + await dialog.getByLabel("Text verbosity").selectOption("low"); + await dialog.getByRole("checkbox", { name: "Reset multi-agent to disabled" }).check(); + await dialog.getByRole("checkbox", { name: "Reset reasoning to Core defaults" }).check(); + await dialog.getByRole("checkbox", { name: "Reset service tier to auto" }).check(); + await dialog.getByRole("radio", { name: "Clear all" }).check(); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "New Session" }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b"); + await openAdvancedSessionSettings(dialog); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(2); + expect(creates[0]?.body).toEqual({ + agent_id: "agent_a", + agent: { + model: "fixture/session-model", + instructions: null, + multi_agent: null, + reasoning: null, + service_tier: null, + text: { format: { type: "text" }, verbosity: "low" }, + tools: [], + }, + environment: { type: "none" }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(creates[1]?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(creates[1]?.body).not.toHaveProperty("agent"); + expect(creates[1]?.body).not.toHaveProperty("input"); +}); + +test("derives manual Vault attachments for anonymous and explicit MCP Credentials", async ({ page, request }) => { + await resetFixture(request); + const serverURL = "https://mcp.vault-fixture.test/tools"; + const createVault = async (name: string) => { + const response = await request.post(`${fixtureBaseUrl}/v1/vaults`, { data: { name, metadata: {} } }); + expect(response.ok()).toBe(true); + return response.json() as Promise<{ id: string }>; + }; + const createCredential = async (vaultId: string, name: string) => { + const response = await request.post(`${fixtureBaseUrl}/v1/vaults/${vaultId}/credentials`, { data: { + name, + auth: { type: "static_bearer", mcp_server_url: serverURL, token: "fixture-secret-never-rendered" }, + } }); + expect(response.ok()).toBe(true); + return response.json() as Promise<{ id: string }>; + }; + const vaultAlpha = await createVault("Vault Alpha"); + const vaultBeta = await createVault("Vault Beta"); + const credentialAlpha = await createCredential(vaultAlpha.id, "Credential Alpha"); + await createCredential(vaultBeta.id, "Credential Beta"); + const mcpTool = (credentialId: string | null) => ({ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: serverURL, headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: credentialId, + request_metadata: {}, + required: false, + }); + for (const [name, credentialId] of [ + ["Anonymous MCP Agent", null], + ["Explicit MCP Agent", credentialAlpha.id], + ] as const) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp", + name, + tools: [mcpTool(credentialId)], + } }); + expect(response.status()).toBe(201); + } + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await expect(dialog).toContainText("Anonymous for this Session"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); + await expect(dialog).toContainText("Implicit unique match · Credential Alpha · Vault Alpha"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); + await dialog.getByRole("checkbox", { name: "Vault Beta" }).check(); + await expect(dialog.getByRole("alert")).toContainText("matches multiple Credentials"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + const requiredVault = dialog.getByRole("checkbox", { name: /Vault Alpha · attached automatically/ }); + await expect(requiredVault).toBeChecked(); + await expect(requiredVault).toBeDisabled(); + await expect(dialog).toContainText("Explicit · Credential Alpha · Vault Alpha"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + let creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(3); + expect(creates.map((entry) => entry.body?.vault_ids)).toEqual([ + [], + [vaultAlpha.id], + [vaultAlpha.id], + ]); + const createsBeforeCredentialDelete = creates.length; + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + const deleted = await request.delete(`${fixtureBaseUrl}/v1/vaults/${vaultAlpha.id}/credentials/${credentialAlpha.id}`); + expect(deleted.ok()).toBe(true); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("explicit MCP Credential is unavailable"); + creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates.length - createsBeforeCredentialDelete).toBeLessThanOrEqual(1); +}); + +test("clears manual Vault attachments when overrides remove HTTP MCP tools", async ({ page, request }) => { + await resetFixture(request); + const serverURL = "https://mcp.vault-clear.test/tools"; + const vaultResponse = await request.post(`${fixtureBaseUrl}/v1/vaults`, { + data: { name: "Vault to clear", metadata: {} }, + }); + expect(vaultResponse.ok()).toBe(true); + const vault = await vaultResponse.json() as { id: string }; + const agentResponse = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp-clear", + name: "MCP Clear Agent", + tools: [{ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: serverURL, headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + }], + } }); + expect(agentResponse.status()).toBe(201); + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault to clear" }).check(); + await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); + await dialog.getByRole("radio", { name: "Clear all" }).check(); + + await expect(dialog.getByRole("heading", { name: "Tools & Vaults" })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toMatchObject({ + agent: { tools: [] }, + vault_ids: [], + }); + expect(creates[0]?.body?.vault_ids).not.toContain(vault.id); +}); + +test("creates the bounded self-hosted profile and renders a secret-free connection guide", async ({ page, request }) => { + await openAgents(page, request); + const sessionPostsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); + await dialog.getByRole("radio", { name: /Self-hosted/ }).check(); + const workspace = dialog.getByLabel("Workspace directory"); + await workspace.fill("relative/workspace"); + await expect(dialog.getByText("Workspace directory must be an absolute POSIX path")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(sessionPostsBefore); + + await workspace.fill("/executor/workspace"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + const { dialog: environmentDialog, panel: environmentPanel, trigger } = await openEnvironmentDialog(page); + await expect(environmentPanel).toBeVisible(); + await expect(environmentPanel).toContainText("Self-hosted Environment"); + await expect(environmentPanel).toContainText("0f745b0d-b545-49cd-8d7e-4c31c80dc564"); + await expect(environmentPanel).toContainText("https://executor.example.test"); + await expect(environmentPanel).toContainText("/executor/workspace"); + await expect(environmentPanel).toContainText("Pending"); + const files = environmentPanel.getByRole("region", { name: "Workspace files" }); + await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); + await expect(files).toContainText("Workspace root"); + + await expect(environmentPanel.locator("details.environment-launcher-guide")).toHaveAttribute("open", ""); + await expect(environmentPanel.getByRole("button", { name: "Copy native command" })).toBeVisible(); + await expect(environmentPanel).toContainText("agents-api-codex-executor"); + await expect(environmentPanel).toContainText("$HOME/.parsar/executor-key.json"); + await expect(environmentPanel).toContainText("Web copies its path but never creates, reads, stores, or transmits the key"); + await expect(environmentPanel).not.toContainText("executor_token"); + await expect(environmentPanel).not.toContainText("Authorization: Bearer"); + + await page.keyboard.press("Escape"); + await expect(environmentDialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + + const requests = await fixtureRequests(request); + const sessionCreates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); + expect(sessionCreates).toHaveLength(sessionPostsBefore + 1); + expect(sessionCreates.at(-1)?.body).toEqual({ + agent_id: "agent_b", + environment: { + type: "self_hosted", + workspace_directory: "/executor/workspace", + capability_directories: [], + }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(requests.filter((entry) => entry.method === "POST" && entry.path.endsWith("/events"))).toHaveLength(0); +}); + +test("saves a reusable Environment Template and references it from a managed Session", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + + const templateSelect = dialog.getByLabel("Reusable Environment Template"); + await expect(templateSelect).toBeDisabled(); + await expect(dialog).toContainText("This Core exposes no saved Templates"); + + await dialog.getByRole("button", { name: "New Template" }).click(); + await dialog.getByLabel("Template name").fill("Restricted outbound access"); + await dialog.getByLabel("Template network access").selectOption("disabled"); + await dialog.getByRole("button", { name: "Save Template" }).click(); + + await expect(templateSelect).toBeEnabled(); + await expect(templateSelect).toHaveValue(/^4/); + const selectedTemplateId = await templateSelect.inputValue(); + await expect(dialog.getByLabel("Managed Environment network access")) + .toContainText("Inherit from Template (disabled)"); + + const templateCreates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/environments/templates" + )); + expect(templateCreates).toHaveLength(1); + expect(templateCreates[0]?.beta).toBe("agents=v1"); + expect(templateCreates[0]?.body).toEqual({ + name: "Restricted outbound access", + network: { access: "disabled" }, + }); + + // A Session can keep or narrow the Template policy, never widen it. + await dialog.getByLabel("Managed Environment network access").selectOption("enabled"); + await expect(dialog).toContainText("never widen it"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + await dialog.getByLabel("Managed Environment network access").selectOption("default"); + + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + const latest = creates.at(-1)?.body; + expect(latest?.environment).toEqual({ + type: "openai_hosted", + environment_template_id: selectedTemplateId, + }); + expect(latest?.stream).toBe(true); +}); + +test("hides Template selection when the connected Core lacks the resource", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentTemplateListStatus: 400 }); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + + await expect(dialog).toContainText("does not expose the Environment Template resource"); + await expect(dialog.getByLabel("Reusable Environment Template")).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "New Template" })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); +}); + +test("creates managed hosted default, enabled and disabled profiles through POST SSE", async ({ page, request }) => { + await openAgents(page, request); + const profiles = [ + { label: "default", option: "default", environment: { type: "openai_hosted" }, input: undefined }, + { label: "enabled", option: "enabled", environment: { type: "openai_hosted", network: { access: "enabled" } }, input: "Managed initial input" }, + { label: "disabled", option: "disabled", environment: { type: "openai_hosted", network: { access: "disabled" } }, input: undefined }, + ] as const; + + for (const [index, profile] of profiles.entries()) { + if (index > 0) await page.getByRole("button", { name: "Agents", exact: true }).click(); + if (index === 0) await controlFixture(request, { environmentEventStatus: 3, environmentEventCount: 1 }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + const network = dialog.getByLabel("Managed Environment network access"); + await network.selectOption(profile.option); + if (profile.input) await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(profile.input); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + const latest = creates.at(-1)?.body; + expect(latest).toMatchObject({ + agent_id: "agent_b", + environment: profile.environment, + stream: true, + vault_ids: [], + }); + expect(latest?.environment).toEqual(profile.environment); + if (profile.input) expect(latest?.input).toBe(profile.input); + else expect(latest).not.toHaveProperty("input"); + + if (index === 0) { + const { dialog: environmentDialog, panel } = await openEnvironmentDialog(page); + await expect(panel).toContainText("Managed hosted Environment"); + await expect(panel).toContainText("7a263c51-6bf0-4d53-8518-c792eb1f0d21"); + await expect(panel).toContainText("Network access"); + await expect(panel).toContainText("Enabled"); + await expect(panel).toContainText("/workspace"); + await expect(panel).toContainText("None installed by the basic profile"); + await expect(panel).toContainText("Workspace files"); + await expect(panel.getByText("Add inline Workspace file", { exact: true })).toBeVisible(); + await expect(panel).not.toContainText("Connect Environment"); + await expect(panel).not.toContainText("Remote URL"); + + await panel.getByLabel("Local file").setInputFiles({ + name: "managed.txt", + mimeType: "text/plain", + buffer: Buffer.from("managed bytes", "utf8"), + }); + await panel.getByLabel("Destination path").fill("/workspace/managed.txt"); + await panel.getByRole("button", { name: "Write selected file" }).click(); + await expect(panel).toContainText("Write confirmed:"); + await expect(panel).toContainText("/workspace/managed.txt"); + let writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + )); + expect(writes.at(-1)?.body).toEqual({ + type: "inline", + data: Buffer.from("managed bytes", "utf8").toString("base64"), + path: "/workspace/managed.txt", + }); + + await panel.getByLabel("Local file").setInputFiles({ + name: "unknown.txt", + mimeType: "text/plain", + buffer: Buffer.from("uncertain bytes", "utf8"), + }); + await panel.getByLabel("Destination path").fill("/workspace/unknown.txt"); + await controlFixture(request, { environmentFileCreateResponseLoss: 1 }); + const writesBeforeUnknown = writes.length; + await panel.getByRole("button", { name: "Write selected file" }).click(); + await expect(panel.getByRole("alert")).toContainText("Web did not retry the request"); + writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + )); + expect(writes).toHaveLength(writesBeforeUnknown + 1); + await page.waitForTimeout(250); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + ))).toHaveLength(writesBeforeUnknown + 1); + await environmentDialog.getByRole("button", { name: "Done" }).click(); + } + } + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); +}); + +test("blocks hosted MCP before persistence while allowing a Function-only managed Session", async ({ page, request }) => { + await resetFixture(request); + const mcp = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp", + name: "Hosted MCP Agent", + tools: [{ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: "https://mcp.example/tools", headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + }], + } }); + expect(mcp.status()).toBe(201); + const fn = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-function", + name: "Hosted Function Agent", + tools: [{ type: "function", name: "lookup", description: "", parameters: {}, defer_loading: false }], + } }); + expect(fn.status()).toBe(201); + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + const before = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await page.getByRole("button", { name: /^Start a Session with Hosted MCP Agent/ }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + await expect(dialog.getByRole("alert")).toContainText("Managed hosted Sessions do not yet support MCP tools"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(before); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Hosted Function Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(before + 1); + expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: true }); +}); + +test("keeps managed Environment resource and terminal event states fail-closed", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 8, environmentResourceStatus: "expired" }); + await openSessionsFromHome(page); + let trigger = environmentTrigger(page); + await expect(trigger).toContainText("Managed Environment expired"); + let opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment expired"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + await opened.dialog.getByRole("button", { name: "Done" }).click(); + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await controlFixture(request, { environmentResourceStatus: "pending", environmentResourceVariant: "wrong_type" }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + trigger = environmentTrigger(page); + await expect(trigger).toContainText("Managed Environment unavailable"); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Durable managed Environment status is unavailable"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await opened.dialog.getByRole("button", { name: "Done" }).click(); + + await controlFixture(request, { + environmentResourceVariant: "valid", + environmentEventStatus: 5, + environmentEventCount: 1, + }); + await page.reload(); + await expect(environmentTrigger(page)).toContainText("Managed Environment failed"); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + + await opened.dialog.getByRole("button", { name: "Done" }).click(); + await controlFixture(request, { + environmentScenario: 9, + environmentResourceStatus: "failed", + environmentResourceVariant: "valid", + environmentEventCount: 0, + }); + await page.reload(); + await expect(page.getByText("Session cannot continue", { exact: true })).toBeVisible(); + await expect(page.getByText("The environment is no longer available for this input.", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeDisabled(); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + + await opened.dialog.getByRole("button", { name: "Done" }).click(); + await controlFixture(request, { + environmentScenario: 8, + environmentResourceStatus: "pending", + environmentResourceVariant: "populated", + }); + await page.reload(); + opened = await openEnvironmentDialog(page); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); +}); + +test("creates an inline Session without saved Agents and preserves ordered user-message input", async ({ page, request }) => { + await openAgents(page, request); + for (const agentId of ["agent_a", "agent_b", "agent_tool_only"]) { + const deleted = await request.delete(`${fixtureBaseUrl}/v1/agents/${agentId}`); + expect(deleted.ok()).toBe(true); + } + await page.getByRole("button", { name: "Refresh Agents" }).click(); + await expect(page.getByText("No saved Agents", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + const newSession = page.getByRole("button", { name: "New Session" }); + await expect(newSession).toBeEnabled(); + await newSession.click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await expect(dialog.getByRole("radio", { name: /Inline Agent/ })).toBeChecked(); + await expect(dialog.getByRole("radio", { name: /Saved Agent/ })).toBeDisabled(); + await dialog.getByLabel("Inline model").fill("fixture/inline-model"); + await dialog.getByLabel("Inline instructions").fill("Keep exact input structure."); + const tools = dialog.getByRole("region", { name: "Inline tools" }); + await tools.getByRole("button", { name: "Add Function" }).click(); + await tools.getByLabel("Name", { exact: true }).fill("lookup_fixture"); + await tools.getByLabel("Description", { exact: true }).fill("Look up fixture data"); + await tools.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); + + await dialog.getByRole("radio", { name: "Message array" }).check(); + await expect(dialog.getByRole("alert").filter({ hasText: "User message 1 needs nonblank text across its parts." })).toHaveCount(1); + const advancedToggle = dialog.getByRole("button", { name: /Advanced settings/ }); + await advancedToggle.click(); + await expect(dialog.getByRole("button", { name: "Edit messages", exact: true })).toBeVisible(); + await dialog.getByRole("button", { name: "Edit messages", exact: true }).click(); + await expect(advancedToggle).toHaveAttribute("aria-expanded", "true"); + let messages = dialog.locator(".session-initial-message"); + await messages.nth(0).locator(".session-initial-part textarea").nth(0).fill("first-message-part-one"); + await dialog.getByRole("button", { name: "Add text part" }).click(); + await messages.nth(0).locator(".session-initial-part textarea").nth(1).fill("first-message-part-two"); + await dialog.getByRole("button", { name: "Add message" }).click(); + messages = dialog.locator(".session-initial-message"); + await messages.nth(1).locator(".session-initial-part textarea").fill("second-message"); + await dialog.getByRole("button", { name: "Move user message 2 up" }).click(); + await dialog.getByRole("button", { name: "Move text part 2 of user message 2 up" }).click(); + await expect(dialog).not.toContainText("Creation events stream automatically"); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + await expect(dialog).toHaveCount(0); + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByText("second-message", { exact: true })).toBeVisible(); + await expect(conversation).toContainText("first-message-part-two"); + await expect(conversation).toContainText("first-message-part-one"); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toEqual({ + agent: { + model: "fixture/inline-model", + instructions: "Keep exact input structure.", + tools: [{ + type: "function", + name: "lookup_fixture", + description: "Look up fixture data", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }], + }, + environment: { type: "none" }, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "second-message" }] }, + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "first-message-part-two" }, + { type: "input_text", text: "first-message-part-one" }, + ], + }, + ], + metadata: {}, + stream: true, + vault_ids: [], + }); + expect(creates[0]?.body).not.toHaveProperty("agent_id"); +}); + +test("keeps failures visible, rejects stale async continuations, and never retries writes", async ({ page, request }) => { + await openAgents(page, request); + + await controlFixture(request, { retrieveStatus: 500 }); + const failedEditTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await failedEditTrigger.click(); + const openError = page.getByRole("alert"); + await expect(openError).toContainText("Fixture retrieve failed."); + await expect(failedEditTrigger).toBeEnabled(); + await expect(failedEditTrigger).toBeFocused(); + await openError.getByRole("button", { name: "Retry" }).click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(page.locator(".agent-setup-page")).not.toContainText("stale list"); + await page.getByRole("button", { name: "Back to Agents" }).click(); + + await controlFixture(request, { retrieveDelayMs: 400 }); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.waitForTimeout(500); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.locator(".agent-setup-page")).toBeVisible(); + await page.getByLabel("Name").fill("Preserved after failure"); + await controlFixture(request, { updateStatus: 500 }); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("alert")).toContainText("Fixture update failed."); + await expect(page.getByLabel("Name")).toHaveValue("Preserved after failure"); + + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(1); + + await controlFixture(request, { updateDelayMs: 600 }); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("button", { name: "Back to Agents" })).toBeDisabled(); + await page.waitForTimeout(100); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.waitForTimeout(700); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); + + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); +}); + +test("requires delete confirmation, preserves failures, and keeps Session snapshots", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await page.getByRole("button", { name: "Delete Agent" }).click(); + let dialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await expect(dialog).toContainText("Existing Sessions keep their durable Agent snapshots"); + await expect(dialog).toContainText("agent_a"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("button", { name: "Delete Agent" })).toBeFocused(); + + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); + + await page.getByRole("button", { name: "Delete Agent" }).click(); + dialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await controlFixture(request, { deleteStatus: 500 }); + await dialog.getByRole("button", { name: "Delete Agent" }).click(); + await expect(dialog.getByRole("alert")).toContainText("Fixture delete failed."); + await expect(dialog).toContainText("Lifecycle Agent"); + + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(1); + + await controlFixture(request, { deleteDelayMs: 300 }); + await dialog.getByRole("button", { name: "Delete Agent" }).click(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeDisabled(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toHaveCount(0); + + await page.getByRole("button", { name: "Sessions" }).click(); + await expect(page.getByRole("button", { name: /idle Lifecycle Agent/ })).toBeVisible(); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); +}); + +test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px", async ({ page, request }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgents(page, request); + + const agentList = page.getByRole("list", { name: "Agents", exact: true }); + await expect(agentList.getByRole("listitem")).toHaveCount(2); + await expect(agentList.getByRole("listitem").first()).toContainText("Create agent"); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Second Agent/ })).toHaveCount(0); + const showMore = page.getByRole("button", { name: "Show 2 more" }); + await expect(showMore).toHaveAttribute("aria-expanded", "false"); + await showMore.click(); + await expect(page.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true"); + await expect(agentList.getByRole("listitem")).toHaveCount(4); + const secondAgent = page.getByRole("button", { name: /^Edit Second Agent/ }); + await expect(secondAgent).toBeVisible(); + await secondAgent.click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Second Agent", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(secondAgent).toBeFocused(); + await expect(page.getByRole("button", { name: "Show less" })).toBeVisible(); + + const metrics = await page.evaluate(() => { + const main = document.querySelector(".app-main")?.getBoundingClientRect(); + const trigger = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Create agent"))?.getBoundingClientRect(); + const grid = document.querySelector(".agent-card-grid")?.getBoundingClientRect(); + const sessionTrigger = document.querySelector('button[aria-label^="Start a Session with Second Agent ("]')?.getBoundingClientRect(); + return { + innerWidth, + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + main: main && { left: main.left, right: main.right, width: main.width }, + trigger: trigger && { left: trigger.left, right: trigger.right, width: trigger.width }, + grid: grid && { left: grid.left, right: grid.right, width: grid.width }, + sessionTrigger: sessionTrigger && { left: sessionTrigger.left, right: sessionTrigger.right, width: sessionTrigger.width }, + }; + }); + expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); + expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); + expect(metrics.main?.left).toBeGreaterThanOrEqual(0); + expect(metrics.main?.right).toBeLessThanOrEqual(390); + expect(metrics.trigger?.left).toBeGreaterThanOrEqual(0); + expect(metrics.trigger?.right).toBeLessThanOrEqual(390); + expect(metrics.grid?.left).toBeGreaterThanOrEqual(0); + expect(metrics.grid?.right).toBeLessThanOrEqual(390); + expect(metrics.sessionTrigger?.left).toBeGreaterThanOrEqual(metrics.grid?.left ?? 0); + expect(metrics.sessionTrigger?.right).toBeLessThanOrEqual(metrics.grid?.right ?? 390); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); + await attachScreenshot(page, testInfo, "narrow-light-agent-card-grid"); + + await expect(page.getByRole("button", { name: "Environments", exact: true })).toHaveCount(0); + const sessionsNavigation = page.getByRole("button", { name: "Sessions", exact: true }); + await sessionsNavigation.click(); + await expect(sessionsNavigation).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".session-page")).toBeVisible(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + + const globalCreate = page.getByRole("button", { name: "Create", exact: true }); + await globalCreate.click(); + const createPanel = page.getByRole("menu", { name: "Create" }); + await expect(createPanel).toBeVisible(); + await expect(createPanel.getByRole("menuitem")).toHaveCount(2); + await expect(createPanel.getByRole("menuitem", { name: /Environment template/i })).toHaveCount(0); + await expect(createPanel.getByRole("menuitem", { name: /Environment key/i })).toHaveCount(0); + const createPanelBox = await createPanel.boundingBox(); + expect(createPanelBox).not.toBeNull(); + expect(createPanelBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((createPanelBox?.x ?? 0) + (createPanelBox?.width ?? 0)).toBeLessThanOrEqual(390); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await createPanel.getByRole("menuitem", { name: /^Agent\b/ }).click(); + const setup = page.locator(".agent-setup-page"); + const box = await setup.boundingBox(); + expect(box).not.toBeNull(); + expect(box?.x ?? -1).toBeGreaterThanOrEqual(0); + expect(box?.y ?? -1).toBeGreaterThanOrEqual(0); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390.5); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await expect(page.getByRole("button", { name: "Save Agent definition" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); + await expect(page.getByLabel("Text format")).toHaveValue("Text"); + await expect(page.getByLabel("Reasoning effort")).toBeDisabled(); + await expect(setup).toContainText("Creation is locked to the current Session-compatible profile"); + await attachScreenshot(page, testInfo, "narrow-light-agent-setup"); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(globalCreate).toBeFocused(); + + await page.getByRole("button", { name: "Dark theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + const editSetup = page.locator(".agent-setup-page"); + await expect(editSetup).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const editLayout = await editSetup.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(editLayout.documentScrollWidth).toBeLessThanOrEqual(390); + expect(editLayout.bodyScrollWidth).toBeLessThanOrEqual(390); + expect(editLayout.left).toBeGreaterThanOrEqual(0); + expect(editLayout.right).toBeLessThanOrEqual(390.5); + await attachScreenshot(page, testInfo, "narrow-dark-agent-edit-setup"); + + const deleteAction = editSetup.getByRole("button", { name: "Delete Agent" }); + await deleteAction.scrollIntoViewIfNeeded(); + await expect(deleteAction).toBeInViewport(); + await deleteAction.click(); + const deleteDialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await expect(deleteDialog).toBeVisible(); + await expect(deleteDialog).toContainText("agent_a"); + await expect(deleteDialog.getByRole("button", { name: "Cancel" })).toBeInViewport(); + await expect(deleteDialog.getByRole("button", { name: "Delete Agent" })).toBeInViewport(); + const dialogBox = await deleteDialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((dialogBox?.x ?? 0) + (dialogBox?.width ?? 0)).toBeLessThanOrEqual(390.5); + await attachScreenshot(page, testInfo, "narrow-dark-agent-delete-confirmation"); + await deleteDialog.getByRole("button", { name: "Cancel" }).click(); + await expect(editSetup).toBeVisible(); + await expect(editSetup.getByRole("button", { name: "Delete Agent" })).toBeFocused(); +}); + +test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => { + await openAgents(page, request); + await startSessionWithSecondAgent(page); + await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + + const requests = await fixtureRequests(request); + const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); + expect(creates).toHaveLength(1); + expect(creates[0]?.idempotencyKeyPresent).toBe(true); + expect(creates[0]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, stream: false }); + for (const entry of requests.filter((candidate) => candidate.path.startsWith("/v1/"))) { + expect(entry.beta).toBe("agents=v1"); + expect(entry.authorizationPresent).toBe(false); + } +}); + +test("filters every Session page by Agent and aborts a stale filter read", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { sessionListPageSize: 1 }); + + await openSessionsFromHome(page); + const filter = page.getByLabel("Filter Sessions by Agent"); + await expect(filter).toBeVisible(); + await expect(page.locator(".session-row")).toHaveCount(1); + + // These Sessions are deliberately created after the global snapshot loads. + // The Agent-scoped list must be able to select them without inflating Dashboard totals. + await createFixtureSession(request, "first"); + await createFixtureSession(request, "second"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".session-row")).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const filteredReads = (await fixtureRequests(request)).filter((entry) => { + if (entry.method !== "GET" || entry.path !== "/v1/agents/sessions") return false; + return new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b"; + }); + expect(filteredReads).toHaveLength(2); + expect(filteredReads.every((entry) => ( + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b" + ))).toBe(true); + expect(filteredReads.some((entry) => ( + new URLSearchParams(entry.query ?? "").has("after") + ))).toBe(true); + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await expect(filter).toHaveValue("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const before = await fixtureState(request); + const agentAReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && + entry.path === "/v1/agents/sessions" && + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" + )).length; + await controlFixture(request, { sessionListDelayMs: 500 }); + await filter.selectOption("agent_a"); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && + entry.path === "/v1/agents/sessions" && + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" + )).length).toBeGreaterThan(agentAReadsBefore); + + await filter.selectOption("agent_b"); + await expect.poll(async () => (await fixtureState(request)).aborts.sessionListReads) + .toBeGreaterThan(before.aborts.sessionListReads); + await expect(page.locator(".session-row")).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + await page.waitForTimeout(550); + await expect(filter).toHaveValue("agent_b"); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + + await filter.selectOption(""); + await expect(filter).toHaveValue(""); + await expect(page.locator(".session-row")).toHaveCount(1); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const lifecycleRow = page.getByRole("table", { name: "Recent Sessions" }) + .getByRole("row") + .filter({ hasText: "Lifecycle Agent" }); + await lifecycleRow.getByRole("button").click(); + await expect(filter).toHaveValue(""); + await expect(page.locator(".session-row")).toHaveCount(1); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); +}); + +test("fences the filtered workspace across loading, errors, unavailable Agents, and deletes", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await createFixtureSession(request, "delete-first"); + await createFixtureSession(request, "delete-second"); + + const filter = page.getByLabel("Filter Sessions by Agent"); + await controlFixture(request, { sessionListDelayMs: 500 }); + await filter.selectOption("agent_b"); + await expect(page.getByLabel("Loading Session workspace")).toBeVisible(); + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + await controlFixture(request, { sessionListStatus: 503 }); + await filter.selectOption("agent_a"); + await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await page.locator(".workspace-error").getByRole("button", { name: "Retry" }).click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + const deletedAgent = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); + expect(deletedAgent.ok()).toBe(true); + await page.getByRole("button", { name: "Recover durable state" }).click(); + await expect(filter).toHaveValue("agent_b"); + await expect(filter.locator("option:checked")).toHaveText("Unavailable Agent (not loaded)"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + for (const remainingRows of [1, 0]) { + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".session-row")).toHaveCount(remainingRows); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + if (remainingRows) { + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + } + } + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await expect(page.locator(".workspace-empty")).toContainText("Select or create a Session"); +}); + +test("streams initial Session creation, captures early events, then hands off to one GET stream", async ({ page, request }) => { + await openAgents(page, request); + await controlFixture(request, { sessionCreateStreamCloseDelayMs: 1_500 }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^Title\b/u }).fill("Streamed creation"); + const exactInput = " Initial streamed input \n"; + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(exactInput); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Streamed creation"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByText("Initial streamed input", { exact: true }), + ).toBeVisible(); + + await expect.poll(async () => (await fixtureState(request)).sessions[0]?.id).toMatch(/^session_created_/u); + const createdSessionId = (await fixtureState(request)).sessions[0]?.id; + if (!createdSessionId) throw new Error("Fixture did not retain the streamed Session."); + const createRequests = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(createRequests).toHaveLength(1); + expect(createRequests[0]?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + input: exactInput, + metadata: { title: "Streamed creation" }, + stream: true, + vault_ids: [], + }); + + await page.waitForTimeout(250); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + ))).toHaveLength(0); + + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + )).length).toBe(1); + const handoffRequests = await fixtureRequests(request); + const getStreamIndex = handoffRequests.findIndex((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + )); + expect(getStreamIndex).toBeGreaterThan(-1); + expect(handoffRequests.slice(0, getStreamIndex).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}` + )).length).toBeGreaterThanOrEqual(2); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByText("Initial streamed input", { exact: true }), + ).toBeVisible(); +}); + +test("keeps one Session create attempt across response loss and an unchanged manual retry", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + const create = dialog.getByRole("button", { name: "Create Session" }); + await controlFixture(request, { sessionCreateDelayMs: 1_500, sessionCreateResponseLoss: 1 }); + + await create.evaluate((button) => { + button.click(); + button.click(); + }); + await expect(dialog.getByRole("alert")).toContainText("Agent core request failed (502)."); + await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible(); + + let creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + const originalKey = creates[0]?.idempotencyKey; + expect(originalKey).toBeTruthy(); + expect((await fixtureState(request)).sessions).toHaveLength(2); + + await create.click(); + await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(2); + expect(creates[1]?.idempotencyKey).toBe(originalKey); + expect((await fixtureState(request)).sessions).toHaveLength(2); +}); + +test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const activity = page.locator(".conversation-activity"); + await expect(activity).toHaveCount(0); + await emitSessionFixture(request, "in_progress"); + await expect(activity).toBeVisible(); + await expect(activity).toContainText("Lifecycle Agent is working…"); + await expect(activity).toHaveAttribute("aria-live", "polite"); + await expect(activity).toHaveAttribute("aria-atomic", "true"); + await expect(activity.locator(".status-running")).toBeVisible(); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeVisible(); + await attachElementScreenshot(activity, testInfo, "conversation-activity"); + + await emitSessionFixture(request, "idle"); + await expect(activity).toHaveCount(0); + await expect(page.getByRole("button", { name: "Send message" })).toBeVisible(); +}); + +test("shows immediate local feedback while a message submission is waiting for Core", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + let interceptedSends = 0; + await page.route("**/v1/agents/sessions/*/events", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + interceptedSends += 1; + await sendGate; + await route.continue(); + }); + + const composer = page.getByLabel("Message the Agent"); + await composer.fill("Immediate pending message"); + await composer.press("Enter"); + + const pending = page.locator('[data-send-state="sending"]'); + await expect(pending).toBeVisible(); + await expect(pending).toContainText("Immediate pending message"); + await expect(pending).toContainText("Sending…"); + await expect(page.locator(".conversation-activity")).toContainText("Sending message…"); + await expect(page.getByText("Lifecycle Agent is working…")).toHaveCount(0); + await expect(composer).toHaveValue(""); + await expect.poll(() => interceptedSends).toBe(1); + + releaseSend?.(); + await expect(pending).toHaveCount(0); + await expect(page.locator(".conversation-activity")).toHaveCount(0); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(1); +}); + +test("updates Session title and metadata after a latest read while preserving failed and unknown drafts", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/events") + )).length; + + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Renamed Session"); + await dialog.getByLabel("Additional Session metadata", { exact: true }).fill('{"team":"web","note":"safe"}'); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("heading", { name: "Renamed Session" })).toBeVisible(); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + + let requests = await fixtureRequests(request); + const updateIndex = requests.findLastIndex((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + const latestReadIndex = requests.findLastIndex((entry, index) => ( + index < updateIndex && entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(latestReadIndex).toBeGreaterThanOrEqual(0); + expect(latestReadIndex).toBeLessThan(updateIndex); + expect(requests[updateIndex]?.body).toEqual({ + metadata: { team: "web", note: "safe", title: "Renamed Session" }, + }); + expect(requests.filter((entry) => entry.method === "GET" && entry.path.endsWith("/events"))).toHaveLength(streamReadsBefore); + + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Draft survives 503"); + await controlFixture(request, { sessionUpdateStatus: 503 }); + const postsBefore503 = requests.filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("503"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + await page.waitForTimeout(350); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot")) + .toHaveLength(postsBefore503 + 1); + + await controlFixture(request, { sessionUpdateResponseLoss: 1 }); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("result is unknown"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + const postsAfterLoss = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(postsAfterLoss); +}); + +test("preserves and safely rebases a Session metadata draft after a same-key conflict", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("My preserved draft"); + await setFixtureSessionMetadata(request, "session_snapshot", { + title: "Concurrent title", + concurrent: "must survive", + }); + + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("Metadata changed in Agent Core"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("My preserved draft"); + await expect(dialog.getByLabel("Additional Session metadata", { exact: true })).toContainText('"concurrent": "must survive"'); + await expect(page.locator(".conversation-header h2")).toHaveText("Concurrent title"); + let writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(writes).toHaveLength(0); + + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("heading", { name: "My preserved draft" })).toBeVisible(); + writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(writes).toHaveLength(1); + expect(writes[0]?.body).toEqual({ + metadata: { title: "My preserved draft", concurrent: "must survive" }, + }); +}); + +test("rejects wrong-id and deep-malformed Session reads before writes or delete retries", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await controlFixture(request, { sessionRetrieveVariant: "wrong_id" }); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("alert")).toContainText("invalid Session resource"); + await expect(dialog).toContainText("session_snapshot"); + await expect(dialog).not.toContainText("another_session"); + + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Draft stays local"); + await controlFixture(request, { sessionRetrieveVariant: "deep_malformed" }); + const writesBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("invalid Session retrieval response"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft stays local"); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(writesBefore); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog).toContainText("Exact Session: session_snapshot"); + await controlFixture(request, { + sessionDeleteResponseLoss: 2, + sessionRetrieveVariant: "deep_malformed", + }); + const deletesBefore = (await fixtureRequests(request)).filter((entry) => entry.method === "DELETE").length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); + await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")) + .toHaveLength(deletesBefore + 1); +}); + +test("requires confirmation and reconciles unknown Session deletes once without retrying the write", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog).toContainText("Exact Session: session_snapshot"); + await expect(dialog).toContainText("not a promise of physical history erasure"); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(0); + + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteStatus: 409 }); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("compatible Core rejected"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await controlFixture(request, { sessionDeleteStatus: 503 }); + const readsBefore503 = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("503"); + await expect(dialog.getByRole("alert")).toContainText("refresh confirmed that the Session is still present"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(2); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBefore503 + 1); + + await controlFixture(request, { + sessionDeleteResponseLoss: 2, + sessionRetrieveStatus: 503, + }); + const readsBeforeUnresolved = requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("result is unknown"); + await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(3); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBeforeUnresolved + 1); + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(3); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + await manage.click(); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteResponseLoss: 1 }); + const readsBeforeAppliedLoss = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(4); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBeforeAppliedLoss + 1); + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(4); +}); + +test("keeps a stale Session row and surfaces each explicit repeated 404 deletion", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await removeFixtureSession(request, "session_snapshot"); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + + for (const expectedDeletes of [1, 2]) { + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("not found in Agent Core"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(expectedDeletes); + } +}); + +test("deletes an inactive Session without disturbing the active composer or live event stream", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await startSessionWithSecondAgent(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + const composer = page.getByLabel("Message the Agent"); + await composer.fill("active draft must survive"); + const before = await fixtureState(request); + const activeId = before.sessions.find((session) => session.id !== "session_snapshot")?.id; + expect(activeId).toBeTruthy(); + const activeStreamReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` + )).length; + + const inactiveRow = page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }); + await inactiveRow.locator(".session-row-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteDelayMs: 1_500 }); + const deleteButton = dialog.locator(".modal-footer .button.danger"); + const deleteClick = deleteButton.click(); + await expect(deleteButton).toBeDisabled(); + await expect(deleteButton).toHaveText("Deleting…"); + await expect(inactiveRow).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + await deleteClick; + await expect(dialog).toHaveCount(0); + await expect(inactiveRow).toHaveCount(0); + await expect(composer).toHaveValue("active draft must survive"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.locator(".conversation-session-action")).toBeFocused(); + + const after = await fixtureState(request); + expect(after.aborts.streams).toBe(before.aborts.streams); + expect(after.openStreams).toContain(activeId); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` + ))).toHaveLength(activeStreamReads); +}); + +test("continues a Session with a new Turn after its latest attempt fails", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await emitSessionFixture(request, "failed"); + await expect(page.getByText("Latest attempt failed", { exact: true })).toBeVisible(); + await expect(page.locator(".session-runtime-error")).toContainText( + "You can send another message to start a new Turn in this Session.", + ); + + const composer = page.getByLabel("Message the Agent"); + await expect(composer).toBeEnabled(); + await composer.fill("Continue after the failed Turn"); + const send = page.getByRole("button", { name: "Send message" }); + await expect(send).toBeEnabled(); + await send.click(); + + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length).toBe(1); + const submission = (await fixtureRequests(request)).find((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )); + expect(submission?.body).toEqual({ + events: [{ + type: "agent.session.input.message", + input: [{ + role: "user", + content: [{ type: "input_text", text: "Continue after the failed Turn" }], + }], + }], + }); +}); + +for (const pendingRead of [ + { + label: "Session", + path: "/v1/agents/sessions/session_snapshot", + control: { sessionRetrieveDelayMs: 5_000 }, + }, + { + label: "Item", + path: "/v1/agents/sessions/session_snapshot/items", + control: { itemsRetrieveDelayMs: 5_000 }, + }, + { + label: "Turn", + path: "/v1/agents/sessions/session_snapshot/turns", + control: { turnsRetrieveDelayMs: 5_000 }, + }, +] as const) { + test(`aborts the selected Session's pending ${pendingRead.label} read and SSE after confirmed delete`, async ({ page, request }) => { + const failedReads = new Map(); + page.on("requestfailed", (failedRequest) => { + const path = new URL(failedRequest.url()).pathname; + if (failedRequest.method() === "GET") { + failedReads.set(path, failedRequest.failure()?.errorText ?? "unknown failure"); + } + }); + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await startSessionWithSecondAgent(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await controlFixture(request, { turnsScenario: 1 }); + await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + const before = await fixtureState(request); + const previousReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === pendingRead.path + )).length; + await controlFixture(request, pendingRead.control); + await emitTurnFixture(request, "completed"); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === pendingRead.path + )).length).toBeGreaterThan(previousReads); + + await controlFixture(request, { sessionDeleteStreamCloseDelayMs: 3_000 }); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); + await expect(page.locator('[data-turn-id="turn_completed"]')).toHaveCount(0); + await expect(page.locator(".conversation-session-action")).toBeFocused(); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await expect.poll(() => failedReads.get(pendingRead.path)).toContain("ERR_ABORTED"); + await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); + const after = await fixtureState(request); + expect(after.sessions.some((session) => session.id === "session_snapshot")).toBe(false); + expect(after.openStreams).not.toContain("session_snapshot"); + }); +} + +test("aborts a pending manual recovery read after deleting the selected Session", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); + + await expectSelectedDeleteAbortsSessionRead(page, request, () => ( + page.getByRole("button", { name: "Recover durable state" }).click() + )); +}); + +test("aborts a pending detail retry read after deleting the selected Session", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await controlFixture(request, { sessionRetrieveStatus: 503 }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + const detailError = page.locator(".session-detail-error"); + await expect(detailError).toBeVisible(); + await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); + + await expectSelectedDeleteAbortsSessionRead(page, request, () => ( + detailError.getByRole("button", { name: "Retry" }).click() + )); +}); + +test("deletes the selected Session while its SSE is still connecting", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await startSessionWithSecondAgent(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await controlFixture(request, { streamOpenDelayMs: 3_000 }); + const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length; + await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(page.getByText("Connecting events…", { exact: true })).toBeVisible(); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length).toBeGreaterThan(streamReadsBefore); + const before = await fixtureState(request); + + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); + expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); +}); + +test("keeps Session actions accessible and contained at 390 px in dark mode", async ({ page, request }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Dark theme" }).click(); + const manage = page.locator(".conversation-session-action"); + await manage.focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + const metrics = await dialog.evaluate((element) => { + const box = element.getBoundingClientRect(); + const footer = element.querySelector(".modal-footer")?.getBoundingClientRect(); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + left: box.left, + right: box.right, + bottom: box.bottom, + footerBottom: footer?.bottom ?? Number.POSITIVE_INFINITY, + }; + }); + expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.left).toBeGreaterThanOrEqual(0); + expect(metrics.right).toBeLessThanOrEqual(390); + expect(metrics.bottom).toBeLessThanOrEqual(844); + expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.bottom); + + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(manage).toBeFocused(); +}); + +test("presents Dashboard page-chain results and System boundaries without extra detail reads", async ({ page, request }, testInfo) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.getByText("Session is ready", { exact: true })).toBeVisible(); + const initialDetailPaths = [ + "/v1/agents/sessions/session_snapshot", + "/v1/agents/sessions/session_snapshot/items", + "/v1/agents/sessions/session_snapshot/turns", + ]; + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return initialDetailPaths.every((path) => entries.filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length >= 2); + }).toBe(true); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.getByRole("heading", { name: /Dashboard/ })).toBeVisible(); + await expect(dashboard).toContainText("Latest complete paginated reads"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "In progress" })).toContainText("0"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Needs attention" })).toContainText("0"); + await expect(dashboard).not.toContainText("Reported aggregate tokens"); + await expect(dashboard).toContainText("No Sessions currently need attention."); + await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); + await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); + + const before = await fixtureRequests(request); + const count = (entries: FixtureRequest[], path: string) => entries.filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length; + const detailPaths = [ + "/v1/agents/sessions/session_snapshot", + "/v1/agents/sessions/session_snapshot/items", + "/v1/agents/sessions/session_snapshot/turns", + ]; + const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + await refresh.click(); + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; + }).toEqual([count(before, "/v1/agents") + 1, count(before, "/v1/agents/sessions") + 1]); + const after = await fixtureRequests(request); + expect(count(after, "/v1/agents")).toBe(count(before, "/v1/agents") + 1); + expect(count(after, "/v1/agents/sessions")).toBe(count(before, "/v1/agents/sessions") + 1); + for (const path of detailPaths) expect(count(after, path)).toBe(count(before, path)); + await attachScreenshot(page, testInfo, "desktop-dashboard-loaded-snapshot"); + + await page.setViewportSize({ width: 390, height: 844 }); + const dashboardBounds = await dashboard.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(dashboardBounds.documentWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); + expect(dashboardBounds.bodyWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); + expect(dashboardBounds.left).toBeGreaterThanOrEqual(0); + expect(dashboardBounds.right).toBeLessThanOrEqual(390); + await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); + await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); + await attachScreenshot(page, testInfo, "narrow-dashboard-loaded-snapshot"); + await page.setViewportSize({ width: 1280, height: 720 }); + + await dashboard.getByRole("button", { name: /Create agent/ }).click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "New Agent" })).toBeVisible(); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await dashboard.getByRole("button", { name: /Start session/ }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await sessionDialog.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click(); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); + + await page.getByRole("button", { name: "System", exact: true }).click(); + const system = page.locator(".system-page"); + await expect(system.getByRole("listitem").filter({ hasText: "Core API" })).toContainText("Available"); + await expect(system.getByRole("listitem").filter({ hasText: "Vaults" })).toContainText("Available"); + await expect(system.getByRole("listitem").filter({ hasText: "Self-hosted" })).toContainText("Enabled"); + await expect(system.getByRole("listitem").filter({ hasText: "Runtime status" })).toContainText("Cannot be pre-checked"); + await expect(system.getByRole("listitem")).toHaveCount(4); + await expect(system).not.toContainText("Source Files"); + await expect(system).not.toContainText("Public capability surface"); + + const beforeSystemRefresh = await fixtureRequests(request); + const systemRefresh = system.getByRole("button", { name: "Refresh System status" }); + await systemRefresh.click(); + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; + }).toEqual([ + count(beforeSystemRefresh, "/v1/agents") + 1, + count(beforeSystemRefresh, "/v1/agents/sessions") + 1, + ]); + const afterSystemRefresh = await fixtureRequests(request); + expect(count(afterSystemRefresh, "/v1/agents")).toBe(count(beforeSystemRefresh, "/v1/agents") + 1); + expect(count(afterSystemRefresh, "/v1/agents/sessions")).toBe(count(beforeSystemRefresh, "/v1/agents/sessions") + 1); + for (const path of detailPaths) expect(count(afterSystemRefresh, path)).toBe(count(beforeSystemRefresh, path)); + await attachScreenshot(page, testInfo, "desktop-system-contract-boundary"); + + await page.setViewportSize({ width: 390, height: 844 }); + const systemBounds = await system.evaluate((element) => { + const rows = [...element.querySelectorAll(".system-summary-cell")].map((row) => row.getBoundingClientRect()); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + rowBounds: rows.map((row) => ({ top: row.top, bottom: row.bottom, height: row.height })), + }; + }); + expect(systemBounds.documentWidth).toBeLessThanOrEqual(systemBounds.viewportWidth); + for (let index = 1; index < systemBounds.rowBounds.length; index += 1) { + expect(systemBounds.rowBounds[index]!.top).toBeGreaterThanOrEqual(systemBounds.rowBounds[index - 1]!.bottom); + } + expect(systemBounds.rowBounds.every((row) => row.height >= 36)).toBe(true); + await attachScreenshot(page, testInfo, "narrow-system-contract-boundary"); +}); + +test("publishes Dashboard counts only after every top-level Agent and Session page loads", async ({ page, request }) => { + await resetFixture(request); + const agentAfters: Array = []; + const sessionAfters: Array = []; + let sessionTemplate: Record | null = null; + + await page.route("**/v1/agents**", async (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET" || !["/v1/agents", "/v1/agents/sessions"].includes(url.pathname)) { + await route.continue(); + return; + } + + const upstream = await route.fetch(); + const payload = await upstream.json() as { + object: string; + data: Array>; + }; + const after = url.searchParams.get("after"); + expect(url.searchParams.get("limit")).toBe("100"); + expect(url.searchParams.get("order")).toBe("desc"); + + if (url.pathname === "/v1/agents") { + agentAfters.push(after); + const data = after === null ? payload.data.slice(0, 2) : payload.data.slice(2); + await route.fulfill({ + response: upstream, + json: { + object: "list", + data, + has_more: after === null, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }, + }); + return; + } + + sessionAfters.push(after); + if (after === null) sessionTemplate = payload.data[0] ?? null; + if (sessionTemplate === null) throw new Error("The fixture did not provide a Session pagination template."); + const original = sessionTemplate; + const second = { + ...original, + id: "session_second_page", + metadata: { fixture: "second-page" }, + created_at: Number(original?.created_at ?? 0) - 1, + last_active_at: Number(original?.last_active_at ?? 0) - 1, + }; + const data = after === null ? [original] : [second]; + await route.fulfill({ + response: upstream, + json: { + object: "list", + data, + has_more: after === null, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }, + }); + }); + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); + expect(agentAfters).toEqual([null, "agent_b"]); + expect(sessionAfters).toEqual([null, "session_snapshot"]); +}); + +test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + const dashboard = page.locator(".dashboard-page"); + const loadedAgents = dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" }); + await expect(loadedAgents).toContainText("3"); + + let template: Record | null = null; + let reads = 0; + await page.route("**/v1/agents**", async (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET" || url.pathname !== "/v1/agents") { + await route.continue(); + return; + } + reads += 1; + if (template === null) { + const upstream = await route.fetch(); + const payload = await upstream.json() as { data: Array> }; + template = payload.data[0] ?? null; + } + expect(template).not.toBeNull(); + const id = `agent_safety_page_${reads}`; + await route.fulfill({ + json: { + object: "list", + data: [{ ...template, id, name: `Safety page ${reads}` }], + has_more: true, + first_id: id, + last_id: id, + }, + }); + }); + + const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + await refresh.click(); + await expect.poll(() => reads).toBe(100); + await expect(refresh).toBeEnabled(); + await expect(dashboard).toContainText("Using the last successful snapshot"); + await expect(dashboard).toContainText("collection pagination exceeded the Web safety limit"); + await expect(loadedAgents).toContainText("3"); + await dashboard.getByRole("button", { name: "Connection settings" }).click(); + const connectionDialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(connectionDialog).toBeVisible(); + await connectionDialog.getByRole("button", { name: "Cancel" }).click(); + expect(reads).toBe(100); +}); + +test("renders self-hosted Environment and Workspace state safely across reconnect and narrow themes", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 1, + environmentEventStatus: 1, + environmentEventCount: 1, + streamCloseCount: 1, + streamCloseDelayMs: 1_000, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const { dialog, panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment pending"); + await expect(panel).toContainText("Self-hosted Environment"); + await expect(panel).toContainText("Pending"); + await expect(panel).toContainText("environment_fixture"); + await expect(panel).toContainText("Workspace is this Environment’s execution directory, not a top-level workspaces API"); + await expect(panel).toContainText("https://executor.example.test/connect"); + await expect(panel.locator('a[href="https://executor.example.test/connect"]')).toHaveCount(0); + await expect(panel.getByRole("link", { name: "Core setup" })).toBeVisible(); + await expect(panel.getByRole("link", { name: "Launcher setup" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); + await expect(page.locator("body")).not.toContainText("launcher:private"); + await expect(page.locator("body")).not.toContainText("executor_token=secret"); + await expect(page.locator('a[href^="file:"]')).toHaveCount(0); + + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/events")).length).toBeGreaterThanOrEqual(2); + await expect(panel).toContainText("Pending"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); + await panel.evaluate((element) => element.scrollIntoView({ block: "start" })); + await attachElementScreenshot(panel, testInfo, "desktop-light-environment-panel"); + await attachScreenshot(page, testInfo, "desktop-light-self-hosted-environment"); + + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "Dark theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + const { panel: narrowPanel } = await openEnvironmentDialog(page); + const widths = await narrowPanel.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.left).toBeGreaterThanOrEqual(0); + expect(widths.right).toBeLessThanOrEqual(390); + await narrowPanel.getByRole("link", { name: "Launcher setup" }).focus(); + await expect(narrowPanel.getByRole("link", { name: "Launcher setup" })).toBeFocused(); + await narrowPanel.evaluate((element) => element.scrollIntoView({ block: "start" })); + await attachElementScreenshot(narrowPanel, testInfo, "narrow-dark-environment-panel"); + await attachScreenshot(page, testInfo, "narrow-dark-self-hosted-environment"); + + await controlFixture(request, { environmentScenario: 2, environmentEventStatus: 0 }); + await page.reload(); + const { panel: unknown, trigger: unknownTrigger } = await openEnvironmentDialog(page); + await expect(unknownTrigger).toHaveAccessibleName("Environment unavailable"); + await expect(unknown).toContainText("Environment unavailable"); + await expect(unknown).toContainText("Unknown type"); + await expect(unknown).not.toContainText("/must-not-render"); + await expect(unknown.locator("a")).toHaveCount(0); + + await controlFixture(request, { environmentScenario: 3, environmentEventStatus: 0 }); + await page.reload(); + await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); + await expect(page.locator(".workspace-error")).toContainText("invalid Session resource"); + await expect(environmentTrigger(page)).toHaveCount(0); +}); + +test("lists Workspace file metadata explicitly, paginates, fails closed, and fences Environment changes", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 7 }); + await openSessionsFromHome(page); + + const { panel } = await openEnvironmentDialog(page); + const files = panel.getByRole("region", { name: "Workspace files" }); + await expect(files).toBeVisible(); + await expect(panel).toContainText("/executor/workspace"); + await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); + await expect(files).toContainText("Files are loaded only when requested"); + expect((await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files"))).toHaveLength(0); + + await files.getByRole("button", { name: "List files" }).click(); + const table = files.getByRole("table", { name: "Workspace file metadata" }); + await expect(table).toContainText("/executor/workspace/file-01.txt"); + await expect(table).toContainText("/executor/workspace/file-20.txt"); + await expect(table).not.toContainText("/executor/workspace/file-21.txt"); + await expect(files.getByRole("button", { name: "Load more" })).toBeVisible(); + + let reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); + expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc"); + expect(reads.at(-1)?.beta).toBe("agents=v1"); + await files.getByRole("button", { name: "Load more" }).click(); + await expect(table).toContainText("/executor/workspace/file-21.txt"); + await expect(files.getByRole("button", { name: "Load more" })).toHaveCount(0); + reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); + expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc&page=fixture-page-2"); + await attachElementScreenshot(files, testInfo, "workspace-file-metadata-list"); + + await controlFixture(request, { environmentFilesStatus: 503 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect(files.getByRole("alert")).toContainText("Workspace files are temporarily unavailable"); + await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); + await expect(files).not.toContainText("No direct regular files were returned"); + + await controlFixture(request, { environmentFilesStatus: 404 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect(files.getByRole("alert")).toContainText("not supported by the connected Core"); + await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); + + const beforeAbort = (await fixtureState(request)).aborts.environmentFileReads; + await controlFixture(request, { environmentFilesStatus: 200, environmentFilesDelayMs: 2_000 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.path.endsWith("/files")).length).toBeGreaterThan(reads.length + 1); + await controlFixture(request, { environmentScenario: 2 }); + await page.getByRole("button", { name: "Recover durable state" }).evaluate((button) => ( + button as HTMLButtonElement + ).click()); + await expect(page.getByRole("region", { name: "Workspace files" })).toHaveCount(0); + await expect.poll(async () => (await fixtureState(request)).aborts.environmentFileReads).toBeGreaterThan(beforeAbort); +}); + +test("hydrates durable expired and unavailable Environment states without a write or paid Turn", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentResourceStatus: "expired", + environmentEventStatus: 0, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + let { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment expired"); + await expect(panel).toContainText("Expired"); + await expect(panel).toContainText("Environment expired"); + await expect(panel).toContainText("no API-managed files, plugins, or skills"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + const initialRequests = await fixtureRequests(request); + const sessionReadIndex = initialRequests.findIndex( + (entry) => entry.method === "GET" && /^\/v1\/agents\/sessions\/[^/]+$/.test(entry.path), + ); + const environmentReadIndex = initialRequests.findIndex( + (entry) => entry.method === "GET" && isEnvironmentResourcePath(entry.path), + ); + expect(sessionReadIndex).toBeGreaterThanOrEqual(0); + expect(environmentReadIndex).toBeGreaterThan(sessionReadIndex); + + let environmentRequests = initialRequests.filter( + (entry) => isEnvironmentResourcePath(entry.path), + ); + expect(environmentRequests.length).toBeGreaterThanOrEqual(1); + expect(environmentRequests.every((entry) => entry.method === "GET" && entry.body === undefined)).toBe(true); + + await controlFixture(request, { environmentRetrieveStatus: 503 }); + await page.reload(); + ({ panel, trigger } = await openEnvironmentDialog(page)); + await expect(trigger).toHaveAccessibleName("Environment unavailable"); + await expect(panel).toContainText("Unavailable"); + await expect(panel).toContainText("conversation remains usable"); + await expect(panel).not.toContainText("Expired"); + await expect(panel).not.toContainText("Connected"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + await controlFixture(request, { + environmentRetrieveStatus: 200, + environmentResourceVariant: "missing_skills", + }); + await page.reload(); + ({ panel, trigger } = await openEnvironmentDialog(page)); + await expect(trigger).toHaveAccessibleName("Environment unavailable"); + await expect(panel).toContainText("Unavailable"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + environmentRequests = (await fixtureRequests(request)).filter( + (entry) => isEnvironmentResourcePath(entry.path), + ); + expect(environmentRequests.every((entry) => entry.method === "GET")).toBe(true); +}); + +test("hydrates durable Environment state even when the live stream is rejected", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentResourceStatus: "expired", + streamStatus: 401, + }); + await openSessionsFromHome(page); + + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment expired"); + await expect(panel).toContainText("Expired"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); + await expect(page.getByText("Events unavailable", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + const requests = await fixtureRequests(request); + expect(requests.some((entry) => ( + entry.method === "GET" && isEnvironmentResourcePath(entry.path) + ))).toBe(true); +}); + +test("keeps canonical Environment UUID identity across Session and resource projections", async ({ page, request }) => { + await resetFixture(request); + const canonicalEnvironmentUuid = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; + await controlFixture(request, { + environmentScenario: 5, + environmentResourceStatus: "connected", + environmentEventStatus: 0, + }); + await openSessionsFromHome(page); + + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment connected"); + await expect(panel).toContainText("Connected"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); + await expect(panel).not.toContainText("Durable Environment status is unavailable"); + await expect(panel).toContainText(canonicalEnvironmentUuid.toUpperCase()); + + const environmentRequests = (await fixtureRequests(request)).filter( + (entry) => entry.method === "GET" && isEnvironmentResourcePath(entry.path), + ); + expect(environmentRequests.length).toBeGreaterThanOrEqual(1); + expect(environmentRequests.every( + (entry) => entry.path === `/v1/agents/environments/${canonicalEnvironmentUuid.toUpperCase()}` && + entry.body === undefined, + )).toBe(true); +}); + +test("applies a buffered live Environment event after an earlier durable snapshot", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentRetrieveDelayMs: 500, + environmentResourceStatus: "pending", + environmentEventStatus: 3, + environmentEventCount: 1, + }); + await openSessionsFromHome(page); + + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment connected"); + await expect(panel).toContainText("Connected"); + await expect(panel).toContainText("last supported live event observed after the durable Environment snapshot"); + await expect(panel).not.toContainText("Pending"); +}); + +test("loads every Turn page, reconciles terminal events, and keeps diagnostics out of Conversation", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsPageSize: 2, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const conversationTab = page.getByRole("tab", { name: "Conversation" }); + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); + + await page.getByRole("tab", { name: "Trace" }).click(); + const trace = page.getByRole("tabpanel", { name: "Trace" }); + const diagnostics = trace.locator("details.trace-turn-diagnostics"); + await expect(diagnostics).not.toHaveAttribute("open", ""); + await diagnostics.locator("summary").click(); + await expect(diagnostics).toHaveAttribute("open", ""); + + const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); + await expect(timeline).toContainText("7 observed Turns"); + for (const status of ["Queued", "In progress", "Waiting", "Completed", "Failed", "Cancelled"]) { + await expect(timeline.getByRole("img", { name: `Turn status: ${status}` }).first()).toBeVisible(); + } + await expect(timeline).toContainText("Running ·"); + await expect(timeline.locator('[data-turn-id="turn_completed"]')).toContainText("7s"); + await expect(timeline.getByRole("region", { name: "Session aggregate usage" })).toContainText("26"); + await expect(timeline.locator('[data-turn-id="turn_completed"]').getByRole("group", { name: "Usage for Turn turn_completed" })).toContainText("13"); + const failed = timeline.locator('[data-turn-id="turn_failed"]'); + await expect(failed).toContainText("The execution could not complete."); + await expect(failed).toContainText("1 linked Item"); + await expect(trace.getByText("Persisted input before the Turn failed.")).toBeVisible(); + await expect(timeline).toContainText("1 Item is not associated with an observed Turn yet."); + + const readsBeforeTerminal = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/turns") + )); + expect(readsBeforeTerminal.length).toBeGreaterThanOrEqual(4); + expect(readsBeforeTerminal.some((entry) => entry.query === "?limit=100&order=asc")).toBe(true); + expect(readsBeforeTerminal.some((entry) => entry.query?.includes("after=turn_in_progress"))).toBe(true); + expect(readsBeforeTerminal.every((entry) => entry.body === undefined)).toBe(true); + + const terminal = timeline.locator('[data-turn-id="turn_terminal_refresh"]'); + await expect(terminal).toHaveAttribute("data-turn-status", "in_progress"); + await emitTurnFixture(request, "completed"); + await expect(terminal).toHaveAttribute("data-turn-status", "completed"); + await expect(terminal).toContainText("Turn usage"); + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/turns")).length).toBeGreaterThan(readsBeforeTerminal.length); + + await controlFixture(request, { turnsRetrieveStatus: 503 }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + await expect(trace.locator(".trace-load-error").filter({ hasText: "Turn history is incomplete" })).toBeVisible(); + await expect(timeline.locator(".turn-timeline-failure")).toContainText("Couldn’t load Turn history"); + await expect(timeline).toContainText("last observed Turn timeline remains visible"); + await expect(trace.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await diagnostics.evaluate((element) => element.scrollIntoView({ block: "start" })); + const diagnosticsLayout = await diagnostics.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + overflowY: getComputedStyle(element).overflowY, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }; + }); + expect(diagnosticsLayout.document).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.body).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.left).toBeGreaterThanOrEqual(0); + expect(diagnosticsLayout.right).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.overflowY).toBe("auto"); + expect(diagnosticsLayout.scrollHeight).toBeGreaterThan(diagnosticsLayout.clientHeight); + await diagnostics.evaluate((element) => { element.scrollTop = element.scrollHeight; }); + await expect(timeline.locator(".turn-card").last()).toBeVisible(); + await attachElementScreenshot(diagnostics, testInfo, "narrow-turn-diagnostics"); + + await conversationTab.click(); + await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); + await expect(conversation.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); +}); + +test("presents an honest searchable Trace workbench without changing the conversation draft", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsPageSize: 2, + itemsScenario: 2, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const viewTabs = page.getByRole("tablist", { name: "Session view" }); + const conversationTab = viewTabs.getByRole("tab", { name: "Conversation" }); + const traceTab = viewTabs.getByRole("tab", { name: "Trace" }); + const composer = page.getByLabel("Message the Agent"); + await expect(page.locator("#session-trace-panel")).toBeHidden(); + await composer.fill("Draft survives Trace inspection\nwith a second line"); + const sendsBefore = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + + await conversationTab.focus(); + await page.keyboard.press("ArrowRight"); + await expect(traceTab).toHaveAttribute("aria-selected", "true"); + const trace = page.getByRole("tabpanel", { name: "Trace" }); + await expect(trace).toBeVisible(); + await expect(trace).toContainText("Known Turn time"); + const diagnostics = trace.locator("details.trace-turn-diagnostics"); + await expect(diagnostics).not.toHaveAttribute("open", ""); + await expect(diagnostics.locator("summary")).toContainText("Turn diagnostics"); + await expect(trace).toContainText("Turns"); + await expect(trace).toContainText("Tool calls"); + await expect(trace).toContainText("Equal-width sequence · not time-scaled"); + await expect(trace.locator(".trace-order-scroll")).toHaveCount(1); + await expect(trace).toContainText("Core reports Turn wall-clock time, but not per-item timing"); + await expect(trace).toContainText("Configured instructions"); + await expect(trace).toContainText("Completed Turn output remains in the conversation."); + await expect(trace).not.toContainText("TTFT"); + await expect(trace).not.toContainText("Throughput"); + + const search = trace.getByRole("searchbox", { name: "Search trace" }); + await search.fill("Persisted input failed"); + await expect(trace.locator(".trace-ledger-row")).toHaveCount(1); + await expect(trace).toContainText("Persisted input before the Turn failed."); + const messageDuration = trace.locator(".trace-ledger-row").filter({ hasText: "Persisted input before the Turn failed." }).locator(".trace-row-duration"); + await expect(messageDuration.locator('[aria-hidden="true"]')).toHaveText("—"); + await expect(messageDuration).toHaveAttribute("title", "Core does not provide per-item timing."); + await expect(messageDuration.locator(".trace-visually-hidden")).toHaveText("Per-item timing not provided by Core."); + await search.fill(""); + + const patchRow = trace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await expect(patchRow.locator('.trace-row-duration [aria-hidden="true"]')).toHaveText("41 ms"); + await patchRow.click(); + const detail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(detail).toBeVisible(); + await expect(detail.getByRole("button", { name: "Close trace details" })).toBeFocused(); + const previewTab = detail.getByRole("tab", { name: "Preview" }); + await expect(previewTab).toHaveAttribute("aria-controls", "session-trace-panel-detail-content"); + await previewTab.click(); + await expect(detail.getByRole("tabpanel", { name: "Preview" })).toHaveAttribute("tabindex", "0"); + const viewer = detail.getByRole("region", { name: "Parsar apply patch diff" }); + await expect(viewer).toContainText("3 files"); + await expect(viewer).toContainText("Completed"); + const desktopSplit = await Promise.all([ + trace.locator(".trace-ledger").boundingBox(), + detail.boundingBox(), + ]); + expect(desktopSplit[0]).not.toBeNull(); + expect(desktopSplit[1]).not.toBeNull(); + expect(desktopSplit[1]!.x).toBeGreaterThanOrEqual(desktopSplit[0]!.x + desktopSplit[0]!.width - 1); + await attachScreenshot(page, testInfo, "desktop-trace-detail"); + await page.keyboard.press("Escape"); + await expect(detail).toHaveCount(0); + await expect(patchRow).toBeFocused(); + + await patchRow.click(); + await search.fill("filter selected row out"); + await expect(patchRow).toBeHidden(); + await page.getByRole("button", { name: "Close trace details" }).click(); + await expect(search).toBeFocused(); + await search.fill(""); + + await conversationTab.click(); + await expect(composer).toBeVisible(); + await expect(composer).toHaveValue("Draft survives Trace inspection\nwith a second line"); + const sendsAfter = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + expect(sendsAfter).toBe(sendsBefore); + + await page.setViewportSize({ width: 390, height: 844 }); + await traceTab.click(); + const narrowTrace = page.getByRole("tabpanel", { name: "Trace" }); + const narrowPatchRow = narrowTrace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await narrowPatchRow.click(); + const narrowDetail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(narrowDetail).toBeVisible(); + await expect(narrowTrace.locator(".trace-ledger")).toBeHidden(); + const widths = await narrowDetail.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.left).toBeGreaterThanOrEqual(0); + expect(widths.right).toBeLessThanOrEqual(widths.viewport); + await attachScreenshot(page, testInfo, "narrow-trace-detail"); +}); + +test("drops a delayed Turn page after switching Sessions", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsRetrieveDelayMs: 700, + turnsPageSize: 2, + }); + await openSessionsFromHome(page); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible({ timeout: 1_500 }); + await page.getByRole("tab", { name: "Trace" }).click(); + const diagnostics = page.locator("details.trace-turn-diagnostics"); + await diagnostics.locator("summary").click(); + const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); + await expect(timeline).toContainText("Loading every Turn page"); + await page.getByRole("button", { name: "Agents" }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await startSessionWithSecondAgent(page); + + await page.getByRole("tab", { name: "Trace" }).click(); + const nextDiagnostics = page.locator("details.trace-turn-diagnostics"); + await nextDiagnostics.locator("summary").click(); + const nextTimeline = nextDiagnostics.getByRole("region", { name: "Turn timeline" }); + await expect(nextTimeline).toContainText("No Turns reported yet."); + await page.waitForTimeout(3_000); + await expect(nextTimeline).not.toContainText("turn_queued"); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); + + const turnReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/turns") + )); + expect(turnReads.some((entry) => entry.path.includes("session_snapshot"))).toBe(true); + expect(turnReads.some((entry) => entry.path.includes("session_created_"))).toBe(true); +}); + +test("renders Parsar patches as accessible read-only diffs in desktop and narrow themes", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { itemsScenario: 1 }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const completedTrace = page.locator('[data-work-trace="completed"]'); + await completedTrace.getByRole("button", { name: /Completed/ }).click(); + const completedStep = completedTrace.locator('[data-trace-step="patch_completed"]'); + await completedStep.getByRole("button", { name: /apply_patch/ }).click(); + const viewer = completedStep.getByRole("region", { name: "Parsar apply patch diff" }); + await expect(viewer).toContainText("3 files"); + await expect(viewer).toContainText("Completed"); + await expect(viewer.getByRole("button", { name: /modify src\/modify.ts/ })).toHaveAttribute("aria-expanded", "true"); + await viewer.getByRole("button", { name: /modify src\/modify.ts/ }).focus(); + await page.keyboard.press("Enter"); + await expect(viewer.getByRole("button", { name: /modify src\/modify.ts/ })).toHaveAttribute("aria-expanded", "false"); + await viewer.getByText("Raw arguments").click(); + await viewer.getByText("Raw result").click(); + await expect(viewer).toContainText('"applied": true'); + await expect(viewer.locator("script")).toHaveCount(0); + const runningStep = page.locator('[data-trace-step="patch_running"]'); + await runningStep.getByRole("button", { name: /apply_patch/ }).click(); + await expect(runningStep.locator('[data-patch-status="in_progress"]')).toContainText("In progress"); + const failedTrace = page.locator('[data-work-trace="failed"]'); + await failedTrace.getByRole("button", { name: /Failed/ }).click(); + const failedStep = failedTrace.locator('[data-trace-step="patch_failed"]'); + await failedStep.getByRole("button", { name: /apply_patch/ }).click(); + await expect(failedStep.locator('[data-patch-status="failed"]')).toContainText("Failed"); + const fallback = page.locator('[data-trace-step="patch_alternate"]'); + await fallback.getByRole("button", { name: /apply_patch/ }).click(); + await expect(fallback).toContainText("malformed alternate shape"); + await expect(fallback.getByRole("region", { name: "Parsar apply patch diff" })).toHaveCount(0); + await attachScreenshot(page, testInfo, "desktop-light-parsar-diff"); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "Dark theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + const widths = await viewer.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { viewport: innerWidth, document: document.documentElement.scrollWidth, body: document.body.scrollWidth, viewerLeft: box.left, viewerRight: box.right }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.viewerLeft).toBeGreaterThanOrEqual(0); + expect(widths.viewerRight).toBeLessThanOrEqual(widths.viewport); + await expect(viewer).toBeVisible(); + await attachScreenshot(page, testInfo, "narrow-dark-parsar-diff"); +}); + +test("manually retries uncertain sends with the original key only while the payload is unchanged", async ({ page, request }, testInfo) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const composer = page.getByLabel("Message the Agent"); + + await controlFixture(request, { sendResponseLoss: 1 }); + await composer.fill("uncertain payload"); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.locator(".session-send-error")).toContainText("may have accepted this message"); + await expect(composer).toHaveValue("uncertain payload"); + await attachScreenshot(page, testInfo, "desktop-uncertain-send"); + await page.getByRole("button", { name: "Send message" }).click(); + + let sends = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(sends).toHaveLength(2); + expect(sends[0]?.idempotencyKey).toBeTruthy(); + expect(sends[1]?.idempotencyKey).toBe(sends[0]?.idempotencyKey); + + await controlFixture(request, { sendResponseLoss: 1 }); + await composer.fill("original before edit"); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(composer).toHaveValue("original before edit"); + await composer.fill("edited payload"); + await page.getByRole("button", { name: "Send message" }).click(); + + sends = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(sends).toHaveLength(4); + expect(sends[3]?.idempotencyKey).not.toBe(sends[2]?.idempotencyKey); + + await controlFixture(request, { sendStatus: 422 }); + await composer.fill("permanently rejected"); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.locator(".session-send-error")).toContainText("Agent Core rejected the message"); + await page.getByRole("button", { name: "Send message" }).click(); + + sends = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(sends).toHaveLength(6); + expect(sends[5]?.idempotencyKey).not.toBe(sends[4]?.idempotencyKey); + await attachScreenshot(page, testInfo, "desktop-send-recovery"); +}); + +test("reuses Function result identity only for an unchanged uncertain explicit retry", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 10 }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const editor = page.getByLabel("Function result or error"); + const submit = page.getByRole("button", { name: "Submit result" }); + const eventWrites = async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + + await editor.fill("uncertain result"); + await controlFixture(request, { sendResponseLoss: 1 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await expect(editor).toHaveValue("uncertain result"); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(2); + let writes = await eventWrites(); + expect(writes[0]?.body).toEqual(writes[1]?.body); + expect(writes[0]?.idempotencyKey).toBeTruthy(); + expect(writes[1]?.idempotencyKey).toBe(writes[0]?.idempotencyKey); + + await editor.fill("before edit"); + await controlFixture(request, { sendResponseLoss: 1 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await editor.fill("after edit"); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(4); + writes = await eventWrites(); + expect(writes[3]?.idempotencyKey).not.toBe(writes[2]?.idempotencyKey); + + await editor.fill("definite rejection"); + await controlFixture(request, { sendStatus: 422 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(6); + writes = await eventWrites(); + expect(writes[5]?.idempotencyKey).not.toBe(writes[4]?.idempotencyKey); +}); + +test("keeps cancellation available for an Environment-only required action", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 6 }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); + const writesBefore = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + const cancel = page.getByRole("button", { name: "Cancel active Turn" }); + await expect(cancel).toBeEnabled(); + await controlFixture(request, { sendResponseLoss: 1 }); + await cancel.click(); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(writesBefore + 1); + await expect(cancel).toBeEnabled(); + await cancel.click(); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(writesBefore + 2); + const writes = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(writes.at(-1)?.body).toEqual({ events: [{ type: "agent.session.input.cancel" }] }); + expect(writes.at(-2)?.idempotencyKey).toBeTruthy(); + expect(writes.at(-1)?.idempotencyKey).toBe(writes.at(-2)?.idempotencyKey); +}); diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts new file mode 100644 index 000000000..e357bcd74 --- /dev/null +++ b/apps/web/e2e/core-connection.spec.ts @@ -0,0 +1,456 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +const probeRequestPattern = /\/v1\/agents\?limit=1$/; + +interface ProbeInstrumentationWindow extends Window { + __probeAbortCount?: number; + __probeCallCount?: number; + __resolveFirstProbe?: (() => void) | null; + __stalledProbeCallCount?: number; +} + +async function resetFixture(request: APIRequestContext) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function boot(page: Page, request: APIRequestContext) { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toBeVisible(); +} + +async function openConnection(page: Page) { + const trigger = page.getByRole("button", { name: "Configure Agent Core connection" }); + await trigger.click(); + const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(dialog).toBeVisible(); + return { dialog, trigger }; +} + +test("migrates a stale local token without sending browser authorization when proxy auth is disabled", async ({ + page, + request, +}) => { + await page.addInitScript(() => { + localStorage.setItem("agents-core-web.core-base-url", "/v1"); + sessionStorage.setItem("agents-core-web.core-token", "historical-local-token"); + }); + let browserAuthorizationSeen = false; + page.on("request", (browserRequest) => { + const url = new URL(browserRequest.url()); + if (url.pathname.startsWith("/v1/") && browserRequest.headers().authorization) { + browserAuthorizationSeen = true; + } + }); + await page.route(probeRequestPattern, (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ object: "list", data: [], has_more: false, first_id: null, last_id: null }), + })); + + await boot(page, request); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); + const { dialog } = await openConnection(page); + await expect(dialog).toContainText("Server-managed key not detected"); + await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); + await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); + await dialog.getByRole("button", { name: "Test connection" }).click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + expect(browserAuthorizationSeen).toBe(false); + + await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); + await expect(dialog.getByLabel("Bearer token")).toHaveValue(""); +}); + +test("scrubs a legacy remote HTTP connection before any bearer can leave the page", async ({ page, request }) => { + await page.addInitScript(() => { + localStorage.setItem("agents-core-web.core-base-url", "http://core.example/v1"); + sessionStorage.setItem("agents-core-web.core-token", "legacy-remote-token"); + }); + let unsafeRequests = 0; + await page.route("http://core.example/**", (route) => { + unsafeRequests += 1; + return route.abort("blockedbyclient"); + }); + + await boot(page, request); + + await expect.poll(() => page.evaluate(() => localStorage.getItem("agents-core-web.core-base-url"))).toBe("/v1"); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); + expect(unsafeRequests).toBe(0); + const { dialog } = await openConnection(page); + await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); +}); + +test("rejects an unsafe runtime target before a local proxy write can be attempted", async ({ page, request }) => { + await boot(page, request); + + const outcome = await page.evaluate(async () => { + const modulePath = "/src/lib/connection.ts"; + const { createCore } = await import(/* @vite-ignore */ modulePath); + const originalFetch = window.fetch; + const calls: Array<{ method: string; url: string }> = []; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + method: init?.method ?? "GET", + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + }); + return new Response(JSON.stringify({ id: "must-not-be-created" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + try { + const core = createCore({ + baseUrl: "http://core.example/v1", + token: "must-not-leave-browser", + }); + await core.createAgent({ model: "must-not-create-locally" }); + return { calls, error: null }; + } catch (error) { + return { + calls, + error: error instanceof Error + ? { + code: "code" in error ? String(error.code) : null, + message: error.message, + name: error.name, + } + : null, + }; + } finally { + window.fetch = originalFetch; + } + }); + + expect(outcome.calls).toEqual([]); + expect(outcome.error).toEqual({ + code: "invalid_core_base_url", + message: "The Agent Core base URL is invalid or unsafe.", + name: "InvalidCoreConnectionError", + }); + expect(JSON.stringify(outcome)).not.toContain("must-not-leave-browser"); + expect(JSON.stringify(outcome)).not.toContain("http://core.example/v1"); +}); + +test("switches real connection modes and fences stale probes when the draft changes or reopens", async ({ + page, + request, +}) => { + await page.addInitScript(() => { + const target = window as ProbeInstrumentationWindow; + const originalFetch = window.fetch.bind(window); + let holdFirstProbe = true; + target.__probeAbortCount = 0; + target.__probeCallCount = 0; + target.__resolveFirstProbe = null; + + window.fetch = (input, init) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const candidate = new URL(url, window.location.href); + const isProbe = candidate.pathname.endsWith("/v1/agents") + && candidate.searchParams.get("limit") === "1" + && [...candidate.searchParams].length === 1; + if (isProbe) target.__probeCallCount = (target.__probeCallCount ?? 0) + 1; + if (holdFirstProbe && isProbe) { + holdFirstProbe = false; + init?.signal?.addEventListener("abort", () => { + target.__probeAbortCount = (target.__probeAbortCount ?? 0) + 1; + }, { once: true }); + return new Promise((resolve) => { + target.__resolveFirstProbe = () => resolve(new Response(JSON.stringify({ + object: "list", + data: [], + has_more: false, + first_id: null, + last_id: null, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }); + } + return originalFetch(input, init); + }; + }); + await boot(page, request); + const { dialog } = await openConnection(page); + const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); + const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); + const testConnection = dialog.getByRole("button", { name: "Test connection" }); + + await expect(local).toBeChecked(); + await expect(dialog.getByLabel("Compatible Core base URL")).toHaveCount(0); + await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__probeCallCount ?? 0 + ))).toBe(1); + await expect.poll(() => page.evaluate(() => ( + typeof (window as ProbeInstrumentationWindow).__resolveFirstProbe + ))).toBe("function"); + + await advanced.click(); + await expect(advanced).toBeChecked(); + await expect(dialog.getByRole("status")).toHaveCount(0); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__probeAbortCount ?? 0 + ))).toBe(1); + await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveFirstProbe?.()); + await expect(dialog.getByText("Core API authenticated")).toHaveCount(0); + + const directBase = `${new URL(page.url()).origin}/v1`; + const baseUrl = dialog.getByLabel("Compatible Core base URL"); + const token = dialog.getByLabel("Bearer token"); + for (const loopback of [ + "http://localhost:8091/v1", + "http://worker.localhost:8091/v1", + "http://127.0.0.42:8091/v1", + "http://[::1]:8091/v1", + ]) { + await baseUrl.fill(loopback); + await expect(testConnection).toBeEnabled(); + } + for (const unsafe of [ + "http://core.example/v1", + `${directBase}?`, + `${directBase}#`, + ]) { + await baseUrl.fill(unsafe); + await expect(dialog.getByText(/Enter an HTTPS URL, or an HTTP loopback URL/)).toBeVisible(); + await expect(testConnection).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "Apply connection" })).toBeDisabled(); + } + expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__probeCallCount)).toBe(1); + + await baseUrl.fill(directBase); + await token.fill("current-tab-token"); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await token.fill("changed-current-tab-token"); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await baseUrl.fill(`${directBase}/`); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await local.click(); + await expect(local).toBeChecked(); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); + const reopened = (await openConnection(page)).dialog; + await expect(reopened.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); + await expect(reopened.getByRole("status")).toHaveCount(0); + await expect(reopened.getByRole("alert")).toHaveCount(0); +}); + +test("announces loading, authenticated access, and each safe failure state from one GET", async ({ + page, + request, +}) => { + const replies = [ + { + status: 200, + body: { + object: "list", + data: [{ + id: "agent_empty_model", + object: "agent", + model: "", + name: null, + instructions: null, + metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + created_at: 1_700_000_000, + updated_at: 1_700_000_001, + }], + has_more: false, + first_id: "agent_empty_model", + last_id: "agent_empty_model", + }, + }, + { status: 202, body: { object: "list", data: [], has_more: false, first_id: null, last_id: null } }, + { status: 200, body: { object: "list", data: [null], has_more: false, first_id: null, last_id: null } }, + { status: 401, body: { error: { code: "invalid_api_key", message: "safe fixture failure" } } }, + { status: 401, body: { error: { code: "gateway_auth_required", message: "safe fixture failure" } } }, + { status: 400, body: { error: { code: "invalid_beta_header", message: "safe fixture failure" } } }, + { status: 503, body: { error: { code: "unavailable", message: "safe fixture failure" } } }, + { abort: true }, + ]; + const methods: string[] = []; + + await page.route(probeRequestPattern, async (route) => { + methods.push(route.request().method()); + const reply = replies.shift(); + if (!reply) return route.abort("failed"); + await new Promise((resolve) => setTimeout(resolve, 150)); + if ("abort" in reply) return route.abort("failed"); + return route.fulfill({ + status: reply.status, + contentType: "application/json", + body: JSON.stringify(reply.body), + }); + }); + + await boot(page, request); + const { dialog } = await openConnection(page); + const action = dialog.getByRole("button", { name: "Test connection" }); + const cases: Array<{ role: "status" | "alert"; text: string; absentText?: string }> = [ + { role: "status", text: "Core API authenticated" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Authentication failed" }, + { role: "alert", text: "Core returned HTTP 401", absentText: "invalid_api_key" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Core returned HTTP 503" }, + { role: "alert", text: "Core unreachable" }, + ]; + + for (const expected of cases) { + await action.click(); + const loading = dialog.getByRole("status"); + await expect(loading).toHaveAttribute("aria-live", "polite"); + await expect(loading).toContainText("Testing Core connection…"); + const terminal = dialog.getByRole(expected.role); + await expect(terminal).toContainText(expected.text); + await expect(terminal).toContainText("Chat uses the current Agents API contract"); + await expect(terminal).toContainText("does not start a Turn or verify its runtime dependencies"); + if (expected.absentText) await expect(terminal).not.toContainText(expected.absentText); + } + + expect(methods).toEqual(["GET", "GET", "GET", "GET", "GET", "GET", "GET", "GET"]); + expect(replies).toHaveLength(0); +}); + +test("turns a stalled probe into one bounded unreachable result", async ({ page, request }) => { + await page.addInitScript(() => { + const target = window as ProbeInstrumentationWindow; + const originalFetch = window.fetch.bind(window); + target.__stalledProbeCallCount = 0; + window.fetch = (input, init) => { + const value = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const candidate = new URL(value, window.location.href); + const stalledProbe = candidate.pathname.endsWith("/v1/agents") + && candidate.searchParams.get("limit") === "1"; + if (!stalledProbe) return originalFetch(input, init); + target.__stalledProbeCallCount = (target.__stalledProbeCallCount ?? 0) + 1; + return new Promise((_resolve, reject) => { + const rejectAbort = () => reject(init?.signal?.reason ?? new DOMException("Aborted", "AbortError")); + if (init?.signal?.aborted) rejectAbort(); + else init?.signal?.addEventListener("abort", rejectAbort, { once: true }); + }); + }; + }); + + await boot(page, request); + const { dialog } = await openConnection(page); + await dialog.getByRole("button", { name: "Test connection" }).click(); + await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__stalledProbeCallCount ?? 0 + ))).toBe(1); + await expect(dialog.getByRole("alert")).toContainText("Core unreachable", { timeout: 7_500 }); + await expect(dialog.getByRole("alert")).toContainText("Chat uses the current Agents API contract"); + await expect(dialog.getByRole("alert")).toContainText("does not start a Turn or verify its runtime dependencies"); + expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__stalledProbeCallCount)).toBe(1); +}); + +test("supports keyboard mode selection, traps focus, and returns focus on Escape", async ({ page, request }) => { + await boot(page, request); + const { dialog, trigger } = await openConnection(page); + const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); + const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); + const apply = dialog.getByRole("button", { name: "Apply connection" }); + const close = dialog.getByRole("button", { name: "Close dialog" }); + + await expect(local).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(advanced).toBeChecked(); + await expect(advanced).toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(local).toBeChecked(); + await expect(local).toBeFocused(); + + await close.focus(); + await page.keyboard.press("Shift+Tab"); + await expect(apply).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(close).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test("keeps both connection modes operable without horizontal overflow at 390 px", async ({ page, request }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await boot(page, request); + const { dialog } = await openConnection(page); + const apply = dialog.getByRole("button", { name: "Apply connection" }); + const cancel = dialog.getByRole("button", { name: "Cancel" }); + + const assertContained = async () => { + const metrics = await page.evaluate(() => ({ + viewportWidth: innerWidth, + viewportHeight: innerHeight, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + })); + const box = await dialog.boundingBox(); + expect(box).not.toBeNull(); + expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.bodyWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(box?.x ?? -1).toBeGreaterThanOrEqual(0); + expect(box?.y ?? -1).toBeGreaterThanOrEqual(0); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(metrics.viewportWidth); + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(metrics.viewportHeight); + const widths = await dialog.evaluate((card) => { + const body = card.querySelector(".modal-body"); + return { + cardClient: card.clientWidth, + cardScroll: card.scrollWidth, + bodyClient: body?.clientWidth ?? 0, + bodyScroll: body?.scrollWidth ?? 0, + }; + }); + expect(widths.cardScroll).toBeLessThanOrEqual(widths.cardClient); + expect(widths.bodyScroll).toBeLessThanOrEqual(widths.bodyClient); + await expect(apply).toBeInViewport(); + await expect(cancel).toBeInViewport(); + }; + + await assertContained(); + const testConnection = dialog.getByRole("button", { name: "Test connection" }); + await testConnection.scrollIntoViewIfNeeded(); + await expect(testConnection).toBeInViewport(); + + await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); + await dialog.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); + await assertContained(); + await dialog.getByLabel("Compatible Core base URL").scrollIntoViewIfNeeded(); + await expect(dialog.getByLabel("Compatible Core base URL")).toBeInViewport(); +}); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs new file mode 100644 index 000000000..5aebe44dd --- /dev/null +++ b/apps/web/e2e/fixture-core.mjs @@ -0,0 +1,1648 @@ +import http from "node:http"; + +const host = "127.0.0.1"; +const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); +const baseline = 1_789_438_800; +const canonicalEnvironmentUuid = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; +const hostedEnvironmentUuid = "7a263c51-6bf0-4d53-8518-c792eb1f0d21"; +const sourceFileUuid = "16e1f26e-8cf6-4272-9c31-d470b08d31af"; +const canonicalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +function patchItems() { + const longLine = `+export const longValue = "${"x".repeat(2_000)}";`; + return [ + { id: "patch_intro", turn_id: "turn_patch_intro", type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: "Review the fixture patch" }] }, + { + id: "patch_completed", turn_id: "turn_patch_completed", type: "function_call", status: "completed", name: "apply_patch", call_id: "call_completed", + arguments: { changes: [ + { path: "src/.ts", kind: { type: "add", move_path: null }, diff: `--- /dev/null\n+++ b/src/.ts\n@@ -0,0 +1,2 @@\n+\n${longLine}` }, + { path: "src/modify.ts", kind: { type: "update", move_path: null }, diff: "@@ -1 +1 @@\n-old value\n+new value" }, + { path: "src/delete.ts", kind: { type: "delete", move_path: null }, diff: "@@ -1 +0,0 @@\n-removed" }, + ] }, + }, + { id: "patch_output", turn_id: "turn_patch_completed", type: "function_call_output", status: "completed", call_id: "call_completed", output: { applied: true }, duration_ms: 41 }, + { id: "patch_running", turn_id: "turn_patch_running", type: "function_call", status: "in_progress", name: "apply_patch", call_id: "call_running", arguments: { changes: [{ path: "src/running.ts", kind: { type: "update" }, diff: "@@ -1 +1 @@\n-wait\n+working" }] } }, + { id: "patch_failed", turn_id: "turn_patch_failed", type: "function_call", status: "failed", name: "apply_patch", call_id: "call_failed", arguments: { changes: [{ path: "src/failed.ts", kind: { type: "delete" }, diff: "@@ -1 +0,0 @@\n-failed" }] }, error: { message: "fixture failure" } }, + { id: "patch_alternate", turn_id: "turn_patch_alternate", type: "function_call", status: "in_progress", name: "apply_patch", call_id: "call_alternate", arguments: { patch: "*** Begin Patch\nmalformed alternate shape" } }, + ]; +} + +function observableTurns() { + return [ + { id: "turn_queued", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "queued", created_at: baseline - 18, started_at: null, completed_at: null, error: null, usage: null }, + { id: "turn_in_progress", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "in_progress", created_at: baseline - 17, started_at: baseline - 16, completed_at: null, error: null, usage: null }, + { id: "turn_waiting", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "waiting", created_at: baseline - 15, started_at: baseline - 14, completed_at: null, error: null, usage: null }, + { id: "turn_completed", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "completed", created_at: baseline - 13, started_at: baseline - 12, completed_at: baseline - 5, error: null, usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13, input_tokens_details: { cached_tokens: 4 }, output_tokens_details: { reasoning_tokens: 2 } } }, + { id: "turn_failed", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "failed", created_at: baseline - 4, started_at: baseline - 3, completed_at: baseline - 2, error: { code: "internal_error", message: "The execution could not complete." }, usage: null }, + { id: "turn_cancelled", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "cancelled", created_at: baseline - 1, started_at: baseline, completed_at: baseline + 1, error: null, usage: null }, + { id: "turn_terminal_refresh", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "in_progress", created_at: baseline + 2, started_at: baseline + 3, completed_at: null, error: null, usage: null }, + ]; +} + +function observableTurnItems() { + return [ + { id: "turn_message", turn_id: "turn_completed", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: "Completed Turn output remains in the conversation." }] }, + { id: "failed_input", turn_id: "turn_failed", type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: "Persisted input before the Turn failed." }] }, + { id: "unassociated", turn_id: "turn_not_loaded", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: "This Item is waiting for its Turn page." }] }, + ]; +} + +function savedAgent(id, name, model, updatedAt) { + return { + id, + object: "agent", + model, + name, + instructions: `Instructions for ${name}`, + metadata: { team: "web", fixture: "safe" }, + multi_agent: { enabled: id === "agent_a", max_concurrent_subagents: id === "agent_a" ? 2 : null }, + reasoning: id === "agent_a" ? { effort: "high", summary: "concise" } : {}, + service_tier: id === "agent_a" ? "priority" : "auto", + text: { format: { type: "text" }, verbosity: id === "agent_a" ? "high" : "medium" }, + tools: id === "agent_a" ? [{ type: "tool_search" }] : [], + created_at: baseline - 600, + updated_at: updatedAt, + }; +} + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasOnlyKeys(value, allowed) { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function isEmptyObject(value) { + return value == null || isRecord(value) && Object.keys(value).length === 0; +} + +function isSafeMcpUrl(value) { + if ( + typeof value !== "string" + || /^\p{White_Space}|\p{White_Space}$/u.test(value) + || /[\u0000-\u0020\u007f\\]/u.test(value) + || value.includes("?") + || value.includes("#") + || /%(?![0-9A-Fa-f]{2})/u.test(value) + ) return false; + const schemeSeparator = value.indexOf("://"); + const authority = schemeSeparator >= 0 ? value.slice(schemeSeparator + 3).split("/", 1)[0] : ""; + if (!authority || authority.includes("@") || authority.includes("%") || /[{}\x60]/u.test(authority)) return false; + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) && Boolean(url.hostname) && !url.username && !url.password; + } catch { + return false; + } +} + +function isCanonicalExecutionFunction(tool) { + return hasOnlyKeys(tool, ["type", "name", "description", "parameters", "defer_loading"]) + && typeof tool.name === "string" + && typeof tool.description === "string" + && isRecord(tool.parameters) + && (tool.defer_loading === undefined || typeof tool.defer_loading === "boolean"); +} + +function isCanonicalExecutionMcp(tool) { + const transport = tool.transport; + const allowedTools = tool.allowed_tools; + return hasOnlyKeys(tool, [ + "type", "server_label", "transport", "allowed_tools", "connection_origin", + "credential_id", "request_metadata", "required", + ]) + && typeof tool.server_label === "string" + && !/^\p{White_Space}*$/u.test(tool.server_label) + && isRecord(transport) + && hasOnlyKeys(transport, ["type", "server_url", "headers"]) + && transport.type === "http" + && isSafeMcpUrl(transport.server_url) + && isEmptyObject(transport.headers) + && tool.connection_origin === "service" + && (tool.credential_id == null || typeof tool.credential_id === "string") + && isEmptyObject(tool.request_metadata) + && (tool.required === undefined || typeof tool.required === "boolean") + && (allowedTools == null || Array.isArray(allowedTools) && allowedTools.every((name) => typeof name === "string" && name.length > 0)); +} + +function sessionAdmissionError(agent) { + if (/^\p{White_Space}*$/u.test(agent.model)) return "Execution currently requires a nonempty model."; + if (agent.multi_agent.enabled || agent.multi_agent.max_concurrent_subagents !== null) return "Enabled multi_agent execution is not supported by this service yet."; + if (agent.reasoning.effort != null || agent.reasoning.summary != null) return "Explicit reasoning execution options are not supported by this service yet."; + if (agent.service_tier !== "auto") return "Execution currently supports service_tier=auto only."; + if (agent.text.format.type !== "text") return "Execution currently supports text.format.type=text only."; + + const functionNames = new Set(); + const mcpLabels = new Set(); + let functionCount = 0; + for (const tool of agent.tools) { + if (tool.type === "function") { + functionCount += 1; + if (!isCanonicalExecutionFunction(tool) || /^\p{White_Space}*$/u.test(tool.name) || Buffer.byteLength(tool.name, "utf8") > 512 || functionNames.has(tool.name) || tool.defer_loading === true) { + return "Invalid execution function fields."; + } + functionNames.add(tool.name); + } else if (tool.type === "mcp") { + if (!isCanonicalExecutionMcp(tool) || mcpLabels.has(tool.server_label)) { + return "Invalid execution MCP fields."; + } + mcpLabels.add(tool.server_label); + } else { + return "Execution currently supports non-deferred functions and the service-origin HTTP MCP profile only."; + } + } + if (functionCount > 64) return "This service supports at most 64 function tools."; + return null; +} + +function sessionVaultError(agent, vaultIds) { + if (!Array.isArray(vaultIds) || vaultIds.some((id) => typeof id !== "string" || !canonicalUuidPattern.test(id))) { + return "Fixture Session Vault attachments are invalid."; + } + const attached = new Set(vaultIds); + if (attached.size !== vaultIds.length || [...attached].some((id) => !state.vaults.some((vault) => vault.id === id))) { + return "Fixture Session Vault attachments are unavailable."; + } + for (const tool of agent.tools) { + if (tool.type !== "mcp") continue; + const matching = state.credentials.filter((credential) => ( + attached.has(credential.vault_id) && credential.auth.mcp_server_url === tool.transport.server_url + )); + if (typeof tool.credential_id === "string") { + const selected = matching.find((credential) => credential.id === tool.credential_id); + if (!selected || !state.credentialTokens.has(selected.id)) return "Fixture explicit MCP Credential is unavailable."; + } else if (matching.length > 1) { + return "Fixture anonymous MCP selection is ambiguous."; + } + } + return null; +} + +function sessionSnapshot(agent) { + const { object: _object, metadata: _metadata, created_at: _created, updated_at: _updated, ...snapshot } = agent; + return snapshot; +} + +function sessionEffectiveAgent(saved, override) { + if (override === undefined) return saved; + if (!isRecord(override) || !hasOnlyKeys(override, [ + "model", "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools", + ])) return null; + if (Object.hasOwn(override, "model") && typeof override.model !== "string") return null; + const effective = { ...saved }; + if (Object.hasOwn(override, "model")) effective.model = override.model; + if (Object.hasOwn(override, "instructions")) { + if (override.instructions !== null && typeof override.instructions !== "string") return null; + effective.instructions = override.instructions; + } + if (Object.hasOwn(override, "multi_agent")) { + if (override.multi_agent === null) effective.multi_agent = { enabled: false, max_concurrent_subagents: null }; + else if (isRecord(override.multi_agent) && typeof override.multi_agent.enabled === "boolean") { + effective.multi_agent = { + enabled: override.multi_agent.enabled, + max_concurrent_subagents: override.multi_agent.enabled + ? override.multi_agent.max_concurrent_subagents ?? 6 + : null, + }; + } else return null; + } + if (Object.hasOwn(override, "reasoning")) { + if (override.reasoning !== null && !isRecord(override.reasoning)) return null; + effective.reasoning = override.reasoning ?? {}; + } + if (Object.hasOwn(override, "service_tier")) { + if (override.service_tier !== null && typeof override.service_tier !== "string") return null; + effective.service_tier = override.service_tier ?? "auto"; + } + if (Object.hasOwn(override, "text")) { + if (override.text !== null && !isRecord(override.text)) return null; + effective.text = { + format: override.text?.format ?? { type: "text" }, + verbosity: override.text?.verbosity ?? "medium", + }; + } + if (Object.hasOwn(override, "tools")) { + if (override.tools !== null && !Array.isArray(override.tools)) return null; + effective.tools = override.tools ?? []; + } + return effective; +} + +function sessionInlineAgent(input, id) { + if ( + !isRecord(input) + || !hasOnlyKeys(input, ["model", "instructions", "tools"]) + || typeof input.model !== "string" + || /^\p{White_Space}*$/u.test(input.model) + || input.model !== input.model.trim() + || (Object.hasOwn(input, "instructions") + && (typeof input.instructions !== "string" + || /^\p{White_Space}*$/u.test(input.instructions) + || input.instructions !== input.instructions.trim())) + || (Object.hasOwn(input, "tools") && !Array.isArray(input.tools)) + ) return null; + return { + id, + model: input.model, + name: null, + instructions: input.instructions ?? null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: input.tools ?? [], + }; +} + +function sessionInitialInputMessages(input) { + if (typeof input === "string") { + return /^\p{White_Space}*$/u.test(input) + ? null + : [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }]; + } + if (!Array.isArray(input) || input.length === 0) return null; + + const messages = []; + for (const message of input) { + if ( + !isRecord(message) + || !hasOnlyKeys(message, ["type", "role", "content"]) + || (message.type !== undefined && message.type !== "message") + || message.role !== "user" + || !Array.isArray(message.content) + || message.content.length === 0 + ) return null; + const content = []; + for (const part of message.content) { + if ( + !isRecord(part) + || !hasOnlyKeys(part, ["type", "text"]) + || part.type !== "input_text" + || typeof part.text !== "string" + ) return null; + content.push({ type: "input_text", text: part.text }); + } + if (/^\p{White_Space}*$/u.test(content.map((part) => part.text).join(""))) return null; + messages.push({ type: "message", role: "user", content }); + } + return messages; +} + +function sessionEnvironmentResponse(environment) { + if (!isRecord(environment) || typeof environment.type !== "string") return null; + if (environment.type === "none") { + return hasOnlyKeys(environment, ["type"]) ? { type: "none" } : null; + } + if (environment.type === "openai_hosted") { + if (!hasOnlyKeys(environment, ["type", "network", "environment_template_id"])) return null; + let access = "enabled"; + let template = null; + if (environment.environment_template_id !== undefined) { + template = state.environmentTemplates.find((entry) => entry.id === environment.environment_template_id); + // A referenced Template must exist, and an explicit null network is rejected + // instead of guessing inheritance. + if (!template || environment.network === null) return null; + access = template.network.access; + } + if (environment.network !== undefined && environment.network !== null) { + if ( + !isRecord(environment.network) || + !hasOnlyKeys(environment.network, ["access"]) || + (environment.network.access !== "enabled" && environment.network.access !== "disabled") + ) return null; + // A Session keeps or narrows the Template policy and never widens it. + if (template && template.network.access === "disabled" && environment.network.access === "enabled") return null; + access = environment.network.access; + } + return { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access, allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + } + if ( + environment.type !== "self_hosted" + || !hasOnlyKeys(environment, ["type", "workspace_directory", "capability_directories"]) + ) return null; + + const workspace = environment.workspace_directory; + const capabilities = environment.capability_directories; + if ( + typeof workspace !== "string" + || !workspace + || workspace.startsWith("~") + || !workspace.startsWith("/") + || /[\0\r\n\\]/u.test(workspace) + || capabilities !== undefined && capabilities !== null + && (!Array.isArray(capabilities) || capabilities.length !== 0) + ) return null; + + return { + type: "self_hosted", + id: canonicalEnvironmentUuid, + remote_url: "https://executor.example.test", + workspace_directory: workspace, + capability_directories: [], + }; +} + +function initialState() { + const first = savedAgent("agent_a", "Lifecycle Agent", "fixture/model-a", baseline - 60); + const second = savedAgent("agent_b", "Second Agent", "fixture/model-b", baseline - 30); + const savedOnlyTool = savedAgent("agent_tool_only", "Saved-only Tool Agent", "fixture/model-tool", baseline - 20); + savedOnlyTool.tools = [{ type: "tool_search" }]; + return { + agents: [first, second, savedOnlyTool], + vaults: [], + credentials: [], + credentialTokens: new Set(), + sessions: [{ + id: "session_snapshot", + object: "agent.session", + agent: sessionSnapshot(first), + environment: { type: "none" }, + status: "idle", + error: null, + metadata: { fixture: "durable-agent-snapshot" }, + required_actions: [], + vault_ids: [], + usage: null, + created_at: baseline - 20, + last_active_at: baseline - 10, + }], + turns: [], + createdSessionItems: new Map(), + requests: [], + sourceFiles: new Map(), + hostedWorkspaceFiles: [], + environmentTemplates: [], + sessionCreateReceipts: new Map(), + controls: { + createAgentResponseVariant: "valid", + sessionCreateDelayMs: 0, + sessionCreateStatus: 201, + sessionCreateResponseLoss: 0, + sessionCreateStreamCloseDelayMs: 120, + sessionListDelayMs: 0, + sessionListStatus: 200, + sessionListPageSize: 100, + retrieveDelayMs: 0, + retrieveStatus: 200, + updateDelayMs: 0, + updateStatus: 200, + deleteDelayMs: 0, + deleteStatus: 200, + sendStatus: 204, + sendResponseLoss: 0, + itemsScenario: 0, + turnsScenario: 0, + turnsRetrieveDelayMs: 0, + turnsRetrieveStatus: 200, + turnsPageSize: 2, + environmentScenario: 0, + environmentRetrieveDelayMs: 0, + environmentRetrieveStatus: 200, + environmentFilesDelayMs: 0, + environmentFilesStatus: 200, + environmentFileCreateStatus: 200, + environmentFileCreateResponseLoss: 0, + environmentTemplateListStatus: 200, + environmentTemplateCreateStatus: 200, + environmentResourceStatus: "pending", + environmentResourceVariant: "valid", + environmentEventStatus: 0, + environmentEventCount: 0, + streamStatus: 200, + streamOpenDelayMs: 0, + streamCloseCount: 0, + streamCloseDelayMs: 30, + sessionRetrieveDelayMs: 0, + sessionRetrieveStatus: 200, + sessionRetrieveVariant: "valid", + sessionUpdateDelayMs: 0, + sessionUpdateStatus: 200, + sessionUpdateResponseLoss: 0, + sessionDeleteDelayMs: 0, + sessionDeleteStatus: 200, + sessionDeleteResponseLoss: 0, + sessionDeleteStreamCloseDelayMs: 0, + itemsRetrieveDelayMs: 0, + itemsRetrieveStatus: 200, + sourceUploadStatus: 200, + sourceUploadResponseLoss: 0, + sourceDeleteStatus: 200, + sourceDeleteResponseLoss: 0, + }, + aborts: { + sessionListReads: 0, + sessionReads: 0, + itemReads: 0, + turnReads: 0, + streams: 0, + environmentFileReads: 0, + }, + sequence: 0, + }; +} + +function applyTurnsScenario(value) { + const session = state.sessions[0]; + if (!session) return; + if (value === 1) { + state.turns = observableTurns(); + session.usage = { + input_tokens: 20, + output_tokens: 6, + total_tokens: 26, + input_tokens_details: { cached_tokens: 8 }, + output_tokens_details: { reasoning_tokens: 4 }, + }; + return; + } + state.turns = []; + session.usage = null; +} + +function applyEnvironmentScenario(value) { + const session = state.sessions[0]; + if (!session) return; + const hostileRemote = "https://launcher:private@executor.example.test/connect?executor_token=secret#credential"; + if (value === 10) { + session.environment = { + type: "self_hosted", + id: "environment_fixture", + remote_url: hostileRemote, + workspace_directory: `/workspace//${"long/".repeat(45)}project`, + capability_directories: ["/capabilities/read-only", `/capabilities/${"wide/".repeat(55)}`], + }; + session.status = "requires_action"; + session.required_actions = [ + { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, + ]; + return; + } + if (value === 9) { + session.environment = { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access: "enabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + session.status = "failed"; + session.error = "The environment is no longer available for this input."; + session.required_actions = []; + return; + } + if (value === 8) { + session.environment = { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access: "disabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + session.status = "idle"; + session.required_actions = []; + return; + } + if (value === 7) { + session.environment = { + type: "self_hosted", + id: canonicalEnvironmentUuid, + remote_url: "https://executor.example.test", + workspace_directory: "/executor/workspace", + capability_directories: [], + }; + session.status = "idle"; + session.required_actions = []; + return; + } + if (value === 1 || value === 4 || value === 5 || value === 6) { + session.environment = { + type: "self_hosted", + id: value === 5 ? canonicalEnvironmentUuid.toUpperCase() : "environment_fixture", + remote_url: hostileRemote, + workspace_directory: `/workspace//${"long/".repeat(45)}project`, + capability_directories: ["/capabilities/read-only", `/capabilities/${"wide/".repeat(55)}`], + }; + session.status = value === 1 || value === 6 ? "requires_action" : "idle"; + session.required_actions = value === 1 + ? [{ type: "environment_connection", environment_id: "environment_fixture" }] + : value === 6 + ? [{ type: "environment_connection", environment_id: "environment_fixture" }] + : []; + return; + } + if (value === 2) { + session.environment = { type: "future_remote", remote_url: "javascript:alert(1)", workspace_directory: "/must-not-render" }; + session.status = "idle"; + session.required_actions = []; + return; + } + if (value === 3) { + session.environment = { type: "self_hosted" }; + session.status = "idle"; + session.required_actions = []; + } +} + +let state = initialState(); +const streamResponses = new Map(); + +function emitTurnLifecycle(status) { + const index = state.turns.findIndex((turn) => turn.id === "turn_terminal_refresh"); + const existing = state.turns[index]; + if (!existing || !["completed", "failed", "cancelled"].includes(status)) return false; + const terminal = { + ...existing, + status, + completed_at: baseline + 10, + error: status === "failed" ? { code: "internal_error", message: "The execution could not complete." } : null, + usage: status === "completed" ? { input_tokens: 5, output_tokens: 2, total_tokens: 7, input_tokens_details: { cached_tokens: 1 }, output_tokens_details: { reasoning_tokens: 1 } } : null, + }; + state.turns[index] = terminal; + state.sequence += 1; + const event = `id: turn_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.turn.${status}`, + event_id: `turn_${state.sequence}`, + session_id: "session_snapshot", + turn_id: terminal.id, + turn: terminal, + })}\n\n`; + for (const stream of streamResponses.keys()) stream.write(event); + return true; +} + +function emitSessionLifecycle(status) { + if (!["in_progress", "idle", "failed"].includes(status)) return false; + const session = state.sessions.find((candidate) => candidate.id === "session_snapshot"); + if (!session) return false; + session.status = status; + session.error = status === "failed" ? "The execution could not complete." : null; + session.required_actions = []; + state.sequence += 1; + const event = `id: session_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.${status}`, + event_id: `session_${state.sequence}`, + session_id: session.id, + session, + })}\n\n`; + for (const [stream, sessionId] of streamResponses) { + if (sessionId === session.id) stream.write(event); + } + return true; +} + +function sendJson(response, value, status = 200) { + const body = JSON.stringify(value); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + "cache-control": "no-store", + }); + response.end(body); +} + +function sendError(response, status, message, code = "fixture_failure", type = "fixture_error") { + sendJson(response, { + error: { + code, + type, + message, + }, + }, status); +} + +async function readJson(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (!chunks.length) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function readBuffer(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function readSourceMultipart(request) { + const contentType = request.headers["content-type"] ?? ""; + const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType); + if (!match) return null; + const boundary = match[1] ?? match[2]; + const raw = (await readBuffer(request)).toString("latin1"); + const parts = raw.split(`--${boundary}`).slice(1, -1); + const result = { file: null, filename: null, purpose: null }; + for (const rawPart of parts) { + const part = rawPart.replace(/^\r\n/, "").replace(/\r\n$/, ""); + const separator = part.indexOf("\r\n\r\n"); + if (separator < 0) return null; + const header = part.slice(0, separator); + const body = part.slice(separator + 4); + const name = /\bname="([^"]+)"/i.exec(header)?.[1]; + if (name === "file") { + if (result.file !== null) return null; + result.filename = /\bfilename="([^"]*)"/i.exec(header)?.[1] ?? null; + result.file = Buffer.from(body, "latin1"); + } else if (name === "purpose") { + if (result.purpose !== null) return null; + result.purpose = body; + } else { + return null; + } + } + return result.file !== null && result.filename && result.purpose === "user_data" ? result : null; +} + +function page(data) { + return { + object: "list", + data, + has_more: false, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }; +} + +function queryPage(data, url, maximumPageSize = 100) { + const ordered = url.searchParams.get("order") === "asc" ? [...data].reverse() : [...data]; + const after = url.searchParams.get("after"); + const start = after ? ordered.findIndex((value) => value.id === after) + 1 : 0; + if (after && start === 0) return null; + const requestedLimit = Number(url.searchParams.get("limit") ?? 20); + const size = Math.max(1, Math.min(requestedLimit, maximumPageSize)); + const values = ordered.slice(start, start + size); + return { + object: "list", + data: values, + has_more: start + values.length < ordered.length, + first_id: values[0]?.id ?? null, + last_id: values.at(-1)?.id ?? null, + }; +} + +function recordRequest(request, url, body) { + let safeBody = body; + if ( + request.method === "POST" && + /^\/v1\/vaults\/[^/]+\/credentials(?:\/[^/]+)?$/u.test(url.pathname) && + isRecord(body) && isRecord(body.auth) && Object.hasOwn(body.auth, "token") + ) { + const { token: _token, ...safeAuth } = body.auth; + safeBody = { ...body, auth: { ...safeAuth, token_present: true } }; + } + state.requests.push({ + method: request.method, + path: url.pathname, + query: url.search, + beta: request.headers["openai-beta"] ?? null, + authorizationPresent: Boolean(request.headers.authorization), + idempotencyKeyPresent: Boolean(request.headers["idempotency-key"]), + idempotencyKey: request.headers["idempotency-key"] ?? null, + body: safeBody, + }); +} + +function fixtureUuid(sequence) { + return `10000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; +} + +function consumeControl(prefix, successStatus = 200) { + const delayMs = state.controls[`${prefix}DelayMs`]; + const status = state.controls[`${prefix}Status`]; + state.controls[`${prefix}DelayMs`] = 0; + state.controls[`${prefix}Status`] = successStatus; + return { delayMs, status }; +} + +function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function trackAbort(response, key) { + let finished = false; + response.once("finish", () => { + finished = true; + }); + response.once("close", () => { + if (!finished) state.aborts[key] += 1; + }); +} + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + + if (request.method === "GET" && url.pathname === "/__fixture/health") { + return sendJson(response, { ready: true }); + } + if (request.method === "POST" && url.pathname === "/__fixture/reset") { + for (const stream of streamResponses.keys()) stream.end(); + streamResponses.clear(); + state = initialState(); + return sendJson(response, { reset: true }); + } + if (request.method === "POST" && url.pathname === "/__fixture/control") { + state.controls = { ...state.controls, ...await readJson(request) }; + applyEnvironmentScenario(state.controls.environmentScenario); + applyTurnsScenario(state.controls.turnsScenario); + return sendJson(response, state.controls); + } + if (request.method === "POST" && url.pathname === "/__fixture/emit-turn") { + const input = await readJson(request); + return emitTurnLifecycle(input.status) + ? sendJson(response, { emitted: true }) + : sendError(response, 400, "Fixture terminal Turn is unavailable."); + } + if (request.method === "POST" && url.pathname === "/__fixture/emit-session") { + const input = await readJson(request); + return emitSessionLifecycle(input.status) + ? sendJson(response, { emitted: true }) + : sendError(response, 400, "Fixture Session lifecycle status is unavailable."); + } + if (request.method === "GET" && url.pathname === "/__fixture/requests") { + return sendJson(response, state.requests); + } + if (request.method === "GET" && url.pathname === "/__fixture/state") { + return sendJson(response, { + sessions: state.sessions, + aborts: state.aborts, + openStreams: [...streamResponses.values()], + }); + } + if (request.method === "POST" && url.pathname === "/__fixture/session-metadata") { + const input = await readJson(request); + const target = state.sessions.find((session) => session.id === input.id); + if (!target) return sendError(response, 404, "Fixture Session not found."); + target.metadata = input.metadata; + return sendJson(response, target); + } + if (request.method === "POST" && url.pathname === "/__fixture/remove-session") { + const input = await readJson(request); + const before = state.sessions.length; + state.sessions = state.sessions.filter((session) => session.id !== input.id); + return sendJson(response, { removed: state.sessions.length !== before }); + } + + if (request.method === "POST" && url.pathname === "/v1/files") { + const upload = await readSourceMultipart(request); + recordRequest(request, url, upload ? { + filename: upload.filename, + bytes: upload.file.length, + purpose: upload.purpose, + } : { multipart: "invalid" }); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + if (state.controls.sourceUploadStatus !== 200) { + const status = state.controls.sourceUploadStatus; + state.controls.sourceUploadStatus = 200; + return sendError(response, status, "Fixture Source upload failed."); + } + if (!upload) return sendError(response, 400, "Fixture Source multipart is invalid."); + const id = `file-${sourceFileUuid}`; + const metadata = { + id, + object: "file", + bytes: upload.file.length, + created_at: baseline, + filename: upload.filename, + purpose: "user_data", + status: "processed", + expires_at: null, + status_details: null, + }; + state.sourceFiles.set(id, { metadata, data: upload.file }); + const lose = state.controls.sourceUploadResponseLoss; + state.controls.sourceUploadResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, metadata); + } + + const sourceContentMatch = url.pathname.match(/^\/v1\/files\/([^/]+)\/content$/); + if (request.method === "GET" && sourceContentMatch) { + const id = decodeURIComponent(sourceContentMatch[1]); + recordRequest(request, url, undefined); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + const source = state.sourceFiles.get(id); + if (!source) return sendError(response, 404, "Fixture Source File not found."); + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-disposition": `attachment; filename="${source.metadata.filename}"`, + "content-length": source.data.length, + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + response.end(source.data); + return; + } + + const sourceFileMatch = url.pathname.match(/^\/v1\/files\/([^/]+)$/); + if (sourceFileMatch && (request.method === "GET" || request.method === "DELETE")) { + const id = decodeURIComponent(sourceFileMatch[1]); + recordRequest(request, url, undefined); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + const source = state.sourceFiles.get(id); + if (!source) return sendError(response, 404, "Fixture Source File not found."); + if (request.method === "GET") return sendJson(response, source.metadata); + if (state.controls.sourceDeleteStatus !== 200) { + const status = state.controls.sourceDeleteStatus; + state.controls.sourceDeleteStatus = 200; + return sendError(response, status, "Fixture Source delete failed."); + } + state.sourceFiles.delete(id); + const lose = state.controls.sourceDeleteResponseLoss; + state.controls.sourceDeleteResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, { id, object: "file", deleted: true }); + } + + const body = request.method === "GET" || request.method === "DELETE" ? undefined : await readJson(request); + recordRequest(request, url, body); + + if (url.pathname === "/v1/vaults") { + if (request.method === "GET") return sendJson(response, page(state.vaults)); + if (request.method === "POST") { + if (!isRecord(body) || typeof body.name !== "string" || !body.name.trim() || !isRecord(body.metadata ?? {})) { + return sendError(response, 400, "Fixture Vault fields are invalid."); + } + state.sequence += 1; + const vault = { + id: fixtureUuid(state.sequence), + object: "vault", + created_at: baseline + state.sequence, + name: body.name, + metadata: body.metadata ?? {}, + }; + state.vaults.unshift(vault); + return sendJson(response, vault); + } + } + + const credentialsMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)\/credentials$/u); + if (credentialsMatch) { + const vaultId = decodeURIComponent(credentialsMatch[1]); + if (!state.vaults.some((vault) => vault.id === vaultId)) return sendError(response, 404, "Fixture Vault not found."); + if (request.method === "GET") { + return sendJson(response, page(state.credentials.filter((credential) => credential.vault_id === vaultId))); + } + if (request.method === "POST") { + if ( + !isRecord(body) || typeof body.name !== "string" || !body.name.trim() || + !isRecord(body.auth) || body.auth.type !== "static_bearer" || + typeof body.auth.mcp_server_url !== "string" || !body.auth.mcp_server_url.startsWith("https://") || + typeof body.auth.token !== "string" + ) return sendError(response, 400, "Fixture Credential fields are invalid."); + state.sequence += 1; + const credential = { + id: fixtureUuid(state.sequence), + vault_id: vaultId, + name: body.name, + object: "vault.credential", + auth: { type: "static_bearer", mcp_server_url: body.auth.mcp_server_url }, + created_at: baseline + state.sequence, + updated_at: baseline + state.sequence, + }; + state.credentials.unshift(credential); + state.credentialTokens.add(credential.id); + return sendJson(response, credential); + } + } + + const credentialMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)\/credentials\/([^/]+)$/u); + if (credentialMatch) { + const vaultId = decodeURIComponent(credentialMatch[1]); + const credentialId = decodeURIComponent(credentialMatch[2]); + const credential = state.credentials.find((candidate) => candidate.vault_id === vaultId && candidate.id === credentialId); + if (!credential) return sendError(response, 404, "Fixture Credential not found."); + if (request.method === "GET") return sendJson(response, credential); + if (request.method === "POST") { + if (!isRecord(body) || !isRecord(body.auth) || body.auth.type !== "static_bearer" || typeof body.auth.token !== "string") { + return sendError(response, 400, "Fixture Credential replacement is invalid."); + } + credential.updated_at += 1; + state.credentialTokens.add(credential.id); + return sendJson(response, credential); + } + if (request.method === "DELETE") { + state.credentials = state.credentials.filter((candidate) => candidate.id !== credentialId); + state.credentialTokens.delete(credentialId); + return sendJson(response, { id: credentialId, object: "vault.credential.deleted", deleted: true }); + } + } + + const vaultMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)$/u); + if (vaultMatch) { + const vaultId = decodeURIComponent(vaultMatch[1]); + const vault = state.vaults.find((candidate) => candidate.id === vaultId); + if (!vault) return sendError(response, 404, "Fixture Vault not found."); + if (request.method === "GET") return sendJson(response, vault); + if (request.method === "DELETE") { + const deletedCredentialIds = state.credentials.filter((credential) => credential.vault_id === vaultId).map((credential) => credential.id); + state.vaults = state.vaults.filter((candidate) => candidate.id !== vaultId); + state.credentials = state.credentials.filter((credential) => credential.vault_id !== vaultId); + for (const credentialId of deletedCredentialIds) state.credentialTokens.delete(credentialId); + return sendJson(response, { id: vaultId, object: "vault.deleted", deleted: true }); + } + } + + if (request.method === "GET" && url.pathname === "/v1/agents") { + const listed = state.agents.map((agent, index) => index === 0 + ? { ...agent, name: `${agent.name} · stale list`, updated_at: agent.updated_at - 10 } + : agent); + return sendJson(response, page(listed)); + } + + if (request.method === "POST" && url.pathname === "/v1/agents") { + state.sequence += 1; + const defaults = savedAgent(`agent_created_${state.sequence}`, body.name ?? null, body.model, baseline + state.sequence); + const created = { + ...defaults, + instructions: body.instructions ?? null, + metadata: body.metadata ?? {}, + multi_agent: body.multi_agent ?? { enabled: false, max_concurrent_subagents: null }, + reasoning: body.reasoning ?? {}, + service_tier: body.service_tier ?? "auto", + text: { + format: body.text?.format ?? { type: "text" }, + verbosity: body.text?.verbosity ?? "medium", + }, + tools: body.tools ?? [], + }; + if (state.controls.createAgentResponseVariant === "reasoning") { + created.reasoning = { effort: "high" }; + state.controls.createAgentResponseVariant = "valid"; + } + state.agents.unshift(created); + return sendJson(response, created, 201); + } + + if (request.method === "GET" && url.pathname === "/v1/agents/sessions") { + trackAbort(response, "sessionListReads"); + const control = consumeControl("sessionList"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture Session list failed."); + const agentId = url.searchParams.get("agent_id"); + const filtered = agentId === null + ? state.sessions + : state.sessions.filter((session) => session.agent.id === agentId); + const listed = queryPage(filtered, url, state.controls.sessionListPageSize); + return listed + ? sendJson(response, listed) + : sendError(response, 400, "Fixture Session cursor is outside the selected Agent filter."); + } + + if (request.method === "POST" && url.pathname === "/v1/agents/sessions") { + const idempotencyKey = request.headers["idempotency-key"]; + const { stream: _streamResponseMode, ...creationIntent } = body; + const fingerprint = JSON.stringify(creationIntent); + const receipt = typeof idempotencyKey === "string" + ? state.sessionCreateReceipts.get(idempotencyKey) + : undefined; + if (receipt) { + if (receipt.fingerprint !== fingerprint) { + return sendError(response, 409, "Fixture idempotency key was reused with a different Session request."); + } + if (body.stream === true) { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + response.write(": fixture creation retry observes only future events\n\n"); + setTimeout(() => response.end(), state.controls.sessionCreateStreamCloseDelayMs); + return; + } + return sendJson(response, receipt.session, 200); + } + + const control = consumeControl("sessionCreate", 201); + const responseLoss = state.controls.sessionCreateResponseLoss; + state.controls.sessionCreateResponseLoss = 0; + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 201) return sendError(response, control.status, "Fixture Session create failed."); + const savedAgent = typeof body.agent_id === "string" + ? state.agents.find((candidate) => candidate.id === body.agent_id) + : undefined; + const agent = body.agent_id === undefined + ? sessionInlineAgent(body.agent, `inline_agent_${state.sequence + 1}`) + : savedAgent + ? sessionEffectiveAgent(savedAgent, body.agent) + : null; + if (body.agent_id !== undefined && !savedAgent) { + return sendError(response, 404, "Fixture Agent not found for Session."); + } + if (!agent) return sendError(response, 400, "Fixture Session Agent override is invalid."); + const admissionError = sessionAdmissionError(agent); + if (admissionError) return sendError(response, 400, admissionError); + const vaultError = sessionVaultError(agent, body.vault_ids ?? []); + if (vaultError) return sendError(response, 400, vaultError); + const environment = sessionEnvironmentResponse(body.environment); + if (!environment) return sendError(response, 400, "Fixture Session environment is unsupported."); + if (environment.type === "openai_hosted" && agent.tools.some((tool) => tool.type === "mcp")) { + return sendError(response, 400, "Fixture managed hosted MCP combination is not qualified."); + } + if ( + body.metadata !== undefined && + (!isRecord(body.metadata) || Object.entries(body.metadata).some(([key, value]) => ( + typeof value !== "string" || [...key].length > 64 || [...value].length > 512 + )) || Object.keys(body.metadata).length > 16) + ) return sendError(response, 400, "Fixture Session metadata is invalid."); + const hasInitialInput = body.input !== undefined && body.input !== null; + const initialInputMessages = hasInitialInput ? sessionInitialInputMessages(body.input) : []; + if (hasInitialInput && !initialInputMessages) { + return sendError(response, 400, "Fixture initial Session input is invalid."); + } + state.sequence += 1; + const created = { + id: `session_created_${state.sequence}`, + object: "agent.session", + agent: sessionSnapshot(agent), + environment, + status: "idle", + error: null, + metadata: body.metadata ?? {}, + required_actions: [], + vault_ids: body.vault_ids ?? [], + usage: null, + created_at: baseline + state.sequence, + last_active_at: baseline + state.sequence, + }; + state.sessions.unshift(created); + if (typeof idempotencyKey === "string") { + state.sessionCreateReceipts.set(idempotencyKey, { fingerprint, session: created }); + } + if (responseLoss) { + response.destroy(); + return; + } + if (body.stream === true) { + const createdSnapshot = structuredClone(created); + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + response.write(": connected\n\n"); + state.sequence += 1; + response.write(`event: agent.session.created\nid: create_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.created", + event_id: `create_${state.sequence}`, + session: createdSnapshot, + })}\n\n`); + if (hasInitialInput && initialInputMessages) { + state.sequence += 1; + const turn = { + id: `turn_created_${state.sequence}`, + agent_id: created.agent.id, + session_id: created.id, + object: "agent.session.turn", + status: "queued", + created_at: baseline + state.sequence, + started_at: null, + completed_at: null, + error: null, + usage: null, + }; + const items = initialInputMessages.map((message, index) => ({ + id: `item_created_${state.sequence}_${index + 1}`, + turn_id: turn.id, + type: "message", + status: "completed", + role: "user", + content: message.content, + })); + state.turns.push(turn); + state.createdSessionItems.set(created.id, items); + response.write(`event: agent.session.turn.created\nid: turn_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.turn.created", + event_id: `turn_${state.sequence}`, + session_id: created.id, + turn_id: turn.id, + turn, + })}\n\n`); + created.status = "in_progress"; + created.last_active_at = baseline + state.sequence; + response.write(`event: agent.session.in_progress\nid: progress_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.in_progress", + event_id: `progress_${state.sequence}`, + session_id: created.id, + session: created, + })}\n\n`); + for (const [index, item] of items.entries()) { + response.write(`event: agent.session.turn.item.added\nid: item_${state.sequence}_${index + 1}\ndata: ${JSON.stringify({ + type: "agent.session.turn.item.added", + event_id: `item_${state.sequence}_${index + 1}`, + session_id: created.id, + turn_id: turn.id, + item, + })}\n\n`); + } + } + setTimeout(() => response.end(), state.controls.sessionCreateStreamCloseDelayMs); + return; + } + return sendJson(response, created, 201); + } + + const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/); + if (agentMatch) { + const id = decodeURIComponent(agentMatch[1]); + const agent = state.agents.find((candidate) => candidate.id === id); + if (!agent) return sendError(response, 404, "Fixture Agent not found."); + + if (request.method === "GET") { + const control = consumeControl("retrieve"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture retrieve failed."); + return sendJson(response, agent); + } + + if (request.method === "POST") { + const control = consumeControl("update"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture update failed."); + const updated = { ...agent, ...body, updated_at: agent.updated_at + 100 }; + state.agents = state.agents.map((candidate) => candidate.id === id ? updated : candidate); + return sendJson(response, updated); + } + + if (request.method === "DELETE") { + const control = consumeControl("delete"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture delete failed."); + state.agents = state.agents.filter((candidate) => candidate.id !== id); + return sendJson(response, { id, object: "agent.deleted", deleted: true }); + } + } + + const sessionMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)$/); + if (sessionMatch) { + const id = decodeURIComponent(sessionMatch[1]); + const session = state.sessions.find((candidate) => candidate.id === id); + if (!session) return sendError(response, 404, "Fixture Session not found."); + + if (request.method === "GET") { + trackAbort(response, "sessionReads"); + const delayMs = state.controls.sessionRetrieveDelayMs; + const status = state.controls.sessionRetrieveStatus; + const variant = state.controls.sessionRetrieveVariant; + state.controls.sessionRetrieveDelayMs = 0; + state.controls.sessionRetrieveStatus = 200; + state.controls.sessionRetrieveVariant = "valid"; + const retrievedSession = variant === "wrong_id" + ? { ...session, id: "another_session" } + : variant === "malformed" + ? { id, object: "agent.session", metadata: session.metadata } + : variant === "deep_malformed" + ? { ...session, agent: { model: session.agent.model } } + : session; + if (delayMs && status === 200) { + const payload = JSON.stringify(retrievedSession); + response.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload) + 1, + "cache-control": "no-store", + }); + response.write(" "); + await wait(delayMs); + if (response.destroyed) return; + response.end(payload); + return; + } + if (delayMs) await wait(delayMs); + if (response.destroyed) return; + if (status !== 200) return sendError(response, status, "Fixture Session retrieve failed."); + return sendJson(response, retrievedSession); + } + + if (request.method === "POST") { + const delayMs = state.controls.sessionUpdateDelayMs; + const status = state.controls.sessionUpdateStatus; + const responseLoss = state.controls.sessionUpdateResponseLoss; + state.controls.sessionUpdateDelayMs = 0; + state.controls.sessionUpdateStatus = 200; + state.controls.sessionUpdateResponseLoss = 0; + if (delayMs) await wait(delayMs); + if (status !== 200) return sendError(response, status, "Fixture Session update failed."); + session.metadata = body.metadata ?? session.metadata; + if (responseLoss) { + response.destroy(); + return; + } + return sendJson(response, session); + } + + if (request.method === "DELETE") { + const delayMs = state.controls.sessionDeleteDelayMs; + const status = state.controls.sessionDeleteStatus; + const responseLoss = state.controls.sessionDeleteResponseLoss; + state.controls.sessionDeleteDelayMs = 0; + state.controls.sessionDeleteStatus = 200; + state.controls.sessionDeleteResponseLoss = 0; + if (delayMs) await wait(delayMs); + if (status !== 200) return sendError(response, status, "Fixture Session delete failed."); + if (responseLoss === 2) { + response.destroy(); + return; + } + state.sessions = state.sessions.filter((candidate) => candidate.id !== id); + state.turns = state.turns.filter((turn) => turn.session_id !== id); + const targetStreams = [...streamResponses] + .filter(([, streamSessionId]) => streamSessionId === id) + .map(([stream]) => stream); + const closeStreams = () => { + for (const stream of targetStreams) { + if (!stream.destroyed) stream.end(); + } + }; + if (state.controls.sessionDeleteStreamCloseDelayMs) { + setTimeout(closeStreams, state.controls.sessionDeleteStreamCloseDelayMs); + } else { + closeStreams(); + } + if (responseLoss) { + response.destroy(); + return; + } + return sendJson(response, { id, object: "agent.session.deleted", deleted: true }); + } + } + + // Declared before the single-Environment route so that the reusable + // configuration collection is never read as an Environment identifier. + if (url.pathname === "/v1/agents/environments/templates") { + if (request.headers["openai-beta"] !== "agents=v1") { + return sendError(response, 400, "Fixture Environment Templates require the Agents beta header."); + } + if (request.method === "GET") { + const status = state.controls.environmentTemplateListStatus; + if (status !== 200) { + return sendError( + response, + status, + status === 400 ? "Environment retrieval does not accept query parameters." : "Fixture Environment Template list failed.", + status === 400 ? "unsupported_parameter" : "fixture_failure", + status === 400 ? "invalid_request_error" : "fixture_error", + ); + } + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + if (limit < 1 || limit > 100 || !["asc", "desc"].includes(order)) { + return sendError(response, 400, "Fixture Environment Template query is invalid."); + } + const templates = [...state.environmentTemplates] + .sort((left, right) => left.created_at - right.created_at); + if (order === "desc") templates.reverse(); + const page = templates.slice(0, limit); + return sendJson(response, { + object: "list", + data: page, + has_more: page.length < templates.length, + first_id: page[0]?.id ?? null, + last_id: page.at(-1)?.id ?? null, + }); + } + if (request.method === "POST") { + const status = state.controls.environmentTemplateCreateStatus; + if (status !== 200) { + state.controls.environmentTemplateCreateStatus = 200; + return sendError(response, status, "Fixture Environment Template create failed."); + } + if (!isRecord(body) || !hasOnlyKeys(body, ["name", "network"])) { + return sendError(response, 400, "Fixture Environment Template body is invalid."); + } + let access = "enabled"; + if (body.network !== undefined && body.network !== null) { + if ( + !isRecord(body.network) || !hasOnlyKeys(body.network, ["access"]) || + (body.network.access !== "enabled" && body.network.access !== "disabled") + ) return sendError(response, 400, "Fixture Environment Template network is unsupported."); + access = body.network.access; + } + if (body.name !== undefined && body.name !== null && typeof body.name !== "string") { + return sendError(response, 400, "Fixture Environment Template name is invalid."); + } + const index = state.environmentTemplates.length + 1; + const created = Math.floor(Date.now() / 1000); + const template = { + id: `4${String(index).padStart(7, "0")}-1111-4111-8111-111111111111`, + object: "agent.environment.template", + name: body.name === undefined ? null : body.name, + network: { access, allowed_domains: [] }, + capability_directories: [], + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + created_at: created, + updated_at: created, + }; + state.environmentTemplates.push(template); + return sendJson(response, template); + } + return sendError(response, 405, "This API method is not supported.", "unsupported_operation"); + } + + const environmentFilesMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)\/files$/); + if (request.method === "POST" && environmentFilesMatch) { + const id = decodeURIComponent(environmentFilesMatch[1]); + if (id !== hostedEnvironmentUuid) return sendError(response, 503, "Fixture Environment is not a writable hosted placement."); + if (request.headers["openai-beta"] !== "agents=v1") return sendError(response, 400, "Fixture Environment Files requires the Agents beta header."); + if (state.controls.environmentFileCreateStatus !== 200) { + const status = state.controls.environmentFileCreateStatus; + state.controls.environmentFileCreateStatus = 200; + return sendError(response, status, "Fixture Environment copy failed."); + } + if (!isRecord(body) || typeof body.path !== "string" || !body.path.startsWith("/workspace/")) { + return sendError(response, 400, "Fixture Environment copy body is invalid."); + } + let data; + if (body.type === "file_id" && Object.keys(body).sort().join(",") === "file_id,path,type" && typeof body.file_id === "string") { + const source = state.sourceFiles.get(body.file_id); + if (!source) return sendError(response, 404, "Fixture Source File not found for copy."); + data = source.data; + } else if (body.type === "inline" && Object.keys(body).sort().join(",") === "data,path,type" && typeof body.data === "string") { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(body.data)) { + return sendError(response, 400, "Fixture inline Environment data is invalid."); + } + data = Buffer.from(body.data, "base64"); + if (data.toString("base64") !== body.data) return sendError(response, 400, "Fixture inline Environment data is invalid."); + } else { + return sendError(response, 400, "Fixture Environment copy body is invalid."); + } + if (data.length > 50 * 1024 * 1024) return sendError(response, 413, "Fixture Environment copy is too large."); + const result = { + environment_id: hostedEnvironmentUuid, + object: "agent.environment.file", + path: body.path, + size_bytes: data.length, + }; + state.hostedWorkspaceFiles = [ + ...state.hostedWorkspaceFiles.filter((file) => file.path !== body.path), + result, + ]; + const lose = state.controls.environmentFileCreateResponseLoss; + state.controls.environmentFileCreateResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, result); + } + if (request.method === "GET" && environmentFilesMatch) { + trackAbort(response, "environmentFileReads"); + const control = consumeControl("environmentFiles"); + if (control.delayMs) await wait(control.delayMs); + if (response.destroyed) return; + if (control.status !== 200) { + return sendError( + response, + control.status, + control.status === 404 ? "This API operation is not supported." : "Fixture Environment files read failed.", + control.status === 404 ? "unsupported_operation" : "fixture_failure", + control.status === 404 ? "invalid_request_error" : "fixture_error", + ); + } + + const id = decodeURIComponent(environmentFilesMatch[1]); + if (id === hostedEnvironmentUuid) { + const directory = url.searchParams.get("path") ?? "/workspace"; + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + if (url.searchParams.get("page") !== null || limit < 1 || limit > 100 || !["asc", "desc"].includes(order)) { + return sendError(response, 400, "Fixture hosted Environment files query is invalid."); + } + const files = state.hostedWorkspaceFiles + .filter((file) => file.path.slice(0, file.path.lastIndexOf("/")) === directory) + .sort((left, right) => left.path.localeCompare(right.path)); + if (order === "desc") files.reverse(); + return sendJson(response, { data: files.slice(0, limit), next: null }); + } + const sessionEnvironment = state.sessions[0]?.environment; + const expectedId = sessionEnvironment?.type === "self_hosted" ? sessionEnvironment.id : null; + if (id !== expectedId) return sendError(response, 404, "Fixture Environment not found."); + const directory = url.searchParams.get("path") ?? sessionEnvironment.workspace_directory; + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + const cursor = url.searchParams.get("page"); + if ( + directory !== sessionEnvironment.workspace_directory || + limit !== 20 || + !["asc", "desc"].includes(order) || + (cursor !== null && cursor !== "fixture-page-2") + ) return sendError(response, 400, "Fixture Environment files query is invalid."); + + const allFiles = Array.from({ length: 21 }, (_, index) => ({ + environment_id: canonicalEnvironmentUuid, + object: "agent.environment.file", + path: `${sessionEnvironment.workspace_directory}/file-${String(index + 1).padStart(2, "0")}.txt`, + size_bytes: (index + 1) * 128, + })); + if (order === "desc") allFiles.reverse(); + const start = cursor === "fixture-page-2" ? 20 : 0; + return sendJson(response, { + data: allFiles.slice(start, start + limit), + next: start + limit < allFiles.length ? "fixture-page-2" : null, + }); + } + + const environmentMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)$/); + if (request.method === "GET" && environmentMatch) { + if (state.controls.environmentRetrieveDelayMs) await wait(state.controls.environmentRetrieveDelayMs); + if (state.controls.environmentRetrieveStatus !== 200) { + return sendError(response, state.controls.environmentRetrieveStatus, "Fixture Environment retrieve failed."); + } + const id = decodeURIComponent(environmentMatch[1]); + if (id === hostedEnvironmentUuid) { + const resource = { + id, + object: "agent.environment", + type: "openai_hosted", + status: state.controls.environmentResourceStatus, + files: [], + plugins: [], + skills: [], + }; + if (state.controls.environmentResourceVariant === "missing_skills") delete resource.skills; + if (state.controls.environmentResourceVariant === "wrong_id") resource.id = canonicalEnvironmentUuid; + if (state.controls.environmentResourceVariant === "extra_field") resource.extra = true; + if (state.controls.environmentResourceVariant === "wrong_type") resource.type = "self_hosted"; + if (state.controls.environmentResourceVariant === "populated") resource.files = [{ id: "unsupported-install" }]; + return sendJson(response, resource); + } + const sessionEnvironment = state.sessions[0]?.environment; + const expectedId = sessionEnvironment?.type === "self_hosted" ? sessionEnvironment.id : null; + if (id !== expectedId) return sendError(response, 404, "Fixture Environment not found."); + const canonicalId = id.toLowerCase(); + const resource = { + id: canonicalUuidPattern.test(canonicalId) ? canonicalId : id, + object: "agent.environment", + type: "self_hosted", + status: state.controls.environmentResourceStatus, + files: [], + plugins: [], + skills: [], + }; + if (state.controls.environmentResourceVariant === "missing_skills") delete resource.skills; + if (state.controls.environmentResourceVariant === "wrong_id") resource.id = "another_environment"; + if (state.controls.environmentResourceVariant === "extra_field") resource.extra = true; + if (state.controls.environmentResourceVariant === "wrong_type") resource.type = "openai_hosted"; + return sendJson(response, resource); + } + + const itemsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/items$/); + if (request.method === "GET" && itemsMatch) { + trackAbort(response, "itemReads"); + if (state.controls.itemsRetrieveDelayMs) await wait(state.controls.itemsRetrieveDelayMs); + if (response.destroyed) return; + if (state.controls.itemsRetrieveStatus !== 200) { + return sendError(response, state.controls.itemsRetrieveStatus, "Fixture Items retrieve failed."); + } + const sessionId = decodeURIComponent(itemsMatch[1]); + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for Items."); + } + const items = sessionId !== "session_snapshot" + ? state.createdSessionItems.get(sessionId) ?? [] + : state.controls.itemsScenario === 2 + ? [...observableTurnItems(), ...patchItems()] + : state.controls.itemsScenario + ? patchItems() + : state.controls.turnsScenario + ? observableTurnItems() + : []; + return sendJson(response, page(items)); + } + + const turnsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/turns$/); + if (request.method === "GET" && turnsMatch) { + trackAbort(response, "turnReads"); + if (state.controls.turnsRetrieveDelayMs) await wait(state.controls.turnsRetrieveDelayMs); + if (response.destroyed) return; + if (state.controls.turnsRetrieveStatus !== 200) { + return sendError(response, state.controls.turnsRetrieveStatus, "Fixture Turns retrieve failed."); + } + const sessionId = decodeURIComponent(turnsMatch[1]); + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for Turns."); + } + const sessionTurns = state.turns.filter((turn) => turn.session_id === sessionId); + const after = url.searchParams.get("after"); + const start = after ? sessionTurns.findIndex((turn) => turn.id === after) + 1 : 0; + if (after && start === 0) return sendError(response, 400, "Fixture Turn cursor not found."); + const requestedLimit = Number(url.searchParams.get("limit") ?? 20); + const size = Math.max(1, Math.min(requestedLimit, state.controls.turnsPageSize)); + const data = sessionTurns.slice(start, start + size); + return sendJson(response, { + object: "list", + data, + has_more: start + data.length < sessionTurns.length, + }); + } + + const eventsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/events$/); + if (request.method === "POST" && eventsMatch) { + const status = state.controls.sendStatus; + const responseLoss = state.controls.sendResponseLoss; + state.controls.sendStatus = 204; + state.controls.sendResponseLoss = 0; + if (responseLoss) { + response.destroy(); + return; + } + if (status !== 204) return sendError(response, status, "Fixture send failed."); + response.writeHead(204); + response.end(); + return; + } + if (request.method === "GET" && eventsMatch) { + trackAbort(response, "streams"); + const sessionId = decodeURIComponent(eventsMatch[1]); + if (state.controls.streamOpenDelayMs) await wait(state.controls.streamOpenDelayMs); + if (response.destroyed) return; + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for stream."); + } + if (state.controls.streamStatus !== 200) { + return sendError(response, state.controls.streamStatus, "Fixture stream rejected."); + } + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + streamResponses.set(response, sessionId); + response.write(": fixture stream open\n\n"); + const statuses = [null, "pending", "ready", "connected", "disconnected", "failed", "expired"]; + const environmentStatus = statuses[state.controls.environmentEventStatus] ?? null; + if (environmentStatus && state.controls.environmentEventCount > 0) { + state.controls.environmentEventCount -= 1; + state.sequence += 1; + const streamSession = state.sessions.find((candidate) => candidate.id === sessionId); + const sessionEnvironment = streamSession?.environment; + const supportedEnvironment = sessionEnvironment?.type === "self_hosted" || sessionEnvironment?.type === "openai_hosted" + ? sessionEnvironment + : null; + const rawEnvironmentId = supportedEnvironment?.id ?? "environment_fixture"; + const canonicalEnvironmentId = rawEnvironmentId.toLowerCase(); + const environment = { + id: canonicalUuidPattern.test(canonicalEnvironmentId) ? canonicalEnvironmentId : rawEnvironmentId, + type: supportedEnvironment?.type ?? "self_hosted", + status: environmentStatus, + error: environmentStatus === "failed" ? { + code: "environment_failed", + type: "environment_error", + message: "Safe failure; Authorization: Bearer fixture-secret X-API-Key: fixture-key", + } : null, + }; + response.write(`id: environment_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.environment.${environmentStatus}`, + event_id: `environment_${state.sequence}`, + session_id: sessionId, + environment, + })}\n\n`); + } + if (state.controls.streamCloseCount > 0) { + state.controls.streamCloseCount -= 1; + setTimeout(() => response.end(), state.controls.streamCloseDelayMs); + } + const heartbeat = setInterval(() => response.write(": fixture heartbeat\n\n"), 10_000); + request.on("close", () => { + clearInterval(heartbeat); + streamResponses.delete(response); + }); + return; + } + + return sendError(response, 404, `No fixture route for ${request.method} ${url.pathname}`); + } catch (error) { + return sendError(response, 500, error instanceof Error ? error.message : "Unknown fixture failure."); + } +}); + +server.listen(port, host, () => { + process.stdout.write(`AGENT_FIXTURE_READY http://${host}:${port}\n`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/apps/web/e2e/vault-credentials.spec.ts b/apps/web/e2e/vault-credentials.spec.ts new file mode 100644 index 000000000..59c557187 --- /dev/null +++ b/apps/web/e2e/vault-credentials.spec.ts @@ -0,0 +1,244 @@ +import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test"; + +// Runner traces record HTTP bodies. This file deliberately submits transient +// bearer values, so it must never produce a trace artifact. +test.use({ trace: "off" }); + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; + +interface FixtureRequest { + method: string; + path: string; + body?: Record; +} + +async function resetFixture(request: APIRequestContext): Promise { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function fixtureRequests(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function openAgents(page: Page, request: APIRequestContext): Promise { + await resetFixture(request); + await page.goto("/"); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); +} + +async function openAdvancedSessionSettings(dialog: Locator): Promise { + const toggle = dialog.getByRole("button", { name: /Advanced settings/ }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +} + +async function fillWriteOnlyBearer(dialog: Locator): Promise { + const input = dialog.locator('input[type="password"]'); + await expect(input).toHaveCount(1); + await input.evaluate((element) => { + (element as HTMLInputElement).value = globalThis.crypto.randomUUID().replaceAll("-", ""); + }); +} + +test.describe("Vault capability discovery", () => { + for (const status of [404, 405]) { + test(`hides Vault controls when the connected Core returns ${status}`, async ({ page, request }) => { + await resetFixture(request); + await page.route("**/v1/vaults?*", async (route) => { + if (route.request().method() === "GET" && new URL(route.request().url()).pathname === "/v1/vaults") { + await route.fulfill({ + status, + json: { error: { code: status === 404 ? "not_found" : "method_not_allowed", message: "Unavailable." } }, + }); + return; + } + await route.continue(); + }); + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Vaults", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "System", exact: true }).click(); + await expect(page.locator(".system-summary-cell").filter({ hasText: "Vaults" })) + .toContainText("Unavailable"); + }); + } +}); + +test("keeps Vault content aligned with the page header without narrow-screen clipping", async ({ page, request }) => { + await resetFixture(request); + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/"); + await page.getByRole("button", { name: "Vaults", exact: true }).click(); + + const desktop = await page.locator(".vaults-page").evaluate((pageElement) => { + const title = pageElement.querySelector(".page-header h1")?.getBoundingClientRect(); + const contentElement = pageElement.querySelector(".vaults-content"); + const note = contentElement?.querySelector(".vault-security-note")?.getBoundingClientRect(); + const page = pageElement.getBoundingClientRect(); + return { + contentOverflowY: contentElement ? getComputedStyle(contentElement).overflowY : null, + pageRight: Math.round(page.right), + titleLeft: Math.round(title?.left ?? -1), + noteLeft: Math.round(note?.left ?? -2), + noteRight: Math.round(note?.right ?? -3), + }; + }); + expect(desktop.contentOverflowY).toBe("auto"); + expect(desktop.noteLeft).toBe(desktop.titleLeft); + expect(desktop.pageRight - desktop.noteRight).toBeGreaterThanOrEqual(23); + expect(desktop.pageRight - desktop.noteRight).toBeLessThanOrEqual(24); + + await page.setViewportSize({ width: 375, height: 720 }); + const narrow = await page.locator(".vaults-page").evaluate((pageElement) => { + const title = pageElement.querySelector(".page-header h1")?.getBoundingClientRect(); + const note = pageElement.querySelector(".vault-security-note")?.getBoundingClientRect(); + const createButton = pageElement.querySelector(".page-actions .button.primary")?.getBoundingClientRect(); + const page = pageElement.getBoundingClientRect(); + return { + pageClientWidth: pageElement.clientWidth, + pageScrollWidth: pageElement.scrollWidth, + pageRight: Math.round(page.right), + titleLeft: Math.round(title?.left ?? -1), + noteLeft: Math.round(note?.left ?? -2), + createButtonRight: Math.round(createButton?.right ?? -3), + }; + }); + expect(narrow.pageScrollWidth).toBeLessThanOrEqual(narrow.pageClientWidth); + expect(narrow.noteLeft).toBe(narrow.titleLeft); + expect(narrow.pageRight - narrow.createButtonRight).toBeGreaterThanOrEqual(11); + expect(narrow.pageRight - narrow.createButtonRight).toBeLessThanOrEqual(12); +}); + +test("shows a fixed 503 storage error, clears the token, and does not retry the write", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: "Vaults", exact: true }).click(); + await page.getByRole("button", { name: "New Vault" }).click(); + const vaultDialog = page.getByRole("dialog", { name: "Create a Vault" }); + await vaultDialog.getByLabel("Name").fill("Unavailable storage"); + await vaultDialog.getByRole("button", { name: "Create Vault" }).click(); + + let credentialWrites = 0; + await page.route("**/v1/vaults/*/credentials", async (route) => { + if (route.request().method() === "POST") { + credentialWrites += 1; + await route.fulfill({ + status: 503, + json: { + error: { + code: "credential_storage_unavailable", + message: "Private fixture detail that the Web must not expose.", + }, + }, + }); + return; + } + await route.continue(); + }); + + const vaultCard = page.locator(".vault-card").filter({ hasText: "Unavailable storage" }); + await vaultCard.getByRole("button", { name: "Credential", exact: true }).click(); + const credentialDialog = page.getByRole("dialog", { name: "Add static bearer Credential" }); + await credentialDialog.getByLabel("Name").fill("Unavailable MCP"); + await credentialDialog.getByLabel("Exact MCP server URL").fill("https://mcp.example/unavailable"); + await fillWriteOnlyBearer(credentialDialog); + await credentialDialog.getByRole("button", { name: "Create Credential" }).click(); + + await expect(credentialDialog.getByRole("alert")).toContainText("Credential encryption is not configured on this Core"); + await expect(credentialDialog).not.toContainText("Private fixture detail"); + await expect(credentialDialog.locator('input[type="password"]')).toHaveValue(""); + expect(credentialWrites).toBe(1); +}); + +test("creates, replaces, uses, and deletes a write-only Vault Credential", async ({ page, request }) => { + const mcpURL = "https://mcp.example/vault-tools"; + await openAgents(page, request); + + const vaultsNavigation = page.getByRole("button", { name: "Vaults" }); + await expect(vaultsNavigation).toBeVisible(); + await vaultsNavigation.click(); + await page.getByRole("button", { name: "New Vault" }).click(); + const vaultDialog = page.getByRole("dialog", { name: "Create a Vault" }); + await vaultDialog.getByLabel("Name").fill("Runtime credentials"); + await vaultDialog.getByRole("button", { name: "Create Vault" }).click(); + + const vaultCard = page.locator(".vault-card").filter({ hasText: "Runtime credentials" }); + await expect(vaultCard).toBeVisible(); + await vaultCard.getByRole("button", { name: "Credential", exact: true }).click(); + const credentialDialog = page.getByRole("dialog", { name: "Add static bearer Credential" }); + await credentialDialog.getByLabel("Name").fill("Private docs MCP"); + await credentialDialog.getByLabel("Exact MCP server URL").fill(mcpURL); + await fillWriteOnlyBearer(credentialDialog); + await credentialDialog.getByRole("button", { name: "Create Credential" }).click(); + await expect(vaultCard).toContainText("Private docs MCP"); + await expect(vaultCard).toContainText("token hidden"); + + let requests = await fixtureRequests(request); + const credentialWrite = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials$/u.test(entry.path)); + expect(credentialWrite?.body?.auth).toMatchObject({ type: "static_bearer", mcp_server_url: mcpURL, token_present: true }); + expect(credentialWrite?.body?.auth).not.toHaveProperty("token"); + + await vaultCard.getByRole("button", { name: "Replace token for Private docs MCP", exact: true }).click(); + const replacementDialog = page.getByRole("dialog", { name: "Replace token · Private docs MCP" }); + await fillWriteOnlyBearer(replacementDialog); + await replacementDialog.getByRole("button", { name: "Replace token", exact: true }).click(); + await expect(replacementDialog).toHaveCount(0); + requests = await fixtureRequests(request); + const replacementWrite = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials\/[^/]+$/u.test(entry.path)); + expect(replacementWrite?.body?.auth).toEqual({ type: "static_bearer", token_present: true }); + expect(replacementWrite?.body?.auth).not.toHaveProperty("token"); + + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /^Create agent/ }).click(); + await page.getByLabel("Name").fill("Credentialed MCP Agent"); + await page.getByLabel("Model").selectOption({ label: "Custom model ID…" }); + await page.getByLabel("Custom model ID").fill("fixture/credential-model"); + await page.getByRole("button", { name: "Add HTTP MCP" }).click(); + const mcpCard = page.locator(".agent-tool-card").filter({ hasText: "HTTP MCP" }).first(); + await mcpCard.getByLabel("Server label").fill("private-docs"); + await mcpCard.getByLabel("Authentication").selectOption({ label: `Private docs MCP · ${mcpURL}` }); + await expect(mcpCard.getByLabel("Server URL")).toHaveValue(mcpURL); + await expect(mcpCard.getByLabel("Server URL")).toHaveAttribute("readonly", ""); + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); + await page.getByRole("button", { name: "Start Session" }).click(); + + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(sessionDialog); + await expect(sessionDialog.getByRole("heading", { name: "Tools & Vaults" })).toBeVisible(); + await expect(sessionDialog).toContainText("Private docs MCP · Runtime credentials"); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); + await expect(page.locator(".toast-region:not(.toast-region-assertive)")).toContainText("Idle Session created"); + + requests = await fixtureRequests(request); + const vaultCreate = requests.find((entry) => entry.method === "POST" && entry.path === "/v1/vaults"); + const vaultId = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials$/u.test(entry.path))?.path.split("/")[3]; + expect(vaultCreate).toBeTruthy(); + expect(vaultId).toMatch(/^[0-9a-f-]{36}$/u); + const agentCreate = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents").at(-1); + const credentialId = (agentCreate?.body?.tools as Array> | undefined)?.[0]?.credential_id; + expect(credentialId).toMatch(/^[0-9a-f-]{36}$/u); + const sessionCreate = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions").at(-1); + expect(sessionCreate?.body?.vault_ids).toEqual([vaultId]); + expect(JSON.stringify(requests)).not.toContain('"token":'); + + await page.getByRole("button", { name: "Vaults" }).click(); + const currentVaultCard = page.locator(".vault-card").filter({ hasText: "Runtime credentials" }); + await currentVaultCard.getByRole("button", { name: "Delete Private docs MCP", exact: true }).click(); + const deleteCredentialDialog = page.getByRole("dialog", { name: "Delete Credential?" }); + await deleteCredentialDialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(currentVaultCard).not.toContainText("Private docs MCP"); + await currentVaultCard.getByRole("button", { name: "Delete Runtime credentials", exact: true }).click(); + const deleteVaultDialog = page.getByRole("dialog", { name: "Delete Vault?" }); + await deleteVaultDialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(page.getByRole("heading", { name: "No Vaults" })).toBeVisible(); + + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && /\/v1\/vaults\/[^/]+\/credentials\/[^/]+$/u.test(entry.path))).toHaveLength(1); + expect(requests.filter((entry) => entry.method === "DELETE" && /^\/v1\/vaults\/[^/]+$/u.test(entry.path))).toHaveLength(1); + expect(JSON.stringify(requests)).not.toContain('"token":'); +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 000000000..4d1421b48 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + Agents Core Web + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..98bf865e2 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@agents-core-web/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 4173", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@agents-core-web/agents-client": "workspace:*", + "lucide-react": "^1.22.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.4", + "typescript": "^5.8.3", + "vite": "^8.1.4", + "vitest": "^4.1.9" + } +} diff --git a/apps/web/public/favicon.png b/apps/web/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..83ad57715d733063ffd94ceb4686ab64350aea10 GIT binary patch literal 7963 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 bCp4xh_}M^@)f@7ErZ+$x{2ElLWEJrr2_Ik8 literal 0 HcmV?d00001 diff --git a/apps/web/public/parsar-mark-dark.png b/apps/web/public/parsar-mark-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8a38b4d34b0734f8cf5f347304a9ab1c0a82f614 GIT binary patch literal 28899 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 QFE4~B$*I9BWXyv82kRa7umAu6 literal 0 HcmV?d00001 diff --git a/apps/web/public/parsar-mark-light.png b/apps/web/public/parsar-mark-light.png new file mode 100644 index 0000000000000000000000000000000000000000..2d3f2bed915bd93aaf5454fb8630b3b3b605cd99 GIT binary patch literal 32993 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 void; +} + +interface LiveSessionEventContext { + sessionId: string; + streamEpoch: number; + isCurrent: () => boolean; + refreshCoordinator: DurableRefreshCoordinator | null; +} + +const COLLECTION_RECONCILIATION_ATTEMPTS = 3; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "The Agent core request failed."; +} + +function isAbort(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +async function listAllItems( + core: AgentCore, + sessionId: string, + signal?: AbortSignal, +): Promise { + const items: SessionItem[] = []; + let after: string | undefined; + + while (true) { + const page = await core.listItems(sessionId, { after, limit: 100, order: "asc", signal }); + items.push(...page.data); + if (!page.has_more) return items; + + const nextAfter = page.last_id ?? page.data[page.data.length - 1]?.id; + if (!nextAfter || nextAfter === after) { + throw new Error("The Agent core returned an invalid Items pagination cursor."); + } + after = nextAfter; + } +} + +function projectTextEvent(current: SessionItem[], event: SessionEvent): SessionItem[] { + const eventType = typeof event.type === "string" ? event.type : ""; + if (!eventType.includes(".output_text.")) return current; + + const partText = event.part?.type === "output_text" ? event.part.text : undefined; + const replacement = event.text ?? partText ?? undefined; + const addition = event.delta; + if (replacement === undefined && addition === undefined) return current; + + const streamId = `stream:${event.turn_id ?? "turn"}:${event.output_index ?? 0}:${event.content_index ?? 0}`; + const existingIndex = current.findIndex((item) => item.id === event.item_id || item.id === streamId); + const existing = existingIndex >= 0 ? current[existingIndex] : undefined; + const previousText = existing?.type === "message" + ? (existing.content ?? []).map((content) => content.text ?? "").join("") + : ""; + const text = replacement ?? `${previousText}${addition ?? ""}`; + const projected: SessionItem = { + id: existing?.id ?? event.item_id ?? streamId, + turn_id: event.turn_id ?? existing?.turn_id ?? "", + type: "message", + status: eventType.endsWith(".done") || eventType.endsWith(".completed") ? "completed" : "in_progress", + role: "assistant", + phase: "final_answer", + content: [{ type: "output_text", text }], + }; + + return upsertSessionItem(current, projected); +} + +export function App() { + const { show: showToast } = useToast(); + const [view, setView] = useState(viewFromLocation); + const [connection, setConnection] = useState(() => loadConnection()); + const [connectionOpen, setConnectionOpen] = useState(false); + + useEffect(() => { + const hash = view === "dashboard" ? "" : `#${view}`; + const next = `${window.location.pathname}${window.location.search}${hash}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (current !== next) window.history.replaceState(window.history.state, "", next); + }, [view]); + + useEffect(() => { + const onHashChange = () => setView(viewFromLocation()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + const [agents, setAgents] = useState([]); + const [vaultCatalog, setVaultCatalog] = useState(null); + const [vaultCollectionState, setVaultCollectionState] = useState("connecting"); + const [vaultCollectionError, setVaultCollectionError] = useState(null); + const [vaultSupported, setVaultSupported] = useState(null); + const [environmentTemplates, setEnvironmentTemplates] = useState(null); + const [sessions, setSessions] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [items, setItems] = useState([]); + const [itemsSessionId, setItemsSessionId] = useState(null); + const [turns, setTurns] = useState([]); + const [turnsSessionId, setTurnsSessionId] = useState(null); + const [turnCollectionLoad, setTurnCollectionLoad] = useState({ + sessionId: null, + state: "idle", + error: null, + }); + const [environmentObservations, setEnvironmentObservations] = useState>( + () => new Map(), + ); + const [agentCollectionState, setAgentCollectionState] = useState("connecting"); + const [agentCollectionError, setAgentCollectionError] = useState(null); + const [agentCollectionHasSnapshot, setAgentCollectionHasSnapshot] = useState(false); + const [sessionCollectionState, setSessionCollectionState] = useState("connecting"); + const [sessionCollectionError, setSessionCollectionError] = useState(null); + const [sessionCollectionHasSnapshot, setSessionCollectionHasSnapshot] = useState(false); + const [sessionAgentFilter, setSessionAgentFilter] = useState(null); + const [filteredSessions, setFilteredSessions] = useState([]); + const [filteredSessionCollectionState, setFilteredSessionCollectionState] = useState("connecting"); + const [filteredSessionCollectionError, setFilteredSessionCollectionError] = useState(null); + const [selectedSessionLoad, setSelectedSessionLoad] = useState({ + sessionId: null, + state: "idle", + error: null, + }); + const [streamConnection, setStreamConnection] = useState({ + sessionId: null, + state: "idle", + error: null, + }); + const [streamRetryRevision, setStreamRetryRevision] = useState(0); + const [sessionSendFailures, setSessionSendFailures] = useState>( + () => new Map(), + ); + const cancelFailureRef = useRef(undefined); + const functionResultFailuresRef = useRef(new Map()); + const [busy, setBusy] = useState(false); + const [agentCreateRequest, setAgentCreateRequest] = useState(null); + const [sessionCreateRequest, setSessionCreateRequest] = useState(null); + const agentCreateSequenceRef = useRef(0); + const sessionCreateSequenceRef = useRef(0); + const selectedIdRef = useRef(selectedId); + const sessionsRef = useRef(sessions); + const filteredSessionsRef = useRef(filteredSessions); + const itemsSessionIdRef = useRef(itemsSessionId); + const turnsSessionIdRef = useRef(turnsSessionId); + const connectionGenerationRef = useRef(0); + const agentCollectionRequestRef = useRef(0); + const sessionCollectionRequestRef = useRef(0); + const agentCollectionAbortRef = useRef(null); + const sessionCollectionAbortRef = useRef(null); + const filteredSessionCollectionAbortRef = useRef(null); + const filteredSessionCollectionRequestRef = useRef(0); + const sessionAgentFilterRef = useRef(sessionAgentFilter); + const vaultCollectionAbortRef = useRef(null); + const environmentTemplateAbortRef = useRef(null); + const vaultCollectionRequestRef = useRef(0); + const agentCollectionRevisionRef = useRef(0); + const sessionCollectionRevisionRef = useRef(0); + const sessionRequestRef = useRef(new Map()); + const sessionEventRevisionRef = useRef(new Map()); + const itemEventRevisionRef = useRef(new Map()); + const turnEventRevisionRef = useRef(new Map()); + const environmentEventRevisionRef = useRef(new Map()); + const environmentRequestRef = useRef(new Map()); + const sessionEnvironmentIdentityRef = useRef(new Map()); + const operationRequestRef = useRef(0); + const streamEpochRef = useRef(0); + const selectedSessionReadAbortRef = useRef(null); + const selectedStreamAbortRef = useRef(null); + const creationStreamOwnerRef = useRef(null); + selectedIdRef.current = selectedId; + sessionsRef.current = sessions; + filteredSessionsRef.current = filteredSessions; + sessionAgentFilterRef.current = sessionAgentFilter; + + const core = useMemo(() => createCore(connection), [connection]); + const sourceFilesOperations = useMemo(() => ({ + uploadSourceFile: (input, options) => core.uploadSourceFile(input, options), + retrieveSourceFile: (fileId, options) => core.retrieveSourceFile(fileId, options), + downloadSourceFile: (fileId, options) => core.downloadSourceFile(fileId, options), + deleteSourceFile: (fileId, options) => core.deleteSourceFile(fileId, options), + retrieveEnvironment: (environmentId, options) => core.retrieveEnvironment(environmentId, options), + createEnvironmentFile: (environmentId, input, options) => core.createEnvironmentFile(environmentId, input, options), + listEnvironmentFiles: (environmentId, options) => core.listEnvironmentFiles(environmentId, options), + }), [core]); + const coreGeneration = connectionGenerationRef.current; + const coreState: CoreConnectionState = agentCollectionState === "ready" || sessionCollectionState === "ready" + ? "ready" + : agentCollectionState === "failed" && sessionCollectionState === "failed" + ? "failed" + : "connecting"; + const sessionVaultCatalog = vaultCollectionState === "ready" ? vaultCatalog : null; + const sessionBrowserSessions = sessionAgentFilter ? filteredSessions : sessions; + const sessionBrowserState = sessionAgentFilter ? filteredSessionCollectionState : sessionCollectionState; + const sessionBrowserError = sessionAgentFilter ? filteredSessionCollectionError : sessionCollectionError; + const selected = selectedId + ? sessionBrowserSessions.find((session) => session.id === selectedId) ?? null + : null; + const detailState: SessionDetailState = !selectedId + ? "idle" + : selectedSessionLoad.sessionId === selectedId + ? selectedSessionLoad.state + : "loading"; + const detailError = selectedSessionLoad.sessionId === selectedId ? selectedSessionLoad.error : null; + const turnState: SessionDetailState = !selectedId + ? "idle" + : turnCollectionLoad.sessionId === selectedId + ? turnCollectionLoad.state + : "loading"; + const turnError = turnCollectionLoad.sessionId === selectedId ? turnCollectionLoad.error : null; + const streamState: StreamState = !selectedId + ? "idle" + : streamConnection.sessionId !== selectedId + ? "connecting" + : streamConnection.state; + const streamError = selectedId && streamConnection.sessionId === selectedId + ? streamConnection.error + : null; + const sendError = selectedId ? sessionSendFailures.get(selectedId) ?? null : null; + const environmentObservation: EnvironmentObservation | null = visibleEnvironmentObservation( + selectedId ? environmentObservations.get(selectedId) : undefined, + selectedId, + streamConnection.sessionId, + streamEpochRef.current, + ); + const streamReady = streamState === "listening"; + const notify = useCallback((message: string, tone: "success" | "error") => { + showToast(message, { tone, key: `${tone}:${message}` }); + }, [showToast]); + + const refreshAgents = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + agentCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + agentCollectionAbortRef.current = controller; + setAgentCollectionState("connecting"); + setAgentCollectionError(null); + try { + const request = agentCollectionRequestRef.current + 1; + agentCollectionRequestRef.current = request; + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listAgents(options), + () => agentCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== agentCollectionRequestRef.current + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setAgentCollectionState("failed"); + setAgentCollectionError(message); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); + return false; + } + if (result.value === null) { + const message = "Agent data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setAgentCollectionState("failed"); + setAgentCollectionError(message); + notify(message, "error"); + return false; + } + setAgents(result.value); + setAgentCollectionHasSnapshot(true); + setAgentCollectionState("ready"); + return true; + } finally { + if (agentCollectionAbortRef.current === controller) agentCollectionAbortRef.current = null; + } + }, [core, coreGeneration, notify]); + + const refreshSessions = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + sessionCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + sessionCollectionAbortRef.current = controller; + setSessionCollectionState("connecting"); + setSessionCollectionError(null); + try { + const request = sessionCollectionRequestRef.current + 1; + sessionCollectionRequestRef.current = request; + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listSessions(options), + () => sessionCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== sessionCollectionRequestRef.current + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setSessionCollectionState("failed"); + setSessionCollectionError(message); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); + return false; + } + if (result.value === null) { + const message = "Session data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setSessionCollectionState("failed"); + setSessionCollectionError(message); + notify(message, "error"); + return false; + } + const stableSessions = result.value; + + const nextEnvironmentIdentities = new Map( + stableSessions.map((session) => [session.id, supportedEnvironmentIdentity(session.environment)]), + ); + const changedEnvironmentSessions = new Set(); + for (const [sessionId, identity] of nextEnvironmentIdentities) { + if (!environmentIdentitiesMatch(sessionEnvironmentIdentityRef.current.get(sessionId), identity)) { + changedEnvironmentSessions.add(sessionId); + } + } + for (const sessionId of sessionEnvironmentIdentityRef.current.keys()) { + if (!nextEnvironmentIdentities.has(sessionId)) changedEnvironmentSessions.add(sessionId); + } + for (const sessionId of changedEnvironmentSessions) { + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + } + sessionEnvironmentIdentityRef.current = nextEnvironmentIdentities; + if (changedEnvironmentSessions.size) { + setEnvironmentObservations((current) => { + if (![...changedEnvironmentSessions].some((sessionId) => current.has(sessionId))) return current; + const next = new Map(current); + for (const sessionId of changedEnvironmentSessions) next.delete(sessionId); + return next; + }); + } + sessionsRef.current = stableSessions; + setSessions(stableSessions); + setSessionCollectionHasSnapshot(true); + if (!sessionAgentFilterRef.current) { + setSelectedId((current) => { + const next = current && stableSessions.some((session) => session.id === current) + ? current + : stableSessions[0]?.id ?? null; + selectedIdRef.current = next; + return next; + }); + } + setSessionCollectionState("ready"); + return true; + } finally { + if (sessionCollectionAbortRef.current === controller) sessionCollectionAbortRef.current = null; + } + }, [core, coreGeneration, notify]); + + const refreshFilteredSessions = useCallback(async (agentId: string) => { + if ( + coreGeneration !== connectionGenerationRef.current || + sessionAgentFilterRef.current !== agentId + ) return false; + filteredSessionCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + filteredSessionCollectionAbortRef.current = controller; + const request = filteredSessionCollectionRequestRef.current + 1; + filteredSessionCollectionRequestRef.current = request; + setFilteredSessionCollectionState("connecting"); + setFilteredSessionCollectionError(null); + try { + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listSessions({ ...options, agentId }), + () => sessionCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== filteredSessionCollectionRequestRef.current || + sessionAgentFilterRef.current !== agentId + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); + return false; + } + if (result.value === null) { + const message = "Filtered Session data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(message, "error"); + return false; + } + const stableFilteredSessions = result.value; + if (stableFilteredSessions.some((session) => session.agent.id !== agentId)) { + const message = "Agent Core returned a Session outside the requested Agent filter."; + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(message, "error"); + return false; + } + filteredSessionsRef.current = stableFilteredSessions; + setFilteredSessions(stableFilteredSessions); + setSelectedId((current) => { + const next = current && stableFilteredSessions.some((session) => session.id === current) + ? current + : stableFilteredSessions[0]?.id ?? null; + selectedIdRef.current = next; + return next; + }); + setFilteredSessionCollectionState("ready"); + return true; + } finally { + if (filteredSessionCollectionAbortRef.current === controller) { + filteredSessionCollectionAbortRef.current = null; + } + } + }, [core, coreGeneration, notify]); + + const refreshVaults = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + vaultCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + vaultCollectionAbortRef.current = controller; + const request = vaultCollectionRequestRef.current + 1; + vaultCollectionRequestRef.current = request; + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + try { + const catalog = await loadVaultCatalog(core, controller.signal); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== vaultCollectionRequestRef.current + ) return false; + setVaultCatalog(catalog); + setVaultSupported(true); + setVaultCollectionState("ready"); + return true; + } catch (error) { + if ( + coreGeneration !== connectionGenerationRef.current || + request !== vaultCollectionRequestRef.current || + isAbort(error) + ) return false; + if (error instanceof AgentCoreError && (error.status === 404 || error.status === 405)) { + setVaultCatalog(null); + setVaultSupported(false); + setVaultCollectionState("ready"); + setVaultCollectionError(null); + return false; + } + setVaultCollectionState("failed"); + setVaultCollectionError(errorMessage(error)); + return false; + } finally { + if (vaultCollectionAbortRef.current === controller) vaultCollectionAbortRef.current = null; + } + }, [core, coreGeneration]); + + const refreshEnvironmentTemplates = useCallback(async () => { + // Managed Environment configuration is only ever read for the Web build that + // can request it. An unread catalog stays null, never an implied capability. + if (!__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) return; + if (coreGeneration !== connectionGenerationRef.current) return; + environmentTemplateAbortRef.current?.abort(); + const controller = new AbortController(); + environmentTemplateAbortRef.current = controller; + try { + const catalog = await loadEnvironmentTemplateCatalog(core, controller.signal); + if ( + coreGeneration !== connectionGenerationRef.current || + environmentTemplateAbortRef.current !== controller + ) return; + setEnvironmentTemplates(catalog); + } catch (error) { + if ( + coreGeneration !== connectionGenerationRef.current || + environmentTemplateAbortRef.current !== controller || + isAbort(error) + ) return; + setEnvironmentTemplates({ state: "failed", message: errorMessage(error) }); + } finally { + if (environmentTemplateAbortRef.current === controller) environmentTemplateAbortRef.current = null; + } + }, [core, coreGeneration]); + + const refreshSession = useCallback( + async (sessionId: string, signal?: AbortSignal): Promise => { + if (coreGeneration !== connectionGenerationRef.current) return false; + const sessionScopeAgentId = sessionAgentFilterRef.current; + const request = (sessionRequestRef.current.get(sessionId) ?? 0) + 1; + const sessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; + const itemRevision = itemEventRevisionRef.current.get(sessionId) ?? 0; + const turnRevision = turnEventRevisionRef.current.get(sessionId) ?? 0; + const environmentRevision = environmentEventRevisionRef.current.get(sessionId) ?? 0; + sessionRequestRef.current.set(sessionId, request); + const turnRead = { coreGeneration, request, sessionId }; + void listAllTurns(core, sessionId, signal).then((sessionTurns) => { + const currentTurnRead = { + coreGeneration: connectionGenerationRef.current, + request: sessionRequestRef.current.get(sessionId) ?? 0, + sessionId, + selectedSessionId: selectedIdRef.current, + }; + if (!turnReadIsCurrent(turnRead, currentTurnRead)) return; + const liveRevisionChanged = turnRevision !== (turnEventRevisionRef.current.get(sessionId) ?? 0); + const currentTurnsSessionId = turnsSessionIdRef.current; + turnsSessionIdRef.current = sessionId; + setTurns((current) => ( + liveRevisionChanged + ? mergeDurableAndLiveTurns( + sessionTurns, + currentTurnsSessionId === sessionId ? current : [], + ) + : sessionTurns + )); + setTurnsSessionId(sessionId); + setTurnCollectionLoad({ sessionId, state: "ready", error: null }); + }).catch((error: unknown) => { + const currentTurnRead = { + coreGeneration: connectionGenerationRef.current, + request: sessionRequestRef.current.get(sessionId) ?? 0, + sessionId, + selectedSessionId: selectedIdRef.current, + }; + if (!turnReadIsCurrent(turnRead, currentTurnRead) || isAbort(error)) return; + setTurnCollectionLoad({ + sessionId, + state: "failed", + error: errorMessage(error), + }); + }); + try { + const [session, sessionItems] = await Promise.all([ + core.retrieveSession(sessionId, { signal }), + listAllItems(core, sessionId, signal), + ]); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== sessionRequestRef.current.get(sessionId) + ) return false; + if (sessionScopeAgentId && session.agent.id !== sessionScopeAgentId) { + throw new Error("Agent Core returned Session details outside the active Agent filter."); + } + const currentSessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; + const sessionIsCurrent = sessionRevision === currentSessionRevision; + const environmentIdentity = supportedEnvironmentIdentity(session.environment); + if (sessionIsCurrent) { + sessionEnvironmentIdentityRef.current.set(sessionId, environmentIdentity); + sessionCollectionRevisionRef.current += 1; + setSessions((current) => { + const found = current.some((value) => value.id === session.id); + const next = found + ? current.map((value) => (value.id === session.id ? session : value)) + : sessionScopeAgentId ? current : [session, ...current]; + sessionsRef.current = next; + return next; + }); + if ( + sessionScopeAgentId && + sessionAgentFilterRef.current === sessionScopeAgentId + ) { + setFilteredSessions((current) => { + if (!current.some((value) => value.id === session.id)) return current; + const next = current.map((value) => (value.id === session.id ? session : value)); + filteredSessionsRef.current = next; + return next; + }); + } + } + if (selectedIdRef.current === sessionId) { + const liveRevisionChanged = itemRevision !== (itemEventRevisionRef.current.get(sessionId) ?? 0); + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItems((current) => ( + liveRevisionChanged + ? mergeDurableAndLiveItems( + sessionItems, + currentItemsSessionId === sessionId ? current : [], + ) + : sessionItems + )); + setItemsSessionId(sessionId); + } + if (selectedIdRef.current === sessionId) { + setSelectedSessionLoad({ sessionId, state: "ready", error: null }); + } + + if (!sessionIsCurrent) return true; + if (!environmentIdentity) { + if (environmentRevision === (environmentEventRevisionRef.current.get(sessionId) ?? 0)) { + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + } + return true; + } + if (signal?.aborted || selectedIdRef.current !== sessionId) return false; + + const environmentStreamEpoch = streamEpochRef.current; + const environmentRequest = (environmentRequestRef.current.get(sessionId) ?? 0) + 1; + environmentRequestRef.current.set(sessionId, environmentRequest); + const environmentRead = { + coreGeneration, + sessionId, + ...environmentIdentity, + sessionRequest: request, + environmentRequest, + streamEpoch: environmentStreamEpoch, + sessionRevision, + environmentRevision, + }; + let observation: EnvironmentObservation; + try { + const resource = await core.retrieveEnvironment(environmentIdentity.environmentId, { signal }); + observation = environmentObservationFromResource(resource, environmentIdentity) + ?? unavailableEnvironmentObservation(environmentIdentity); + } catch (error) { + if (isAbort(error)) return false; + observation = unavailableEnvironmentObservation(environmentIdentity); + } + const currentEnvironmentIdentity = sessionEnvironmentIdentityRef.current.get(sessionId); + if (signal?.aborted || !environmentReadIsCurrent(environmentRead, { + coreGeneration: connectionGenerationRef.current, + sessionId, + environmentId: currentEnvironmentIdentity?.environmentId ?? "", + environmentType: currentEnvironmentIdentity?.environmentType ?? environmentIdentity.environmentType, + sessionRequest: sessionRequestRef.current.get(sessionId) ?? 0, + environmentRequest: environmentRequestRef.current.get(sessionId) ?? 0, + streamEpoch: streamEpochRef.current, + sessionRevision: sessionEventRevisionRef.current.get(sessionId) ?? 0, + environmentRevision: environmentEventRevisionRef.current.get(sessionId) ?? 0, + selectedSessionId: selectedIdRef.current, + })) return false; + setEnvironmentObservations((current) => { + const next = new Map(current); + const existing = current.get(sessionId); + const merged = existing?.streamEpoch === environmentStreamEpoch + ? mergeDurableEnvironmentObservation(existing.observation, observation) + : observation; + next.set(sessionId, { observation: merged, sessionId, streamEpoch: environmentStreamEpoch }); + return next; + }); + return true; + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + request === sessionRequestRef.current.get(sessionId) && + selectedIdRef.current === sessionId + ) { + if (isAbort(error)) return false; + const message = errorMessage(error); + setSelectedSessionLoad({ sessionId, state: "failed", error: message }); + notify(message, "error"); + } + return false; + } + }, + [core, coreGeneration, notify], + ); + + const refreshSelectedSession = useCallback((sessionId: string): Promise => { + if (selectedIdRef.current !== sessionId) return Promise.resolve(false); + selectedSessionReadAbortRef.current?.abort(); + const controller = new AbortController(); + // Turn pagination can outlive refreshSession's Session/Item result, so retain this + // controller until the next selected read, selection change, deletion, or Core change. + selectedSessionReadAbortRef.current = controller; + return refreshSession(sessionId, controller.signal); + }, [refreshSession]); + + const recoverSessionWorkspace = useCallback(() => { + void (async () => { + const generation = coreGeneration; + void refreshAgents(); + const refreshed = await refreshSessions(); + if (!refreshed || generation !== connectionGenerationRef.current) return; + const filter = sessionAgentFilterRef.current; + if (filter) await refreshFilteredSessions(filter); + if (generation !== connectionGenerationRef.current) return; + const sessionId = selectedIdRef.current; + if (sessionId) await refreshSelectedSession(sessionId); + })(); + }, [coreGeneration, refreshAgents, refreshFilteredSessions, refreshSelectedSession, refreshSessions]); + + const refreshDashboard = useCallback(() => { + void refreshAgents(); + void refreshSessions(); + void refreshVaults(); + void refreshEnvironmentTemplates(); + const filter = sessionAgentFilterRef.current; + if (filter) void refreshFilteredSessions(filter); + }, [refreshAgents, refreshEnvironmentTemplates, refreshFilteredSessions, refreshSessions, refreshVaults]); + + const changeSessionAgentFilter = useCallback((agentId: string | null) => { + if (sessionAgentFilterRef.current === agentId) return; + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + filteredSessionCollectionRequestRef.current += 1; + sessionAgentFilterRef.current = agentId; + filteredSessionsRef.current = []; + setFilteredSessions([]); + setFilteredSessionCollectionError(null); + setFilteredSessionCollectionState(agentId ? "connecting" : "ready"); + const current = selectedIdRef.current; + const nextSelectedId = agentId + ? null + : current && sessionsRef.current.some((session) => session.id === current) + ? current + : sessionsRef.current[0]?.id ?? null; + selectedIdRef.current = nextSelectedId; + setSelectedId(nextSelectedId); + setSessionAgentFilter(agentId); + }, []); + + useEffect(() => { + cancelFailureRef.current = undefined; + functionResultFailuresRef.current.clear(); + }, [coreGeneration, selectedId]); + + useEffect(() => { + setAgents([]); + setVaultCatalog(null); + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + setVaultSupported(null); + setSessions([]); + setAgentCollectionHasSnapshot(false); + setSessionCollectionHasSnapshot(false); + setItems([]); + setTurns([]); + setEnvironmentObservations(new Map()); + itemsSessionIdRef.current = null; + setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setSelectedId(null); + setEnvironmentTemplates(null); + void refreshAgents(); + void refreshSessions(); + void refreshVaults(); + void refreshEnvironmentTemplates(); + }, [refreshAgents, refreshEnvironmentTemplates, refreshSessions, refreshVaults]); + + useEffect(() => { + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + filteredSessionCollectionRequestRef.current += 1; + filteredSessionsRef.current = []; + setFilteredSessions([]); + setFilteredSessionCollectionError(null); + if (!sessionAgentFilter) { + setFilteredSessionCollectionState("ready"); + return; + } + setFilteredSessionCollectionState("connecting"); + void refreshFilteredSessions(sessionAgentFilter); + return () => { + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + }; + }, [refreshFilteredSessions, sessionAgentFilter]); + + useEffect(() => { + if (vaultSupported === false && view === "vaults") setView("dashboard"); + }, [vaultSupported, view]); + + useEffect(() => { + selectedSessionReadAbortRef.current?.abort(); + if (!selectedId) { + selectedSessionReadAbortRef.current = null; + setItems([]); + setTurns([]); + itemsSessionIdRef.current = null; + setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setStreamConnection({ sessionId: null, state: "idle", error: null }); + return; + } + setItems([]); + setTurns([]); + itemsSessionIdRef.current = selectedId; + setItemsSessionId(selectedId); + turnsSessionIdRef.current = selectedId; + setTurnsSessionId(selectedId); + setTurnCollectionLoad({ sessionId: selectedId, state: "loading", error: null }); + setSelectedSessionLoad({ sessionId: selectedId, state: "loading", error: null }); + environmentEventRevisionRef.current.set( + selectedId, + (environmentEventRevisionRef.current.get(selectedId) ?? 0) + 1, + ); + environmentRequestRef.current.set( + selectedId, + (environmentRequestRef.current.get(selectedId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(selectedId)) return current; + const next = new Map(current); + next.delete(selectedId); + return next; + }); + const creationOwner = creationStreamOwnerRef.current; + if ( + creationOwner && + creationOwner.coreGeneration === coreGeneration && + creationOwner.sessionId === selectedId && + !creationOwner.controller.signal.aborted + ) creationOwner.startReconciliation?.(); + else void refreshSelectedSession(selectedId); + return () => { + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + }; + }, [coreGeneration, refreshSelectedSession, selectedId]); + + const applyLiveSessionEvent = useCallback(( + event: SessionEvent, + context: LiveSessionEventContext, + ) => { + const { sessionId, streamEpoch, isCurrent, refreshCoordinator } = context; + if (!isCurrent()) return; + if (typeof event.session_id === "string" && event.session_id && event.session_id !== sessionId) return; + const eventType = typeof event.type === "string" ? event.type : ""; + const eventSession = matchingSessionSnapshot(event, sessionId); + if (eventSession) { + const nextEnvironmentIdentity = supportedEnvironmentIdentity(eventSession.environment); + const previousEnvironmentIdentity = sessionEnvironmentIdentityRef.current.get(sessionId); + if (!environmentIdentitiesMatch(previousEnvironmentIdentity, nextEnvironmentIdentity)) { + sessionEnvironmentIdentityRef.current.set(sessionId, nextEnvironmentIdentity); + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + } + } + const isEnvironmentEvent = eventType.startsWith("agent.session.environment."); + if (isEnvironmentEvent) { + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + const next = new Map(current); + const existing = current.get(sessionId); + const previous = existing?.streamEpoch === streamEpoch ? existing.observation : null; + const reduced = reduceEnvironmentObservation( + previous, + event, + sessionId, + sessionEnvironmentIdentityRef.current.get(sessionId) ?? null, + ); + if (reduced) next.set(sessionId, { observation: reduced, sessionId, streamEpoch }); + else next.delete(sessionId); + return next; + }); + } + if (eventSession) { + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + sessionCollectionRevisionRef.current += 1; + setSessions((current) => { + const next = current.map((session) => (session.id === eventSession.id ? eventSession : session)); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + if (!current.some((session) => session.id === eventSession.id)) return current; + const next = current.map((session) => (session.id === eventSession.id ? eventSession : session)); + filteredSessionsRef.current = next; + return next; + }); + } + const eventTurn = matchingTurnSnapshot(event, sessionId); + if (eventTurn) { + turnEventRevisionRef.current.set( + sessionId, + (turnEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + if (selectedIdRef.current === sessionId) { + const currentTurnsSessionId = turnsSessionIdRef.current; + turnsSessionIdRef.current = sessionId; + setTurnsSessionId(sessionId); + setTurns((current) => upsertTurn( + currentTurnsSessionId === sessionId ? current : [], + eventTurn, + )); + } + } + const isCommandOutputDelta = eventType === "agent.output.command_execution_output.delta"; + if (event.item || eventType.includes(".output_text.") || isCommandOutputDelta) { + itemEventRevisionRef.current.set( + sessionId, + (itemEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + } + if (event.item && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => { + const sessionItems = updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (value) => value, + ); + const withoutTemporary = sessionItems.filter((item) => ( + event.item?.type !== "message" || event.item.role !== "assistant" || !item.id.startsWith(`stream:${event.item.turn_id}:`) + )); + return upsertSessionItem(withoutTemporary, event.item as SessionItem); + }); + } + if (eventType.includes(".output_text.") && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (sessionItems) => projectTextEvent(sessionItems, event), + )); + } + if (isCommandOutputDelta && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (sessionItems) => appendCommandOutputDelta(sessionItems, event), + )); + } + refreshCoordinator?.accept(event); + }, []); + + useEffect(() => { + selectedStreamAbortRef.current?.abort(); + const creationOwner = creationStreamOwnerRef.current; + if (!selectedId) { + creationOwner?.controller.abort(); + if (creationStreamOwnerRef.current === creationOwner) creationStreamOwnerRef.current = null; + return; + } + if ( + creationOwner && + !creationOwner.controller.signal.aborted && + creationOwner.coreGeneration === coreGeneration && + creationOwner.sessionId === selectedId + ) return; + if (creationOwner?.sessionId && creationOwner.sessionId !== selectedId) { + creationOwner.controller.abort(); + if (creationStreamOwnerRef.current === creationOwner) creationStreamOwnerRef.current = null; + } + + const sessionId = selectedId; + const controller = new AbortController(); + selectedStreamAbortRef.current = controller; + const streamEpoch = streamEpochRef.current + 1; + streamEpochRef.current = streamEpoch; + setStreamConnection({ sessionId, state: "connecting", error: null }); + let reconnectAttempt = 0; + const recovery = createStreamRecoveryBuffer(); + let refreshCoordinator: DurableRefreshCoordinator | null = null; + let recoveryPromise: Promise | null = null; + + const isCurrentStream = () => ( + !controller.signal.aborted && + coreGeneration === connectionGenerationRef.current && + streamEpoch === streamEpochRef.current && + selectedIdRef.current === sessionId + ); + + const setCurrentStreamState = (state: StreamState, error: string | null = null) => { + if (!isCurrentStream()) return; + setStreamConnection((current) => ( + current.sessionId === sessionId ? { ...current, state, error } : current + )); + }; + + const applyEvent = (event: SessionEvent) => applyLiveSessionEvent(event, { + sessionId, + streamEpoch, + isCurrent: isCurrentStream, + refreshCoordinator, + }); + + refreshCoordinator = createDurableRefreshCoordinator( + () => refreshSession(sessionId, controller.signal), + ); + + const listen = async () => { + while (isCurrentStream()) { + let openedAt: number | null = null; + let receivedEvent = false; + try { + await core.streamEvents(sessionId, { + signal: controller.signal, + onOpen: () => { + if (!isCurrentStream()) return; + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + openedAt = beginStreamReconciliation( + isCurrentStream, + () => setCurrentStreamState("listening"), + () => { + const token = recovery.begin(); + recoveryPromise = refreshSession(sessionId, controller.signal) + .finally(() => recovery.finish(token, isCurrentStream, applyEvent)); + }, + ); + }, + onEvent: (event) => { + if (!isCurrentStream()) return; + receivedEvent = true; + recovery.accept(event, applyEvent); + }, + }); + } catch (error) { + if (!isCurrentStream() || isAbort(error)) return; + if (!shouldRetryStreamError(error)) { + setCurrentStreamState("failed", errorMessage(error)); + const status = error instanceof AgentCoreError ? ` (${error.status})` : ""; + notify( + `Live event stream was rejected by Agent Core${status}. Check the Core connection settings.`, + "error", + ); + return; + } + } + + if (!isCurrentStream()) return; + await recoveryPromise; + recoveryPromise = null; + if (!isCurrentStream()) return; + if (streamConnectionWasStable(openedAt, receivedEvent)) reconnectAttempt = 0; + setCurrentStreamState("recovering"); + + const delay = streamReconnectDelay(reconnectAttempt); + reconnectAttempt += 1; + if (!(await waitForStreamReconnect(delay, controller.signal))) return; + } + }; + + void listen(); + + return () => { + refreshCoordinator?.dispose(); + recovery.invalidate(); + controller.abort(); + if (selectedStreamAbortRef.current === controller) selectedStreamAbortRef.current = null; + }; + }, [applyLiveSessionEvent, core, coreGeneration, notify, refreshSession, selectedId, streamRetryRevision]); + + const retryCurrentStream = useCallback(() => { + requestCurrentStreamRetry(selectedIdRef.current, (sessionId) => { + setStreamConnection({ sessionId, state: "connecting", error: null }); + setStreamRetryRevision((current) => current + 1); + }); + }, []); + + const run = async (operation: () => Promise, success?: string): Promise => { + if (coreGeneration !== connectionGenerationRef.current) return undefined; + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + try { + const result = await operation(); + if (coreGeneration !== connectionGenerationRef.current) return undefined; + if (success) notify(success, "success"); + return result; + } catch (error) { + if (coreGeneration !== connectionGenerationRef.current) return undefined; + notify(errorMessage(error), "error"); + throw error; + } finally { + if ( + coreGeneration === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); + } + }; + + const createAgent = async (input: CreateAgentInput) => { + const agent = await run(() => core.createAgent(input), "Agent created."); + if (!agent || coreGeneration !== connectionGenerationRef.current) return undefined; + agentCollectionRevisionRef.current += 1; + setAgents((current) => [agent, ...current]); + return agent; + }; + + const retrieveAgent = async (agentId: string) => { + return run(() => requestAgentDetail(core, agentId)); + }; + + const updateAgent = async (agentId: string, input: UpdateAgentInput) => { + const agent = await run(() => requestAgentUpdate(core, agentId, input), "Agent updated."); + if (!agent || coreGeneration !== connectionGenerationRef.current) return undefined; + agentCollectionRevisionRef.current += 1; + setAgents((current) => replaceSavedAgent(current, agent)); + return agent; + }; + + const deleteAgent = async (agentId: string) => { + const deleted = await run(() => requestAgentDelete(core, agentId), "Agent deleted."); + if (!deleted || coreGeneration !== connectionGenerationRef.current) return; + agentCollectionRevisionRef.current += 1; + setAgents((current) => removeSavedAgent(current, agentId)); + }; + + const runVaultMutation = async (operation: () => Promise): Promise => { + if (coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Core connection changed before the Vault operation started."); + } + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + try { + const result = await operation(); + if (coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Core connection changed before the Vault operation was confirmed."); + } + return result; + } finally { + if ( + coreGeneration === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); + } + }; + + const refreshAfterVaultMutation = async () => { + if (coreGeneration === connectionGenerationRef.current) await refreshVaults(); + }; + + const vaultOperations: VaultOperations = { + async createVault(name, metadata) { + try { + await runVaultMutation(() => requestVaultCreate(core, name, metadata)); + notify("Vault created.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async createCredential(vaultId, name, serverURL, token) { + try { + const created = await runVaultMutation(() => core.createVaultCredential(vaultId, { + name, + auth: { type: "static_bearer", mcp_server_url: serverURL, token }, + })); + if (created.vault_id !== vaultId || created.name !== name || created.auth.mcp_server_url !== serverURL) { + throw new Error("Agent Core returned mismatched Credential metadata."); + } + notify("Credential created. Token remains hidden.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async replaceCredential(vaultId, credentialId, token) { + const baseline = vaultCatalog?.credentials.find((credential) => ( + credential.vault_id === vaultId && credential.id === credentialId + )); + if (!baseline || vaultCollectionState !== "ready") { + throw new Error("The latest Credential metadata is unavailable. Refresh before replacing its token."); + } + try { + const updated = await runVaultMutation(() => core.replaceVaultCredentialToken(vaultId, credentialId, { + auth: { type: "static_bearer", token }, + })); + if ( + updated.id !== baseline.id || updated.vault_id !== baseline.vault_id || + updated.name !== baseline.name || updated.auth.mcp_server_url !== baseline.auth.mcp_server_url || + updated.created_at !== baseline.created_at || updated.updated_at < baseline.updated_at + ) throw new Error("Agent Core returned mismatched Credential metadata after replacement."); + notify("Credential token replaced. Running work may still hold the previous token.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async deleteCredential(vaultId, credentialId) { + try { + await runVaultMutation(() => core.deleteVaultCredential(vaultId, credentialId)); + notify("Credential deleted. Provider-side token was not revoked.", "success"); + } catch (error) { + let confirmedDeleted = false; + try { + await core.retrieveVaultCredential(vaultId, credentialId); + } catch (readError) { + confirmedDeleted = readError instanceof AgentCoreError && readError.status === 404; + } + await refreshAfterVaultMutation(); + if (confirmedDeleted) { + notify("Credential deletion reconciled from Core.", "success"); + return; + } + throw error; + } + await refreshAfterVaultMutation(); + }, + async deleteVault(vaultId) { + try { + await runVaultMutation(() => core.deleteVault(vaultId)); + notify("Vault and its Credentials deleted. Provider-side tokens were not revoked.", "success"); + } catch (error) { + let confirmedDeleted = false; + try { + await core.retrieveVault(vaultId); + } catch (readError) { + confirmedDeleted = readError instanceof AgentCoreError && readError.status === 404; + } + await refreshAfterVaultMutation(); + if (confirmedDeleted) { + notify("Vault deletion reconciled from Core.", "success"); + return; + } + throw error; + } + await refreshAfterVaultMutation(); + }, + refresh() { + void refreshVaults(); + }, + }; + + const createEnvironmentTemplate = async (input: CreateEnvironmentTemplateInput) => { + if (!__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) { + throw new Error("Managed Environment configuration is not enabled for this Web build."); + } + const template = await core.createEnvironmentTemplate(input); + // Re-read the durable collection so selection uses Core's own listing. + await refreshEnvironmentTemplates(); + notify("Environment Template saved. No Runtime was allocated.", "success"); + return template; + }; + + const createSession = async (input: SessionStartInput) => { + const submittedAgent = validateSessionAgentSubmission( + input.agentMode, + input.agentId, + input.agent, + agents, + sessionVaultCatalog, + ); + if (submittedAgent.error || !submittedAgent.effectiveAgent || !submittedAgent.requestAgent && input.agentMode === "inline") { + const error = new Error(`Session was not created. ${submittedAgent.error ?? "The Agent request is invalid."}`); + notify(error.message, "error"); + throw error; + } + const effectiveAgent = submittedAgent.effectiveAgent; + const vaultPlan = deriveSessionVaultPlan( + effectiveAgent, + sessionVaultCatalog, + input.manualVaultIds, + ); + const expectedVaultIds = [...input.vaultIds].sort(); + if ( + vaultPlan.blocker || + vaultPlan.vaultIds.length !== expectedVaultIds.length || + vaultPlan.vaultIds.some((vaultId, index) => vaultId !== expectedVaultIds[index]) + ) { + const error = new Error(`Session was not created. ${vaultPlan.blocker ?? "The derived Vault attachments changed before submission."}`); + notify(error.message, "error"); + throw error; + } + if (input.environment.type === "self_hosted" && !__AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS__) { + const error = new Error("Session was not created. Self-hosted Sessions are not enabled for this Web build."); + notify(error.message, "error"); + throw error; + } + if (input.environment.type === "openai_hosted" && !__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) { + const error = new Error("Session was not created. Managed hosted Sessions are not enabled for this Web build."); + notify(error.message, "error"); + throw error; + } + const normalizedEnvironment = normalizeSessionEnvironmentInput(input.environment); + const environmentInput = normalizedEnvironment.input; + if (!environmentInput) { + const error = new Error(`Session was not created. ${normalizedEnvironment.error ?? "The Environment input is invalid."}`); + notify(error.message, "error"); + throw error; + } + const environmentAdmissionBlocker = sessionEnvironmentAdmissionBlocker(effectiveAgent, environmentInput.type); + if (environmentAdmissionBlocker) { + const error = new Error(`Session was not created. ${environmentAdmissionBlocker}`); + notify(error.message, "error"); + throw error; + } + const request = sessionCreateRequestPayload({ + ...(input.agentMode === "saved" ? { agentId: input.agentId } : {}), + ...(submittedAgent.requestAgent ? { agent: submittedAgent.requestAgent } : {}), + environment: environmentInput, + ...(input.input !== undefined ? { input: input.input } : {}), + metadata: input.metadata, + stream: input.stream, + vaultIds: vaultPlan.vaultIds, + }); + + const openSession = (session: AgentSession) => { + sessionCollectionRevisionRef.current += 1; + sessionEnvironmentIdentityRef.current.set(session.id, supportedEnvironmentIdentity(session.environment)); + setSessions((current) => { + const next = [session, ...current.filter((value) => value.id !== session.id)]; + sessionsRef.current = next; + return next; + }); + changeSessionAgentFilter(null); + selectedIdRef.current = session.id; + setSelectedId(session.id); + setItems([]); + itemsSessionIdRef.current = session.id; + setItemsSessionId(session.id); + setTurns([]); + turnsSessionIdRef.current = session.id; + setTurnsSessionId(session.id); + setView("sessions"); + }; + + if (!input.stream) { + const session = await run(() => core.createSession( + { ...request, stream: false }, + input.idempotencyKey, + )); + if (!session || coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Session creation outcome could not be confirmed."); + } + openSession(session); + notify("Idle Session created. Opening live events…", "success"); + return; + } + + const generation = coreGeneration; + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + creationStreamOwnerRef.current?.controller.abort(); + const owner: CreationStreamOwner = { + controller: new AbortController(), + coreGeneration: generation, + sessionId: null, + streamEpoch: null, + }; + creationStreamOwnerRef.current = owner; + const recovery = createStreamRecoveryBuffer(); + let refreshCoordinator: DurableRefreshCoordinator | null = null; + let recoveryPromise: Promise | null = null; + let resolveCreated: (session: AgentSession) => void = () => undefined; + let rejectCreated: (error: unknown) => void = () => undefined; + const created = new Promise((resolve, reject) => { + resolveCreated = resolve; + rejectCreated = reject; + }); + const isCurrentCreationStream = () => ( + creationStreamOwnerRef.current === owner && + !owner.controller.signal.aborted && + generation === connectionGenerationRef.current && + owner.sessionId !== null && + owner.streamEpoch !== null && + streamEpochRef.current === owner.streamEpoch && + selectedIdRef.current === owner.sessionId + ); + const applyCreationEvent = (event: SessionEvent) => { + if (!owner.sessionId || owner.streamEpoch === null) return; + applyLiveSessionEvent(event, { + sessionId: owner.sessionId, + streamEpoch: owner.streamEpoch, + isCurrent: isCurrentCreationStream, + refreshCoordinator, + }); + }; + const handoffToGetStream = async () => { + const sessionId = owner.sessionId; + if (!sessionId) return; + owner.startReconciliation?.(); + await recoveryPromise; + if (!isCurrentCreationStream()) return; + setStreamConnection({ sessionId, state: "recovering", error: null }); + await refreshSession(sessionId, owner.controller.signal); + if (!isCurrentCreationStream()) return; + refreshCoordinator?.dispose(); + recovery.invalidate(); + if (creationStreamOwnerRef.current === owner) creationStreamOwnerRef.current = null; + owner.controller.abort(); + setStreamRetryRevision((current) => current + 1); + }; + + void core.createSessionStream(request, input.idempotencyKey, { + signal: owner.controller.signal, + onSession: (session) => { + if ( + creationStreamOwnerRef.current !== owner || + owner.controller.signal.aborted || + generation !== connectionGenerationRef.current + ) return; + owner.sessionId = session.id; + owner.streamEpoch = streamEpochRef.current + 1; + streamEpochRef.current = owner.streamEpoch; + const token = recovery.begin(); + refreshCoordinator = createDurableRefreshCoordinator( + () => refreshSession(session.id, owner.controller.signal), + ); + owner.startReconciliation = () => { + if (recoveryPromise || !isCurrentCreationStream()) return; + recoveryPromise = refreshSession(session.id, owner.controller.signal) + .finally(() => recovery.finish(token, isCurrentCreationStream, applyCreationEvent)); + }; + openSession(session); + setStreamConnection({ sessionId: session.id, state: "listening", error: null }); + owner.startReconciliation(); + resolveCreated(session); + }, + onOpen: () => undefined, + onEvent: (event) => { + if (event.type === "agent.session.created") return; + recovery.accept(event, applyCreationEvent); + }, + }).then( + () => handoffToGetStream(), + (error: unknown) => { + if (!owner.sessionId) { + recovery.invalidate(); + refreshCoordinator?.dispose(); + if (creationStreamOwnerRef.current === owner) creationStreamOwnerRef.current = null; + owner.controller.abort(); + rejectCreated(error); + return; + } + return handoffToGetStream(); + }, + ).catch((error: unknown) => { + if ( + generation === connectionGenerationRef.current && + owner.sessionId && + selectedIdRef.current === owner.sessionId + ) { + setStreamConnection({ sessionId: owner.sessionId, state: "failed", error: errorMessage(error) }); + } + }); + + try { + await created; + if (generation !== connectionGenerationRef.current) { + throw new Error("The Session creation outcome could not be confirmed after the Core connection changed."); + } + notify( + input.input === undefined + ? input.environment.type === "openai_hosted" + ? "Managed hosted Session created. Live creation events are connected." + : "Idle Session created. Live creation events are connected." + : "Session created with initial input. Live creation events are connected.", + "success", + ); + } catch (error) { + if (generation === connectionGenerationRef.current) notify(errorMessage(error), "error"); + throw error; + } finally { + if ( + generation === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); + } + }; + + const retrieveSessionForAction = useCallback(async (sessionId: string) => { + const generation = coreGeneration; + try { + const session = await requestSessionDetail(core, sessionId); + return generation === connectionGenerationRef.current ? session : undefined; + } catch (error) { + if (generation !== connectionGenerationRef.current) return undefined; + throw error; + } + }, [core, coreGeneration]); + + const updateSessionMetadata = async ( + sessionId: string, + baselineMetadata: Record, + draftMetadata: Record, + ): Promise => { + const generation = coreGeneration; + let updated: AgentSession; + try { + updated = await requestSessionUpdate(core, sessionId, baselineMetadata, draftMetadata); + } catch (error) { + if (generation !== connectionGenerationRef.current) return undefined; + if (error instanceof SessionMetadataConflictError && error.latestSession) { + sessionCollectionRevisionRef.current += 1; + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setSessions((current) => { + const next = replaceSessionMetadata(current, error.latestSession as AgentSession); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = replaceSessionMetadata(current, error.latestSession as AgentSession); + filteredSessionsRef.current = next; + return next; + }); + } + throw error; + } + if (generation !== connectionGenerationRef.current) { + notify("The previous Core returned a Session update after the connection changed. The current Core view was not modified.", "error"); + return undefined; + } + sessionCollectionRevisionRef.current += 1; + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setSessions((current) => { + const next = replaceSessionMetadata(current, updated); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = replaceSessionMetadata(current, updated); + filteredSessionsRef.current = next; + return next; + }); + notify("Session metadata updated.", "success"); + return updated; + }; + + const removeSessionFromWorkspace = (sessionId: string, message: string): boolean => { + const selectedAtCompletion = selectedIdRef.current; + const deletingSelected = selectedAtCompletion === sessionId; + const nextSelectedId = selectionAfterSessionDelete( + sessionAgentFilterRef.current ? filteredSessionsRef.current : sessionsRef.current, + selectedAtCompletion, + sessionId, + ); + const increment = (revisions: Map) => { + revisions.set(sessionId, (revisions.get(sessionId) ?? 0) + 1); + }; + sessionCollectionRevisionRef.current += 1; + increment(sessionRequestRef.current); + increment(sessionEventRevisionRef.current); + increment(itemEventRevisionRef.current); + increment(turnEventRevisionRef.current); + increment(environmentEventRevisionRef.current); + increment(environmentRequestRef.current); + sessionEnvironmentIdentityRef.current.delete(sessionId); + + setSessions((current) => { + const next = removeSession(current, sessionId); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = removeSession(current, sessionId); + filteredSessionsRef.current = next; + return next; + }); + setSessionSendFailures((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + + if (deletingSelected) { + selectedIdRef.current = nextSelectedId; + streamEpochRef.current += 1; + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + selectedStreamAbortRef.current?.abort(); + selectedStreamAbortRef.current = null; + if (creationStreamOwnerRef.current?.sessionId === sessionId) { + creationStreamOwnerRef.current.controller.abort(); + creationStreamOwnerRef.current = null; + } + itemsSessionIdRef.current = null; + turnsSessionIdRef.current = null; + setItems([]); + setItemsSessionId(null); + setTurns([]); + setTurnsSessionId(null); + setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setStreamConnection({ sessionId: null, state: "idle", error: null }); + setSelectedId(nextSelectedId); + } + notify(message, "success"); + return true; + }; + + const deleteSessionFromCore = async (sessionId: string): Promise => { + const generation = coreGeneration; + try { + await requestSessionDelete(core, sessionId); + } catch (error) { + if (generation !== connectionGenerationRef.current) return false; + if (!(error instanceof SessionActionError) || error.kind !== "unknown_write") throw error; + + const reconciliation = await reconcileUnknownSessionDelete(core, sessionId); + if (generation !== connectionGenerationRef.current) return false; + if (reconciliation.state === "missing") { + return removeSessionFromWorkspace( + sessionId, + "Session is absent from Agent Core after reconciling the unknown deletion result.", + ); + } + if (reconciliation.state === "unknown") { + throw new SessionActionError( + `${error.message} The follow-up durable Session refresh also failed, so the result remains unknown.`, + "unknown_write", + { cause: error }, + ); + } + throw new SessionActionError( + `${error.message} A follow-up durable Session refresh confirmed that the Session is still present.`, + "request_failed", + { cause: error }, + ); + } + if (generation !== connectionGenerationRef.current) { + notify("The previous Core confirmed Session deletion after the connection changed. The current Core view was not modified.", "error"); + return false; + } + return removeSessionFromWorkspace(sessionId, "Session deleted from Agent Core."); + }; + + const sendMessage = async (text: string) => { + const sessionId = selectedId; + if (!sessionId) return; + if (!streamReady) { + const message = "Wait for the live event stream to connect before sending."; + notify(message, "error"); + throw new Error(message); + } + const previousFailure = sessionSendFailures.get(sessionId); + const pending = beginPendingSend(sessionId, text, previousFailure); + setSessionSendFailures((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + try { + await run(() => core.sendMessage(sessionId, text, pending.idempotencyKey)); + } catch (error) { + if (coreGeneration === connectionGenerationRef.current) { + setSessionSendFailures((current) => { + const next = new Map(current); + next.set(sessionId, failPendingSend(pending, error, errorMessage(error))); + return next; + }); + } + throw error; + } + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + setSessionSendFailures((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + await refreshSelectedSession(sessionId); + }; + + const cancel = async () => { + const sessionId = selectedId; + if (!sessionId) return; + const pending = beginPendingSend( + sessionId, + "agent.session.input.cancel", + cancelFailureRef.current, + ); + cancelFailureRef.current = undefined; + try { + await run( + () => core.cancelTurn(sessionId, pending.idempotencyKey), + "Cancellation requested.", + ); + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + selectedIdRef.current === sessionId + ) cancelFailureRef.current = failPendingSend(pending, error, errorMessage(error)); + throw error; + } + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + cancelFailureRef.current = undefined; + await refreshSelectedSession(sessionId); + }; + + const submitFunctionResult = async (input: FunctionResultInput) => { + const sessionId = selectedId; + if (!sessionId) return; + const actionKey = functionResultActionKey(sessionId, input); + const pending = beginPendingFunctionResult( + sessionId, + input, + functionResultFailuresRef.current.get(actionKey), + ); + functionResultFailuresRef.current.delete(actionKey); + try { + await run( + () => core.submitFunctionResult(sessionId, input, pending.idempotencyKey), + "Function result submitted.", + ); + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + selectedIdRef.current === sessionId + ) { + functionResultFailuresRef.current.set( + actionKey, + failPendingFunctionResult(pending, error, errorMessage(error)), + ); + } + throw error; + } + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + functionResultFailuresRef.current.delete(actionKey); + await refreshSelectedSession(sessionId); + }; + + const listEnvironmentFiles = useCallback(( + environmentId: string, + options: Parameters[1], + ) => core.listEnvironmentFiles(environmentId, options), [core]); + + const createEnvironmentFile = useCallback(( + environmentId, + input, + options, + ) => core.createEnvironmentFile(environmentId, input, options), [core]); + + const applyConnection = (next: CoreConnection) => { + const normalized = { ...next, baseUrl: next.baseUrl.trim() || "/v1", token: next.token.trim() }; + connectionGenerationRef.current += 1; + agentCollectionRequestRef.current += 1; + sessionCollectionRequestRef.current += 1; + filteredSessionCollectionRequestRef.current += 1; + vaultCollectionRequestRef.current += 1; + agentCollectionRevisionRef.current = 0; + sessionCollectionRevisionRef.current = 0; + sessionRequestRef.current.clear(); + sessionEventRevisionRef.current.clear(); + itemEventRevisionRef.current.clear(); + turnEventRevisionRef.current.clear(); + environmentEventRevisionRef.current.clear(); + environmentRequestRef.current.clear(); + sessionEnvironmentIdentityRef.current.clear(); + operationRequestRef.current += 1; + streamEpochRef.current += 1; + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + selectedStreamAbortRef.current?.abort(); + selectedStreamAbortRef.current = null; + creationStreamOwnerRef.current?.controller.abort(); + creationStreamOwnerRef.current = null; + agentCollectionAbortRef.current?.abort(); + agentCollectionAbortRef.current = null; + sessionCollectionAbortRef.current?.abort(); + sessionCollectionAbortRef.current = null; + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + vaultCollectionAbortRef.current?.abort(); + vaultCollectionAbortRef.current = null; + environmentTemplateAbortRef.current?.abort(); + environmentTemplateAbortRef.current = null; + setEnvironmentTemplates(null); + saveConnection(normalized); + setBusy(false); + setAgentCollectionState("connecting"); + setAgentCollectionError(null); + setAgentCollectionHasSnapshot(false); + setSessionCollectionState("connecting"); + setSessionCollectionError(null); + setSessionCollectionHasSnapshot(false); + sessionAgentFilterRef.current = null; + filteredSessionsRef.current = []; + setSessionAgentFilter(null); + setFilteredSessions([]); + setFilteredSessionCollectionState("connecting"); + setFilteredSessionCollectionError(null); + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + setVaultSupported(null); + setVaultCatalog(null); + setAgents([]); + setSessions([]); + setItems([]); + setSelectedId(null); + setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setStreamConnection({ sessionId: null, state: "idle", error: null }); + setSessionSendFailures(new Map()); + cancelFailureRef.current = undefined; + functionResultFailuresRef.current.clear(); + setEnvironmentObservations(new Map()); + itemsSessionIdRef.current = null; + setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurns([]); + setConnection(normalized); + setConnectionOpen(false); + }; + + const openAgentSetup = () => { + setView("agents"); + agentCreateSequenceRef.current += 1; + setAgentCreateRequest(agentCreateSequenceRef.current); + }; + + const openSessionSetup = (agentId?: string) => { + if (agentId && !agents.some((candidate) => candidate.id === agentId)) { + notify("Session setup could not open because the selected saved Agent is not loaded.", "error"); + return; + } + setView("sessions"); + sessionCreateSequenceRef.current += 1; + setSessionCreateRequest({ agentId: agentId ?? null, requestId: sessionCreateSequenceRef.current }); + }; + + const consumeAgentCreateRequest = useCallback((request: number) => { + setAgentCreateRequest((current) => current === request ? null : current); + }, []); + + const consumeSessionCreateRequest = useCallback((request: number) => { + setSessionCreateRequest((current) => current?.requestId === request ? null : current); + }, []); + + return ( +

+ Skip to main content + + +
+
+ openSessionSetup()} + /> +
+
+ {view === "dashboard" ? ( + openSessionSetup()} + onViewAgents={() => setView("agents")} + onViewSessions={() => setView("sessions")} + onConfigureConnection={() => setConnectionOpen(true)} + onOpenSession={(sessionId) => { + changeSessionAgentFilter(null); + selectedIdRef.current = sessionId; + setSelectedId(sessionId); + setView("sessions"); + }} + /> + ) : null} + {view === "sessions" ? ( + { + if (selectedId) void refreshSelectedSession(selectedId); + }} + onRetryStream={retryCurrentStream} + onRetrieveSession={retrieveSessionForAction} + onSelect={setSelectedId} + onSend={sendMessage} + onUpdateSession={updateSessionMetadata} + /> + ) : null} + {view === "agents" ? ( + void refreshAgents()} + onRetrieve={retrieveAgent} + onStartSession={openSessionSetup} + onUpdate={updateAgent} + /> + ) : null} + {view === "vaults" && vaultSupported === true ? ( + + ) : null} + {view === "system" ? ( + + ) : null} +
+
+ + setConnectionOpen(false)} + onSave={applyConnection} + /> +
+ ); +} diff --git a/apps/web/src/components/ConnectionModal.css b/apps/web/src/components/ConnectionModal.css new file mode 100644 index 000000000..c48b60cf3 --- /dev/null +++ b/apps/web/src/components/ConnectionModal.css @@ -0,0 +1,432 @@ +.connection-modal-modes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + padding: 0; + gap: 8px; + border: 0; +} + +.connection-modal-modes legend { + margin-bottom: 6px; + padding: 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 16px; +} + +.connection-modal-modes label { + display: flex; + min-width: 0; + padding: 9px 10px; + align-items: flex-start; + gap: 8px; + cursor: pointer; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-modes label.selected { + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); +} + +.connection-modal-modes input { + flex: 0 0 auto; + margin: 2px 0 0; + accent-color: var(--accent); +} + +.connection-modal-modes label > span { + display: flex; + min-width: 0; + flex-direction: column; +} + +.connection-modal-modes strong, +.connection-modal-modes small { + overflow-wrap: anywhere; +} + +.connection-modal-modes strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +.connection-modal-modes small { + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-modal-local { + margin-top: 12px; + padding: 12px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-local .connection-guide-header > svg { + color: var(--success); +} + +.connection-modal-local dl { + margin: 10px 0 0; +} + +.connection-modal-local dl > div { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + padding: 5px 0; + gap: 8px; + border-top: 1px solid var(--line); +} + +.connection-modal-local dt { + color: var(--fg-muted); +} + +.connection-modal-local dd { + min-width: 0; + margin: 0; + color: var(--fg); + overflow-wrap: anywhere; +} + +.connection-modal-local code { + padding: 1px 4px; + color: var(--fg); + font-family: var(--font-mono); + font-size: 11px; + background: var(--surface-muted); + border-radius: 4px; +} + +.connection-modal-local-note { + margin: 8px 0 0; +} + +.connection-docker-guide { + margin-top: 10px; + padding: 12px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-docker-guide .connection-guide-header > svg { + color: var(--accent); +} + +.connection-docker-path { + margin-top: 12px; + padding: 10px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-docker-path-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.connection-docker-path-heading strong { + color: var(--fg); + font-size: 12px; + font-weight: 550; +} + +.connection-docker-path-heading span { + color: var(--fg-muted); + font-size: 10px; +} + +.connection-docker-path-heading code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; +} + +.connection-docker-steps { + display: grid; + margin: 8px 0 0; + padding: 0; + gap: 10px; + counter-reset: docker-step; + list-style: none; +} + +.connection-docker-first-use > p { + margin: 8px 0 0; +} + +.connection-docker-first-steps { + display: grid; + margin: 9px 0 0; + padding-left: 20px; + gap: 8px; +} + +.connection-docker-first-steps > li { + padding-left: 2px; +} + +.connection-docker-first-steps > li > span { + display: grid; + gap: 1px; +} + +.connection-docker-first-steps strong { + color: var(--fg); + font-size: 11px; + font-weight: 550; +} + +.connection-docker-first-steps small { + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; +} + +.connection-docker-first-steps .connection-docker-command { + margin-top: 5px; +} + +.connection-docker-first-links { + display: flex; + margin-top: 10px; + align-items: flex-start; + flex-direction: column; + gap: 5px; +} + +.connection-docker-first-links a { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--accent-strong); + text-decoration: none; +} + +.connection-docker-first-links a:hover { + text-decoration: underline; +} + +.connection-docker-steps > li { + display: grid; + min-width: 0; + padding-top: 10px; + gap: 6px; + border-top: 1px solid var(--line); + counter-increment: docker-step; +} + +.connection-docker-steps > li > span { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 7px; +} + +.connection-docker-steps > li > span::before { + display: grid; + width: 18px; + height: 18px; + grid-row: 1 / span 2; + place-items: center; + color: var(--accent); + font-family: var(--font-mono); + font-size: 10px; + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + border-radius: 999px; + content: counter(docker-step); +} + +.connection-docker-steps strong, +.connection-docker-unconfigured strong { + color: var(--fg); + font-size: 12px; + font-weight: 550; +} + +.connection-docker-steps small { + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-docker-command { + display: flex; + min-width: 0; + align-items: center; + padding: 7px 7px 7px 9px; + gap: 8px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; +} + +.connection-docker-command code { + min-width: 0; + flex: 1; + overflow-x: auto; + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; + line-height: 15px; + white-space: nowrap; +} + +.connection-docker-command .button { + min-height: 26px; + flex: 0 0 auto; + padding: 3px 7px; + font-size: 10px; +} + +.connection-docker-unconfigured { + margin-top: 10px; + padding: 9px 10px; + background: var(--surface); + border: 1px dashed var(--line-strong); + border-radius: 5px; +} + +.connection-docker-unconfigured p, +.connection-docker-boundary { + margin: 3px 0 0; +} + +.connection-docker-unconfigured code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; +} + +.connection-docker-boundary { + padding-top: 9px; + border-top: 1px solid var(--line); +} + +.connection-modal-advanced { + margin-top: 12px; +} + +.connection-modal-test { + display: flex; + margin-top: 12px; + padding: 10px 0; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.connection-modal-test > div { + min-width: 0; +} + +.connection-modal-test strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; +} + +.connection-modal-test p { + margin: 2px 0 0; + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-modal-test .button { + flex: 0 0 auto; +} + +.connection-modal-probe-result { + margin-top: 10px; + padding: 9px 10px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-probe-result.succeeded { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); +} + +.connection-modal-probe-result.failed { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); +} + +.connection-modal-probe-result strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; +} + +.connection-modal-probe-result.failed strong { + color: var(--danger); +} + +.connection-modal-probe-result p { + margin: 2px 0 0; +} + +.connection-modal-probe-result small { + display: block; + margin-top: 4px; + color: var(--fg-muted); + font-size: 11px; +} + +@media (max-width: 520px) { + .connection-modal-modes { + grid-template-columns: 1fr; + } + + .connection-modal-test { + align-items: stretch; + flex-direction: column; + } + + .connection-modal-test .button { + width: 100%; + } + + .connection-modal-local dl > div { + grid-template-columns: 1fr; + gap: 2px; + } + + .connection-docker-command { + align-items: stretch; + flex-direction: column; + } + + .connection-docker-command .button { + width: 100%; + } + + .connection-docker-path-heading { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } +} diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx new file mode 100644 index 000000000..1e4d0cba6 --- /dev/null +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -0,0 +1,243 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { + ConnectionModal, + ConnectionProbeStatus, + type ConnectionProbeState, +} from "./ConnectionModal"; +import type { LocalDockerBackendGuideProfile } from "../lib/docker-guide-config"; + +const callbacks = { + onClose: () => undefined, + onSave: () => undefined, +}; + +function renderModal( + baseUrl: string, + proxyAuthEnabled: boolean, + token = "", + dockerBackendGuide: LocalDockerBackendGuideProfile | null = null, +) { + return renderToStaticMarkup( + , + ); +} + +describe("Agent Core connection modes", () => { + it("makes the fixed local Parsar path a zero-input default", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Local Parsar Core"); + expect(markup).toContain("Default · zero input"); + expect(markup).toMatch(/type="radio"[^>]*checked[^>]*value="local"/); + expect(markup).toContain("Local `/v1` proxy"); + expect(markup).toContain("No URL is required"); + expect(markup).not.toContain("Compatible Core base URL"); + expect(markup).not.toContain('type="password"'); + }); + + it("does not request or expose a browser bearer when proxy auth is active", () => { + const markup = renderModal("/v1", true, "stale-browser-token"); + + expect(markup).toContain("Server-managed key detected"); + expect(markup).toContain("No bearer credential is exposed to browser JavaScript"); + expect(markup).not.toContain("stale-browser-token"); + expect(markup).not.toContain("Bearer token"); + expect(markup).not.toContain("sessionStorage"); + }); + + it("keeps local mode token-free when server-managed auth is not detected", () => { + const markup = renderModal("/v1", false, "stale-browser-token"); + + expect(markup).toContain("Server-managed key not detected"); + expect(markup).toContain("Configure the local proxy token file and restart Web"); + expect(markup).toContain("Local mode will not request a browser token"); + expect(markup).not.toContain("stale-browser-token"); + expect(markup).not.toContain('type="password"'); + }); + + it("puts direct URLs, CORS guidance, and current-tab tokens behind Advanced", () => { + const markup = renderModal("http://127.0.0.1:8091/v1", true); + + expect(markup).toMatch(/type="radio"[^>]*checked[^>]*value="advanced"/); + expect(markup).toContain("Compatible Core base URL"); + expect(markup).toContain("through CORS"); + expect(markup).toContain('type="password"'); + expect(markup).toContain("Current tab only"); + expect(markup).toContain("sessionStorage, never localStorage"); + expect(markup).not.toContain("Server-managed key detected"); + }); + + it("rejects credentials and query data embedded in an Advanced URL", () => { + const markup = renderModal("https://core.example/v1?token=unsafe", true); + + expect(markup).toContain("an HTTP loopback URL, without credentials, query parameters, or fragments"); + expect(markup).toContain('aria-invalid="true"'); + expect(markup.match(/disabled=""/g)).toHaveLength(2); + }); + + it("rejects remote HTTP while explaining the HTTPS and loopback boundary", () => { + const markup = renderModal("http://core.example/v1", true, "must-not-leave-browser"); + + expect(markup).toContain("Remote Core access requires HTTPS"); + expect(markup).toContain("Plain HTTP is allowed only for an explicit loopback host"); + expect(markup).toContain('aria-invalid="true"'); + expect(markup.match(/disabled=""/g)).toHaveLength(2); + }); + + it("keeps credential tutorials in current monorepo operator links", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Operator-owned setup"); + expect(markup).toContain("Web connection guide · current repository"); + expect(markup).toContain("Connection troubleshooting · current repository"); + expect(markup).toContain("Parsar Core setup"); + expect(markup).toContain("parsar-core/blob/main/docs/web/core-connection.md"); + expect(markup).toContain("parsar-core/blob/main/services/agents-api/README.md"); + expect(markup).not.toContain("MiniMax-AI-Dev/parsar/blob"); + expect(markup).not.toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); + expect(markup).not.toContain("f7cdf591396529880d80f8211fc7a0f4768fdf46"); + expect(markup).not.toContain("8cc2898ca42b272cb3771234ee6a0ad0d2e932ba"); + expect(markup).not.toContain("7409e00ca25311805a9f8f0d03614f820e407642"); + expect(markup).not.toContain("6345391"); + expect(markup).not.toContain("bac551c"); + expect(markup).not.toContain("openssl rand"); + expect(markup).not.toContain("AGENTS_API_KEYS_FILE"); + expect(markup).not.toContain("AGENTS_API_DAEMON_WS_URL"); + }); + + it("renders a copyable, non-executing Docker backend recovery guide", () => { + const markup = renderModal("/v1", true, "", { + databaseContainer: "parsar-agents-api-web-smoke-db", + apiContainer: "agents-core-web-api", + daemonContainer: "agents-core-web-daemon", + corePort: 8091, + }); + + expect(markup).toContain("Start a local Docker backend"); + expect(markup).toContain("docker start parsar-agents-api-web-smoke-db"); + expect(markup).toContain("docker start agents-core-web-api agents-core-web-daemon"); + expect(markup).toContain("http://127.0.0.1:8091/healthz"); + expect(markup).toContain("Already set up on this computer"); + expect(markup).toContain("First time on this computer"); + expect(markup).toContain("make docker-build-agents-api"); + expect(markup).toContain("does not publish a safe zero-input bootstrap"); + expect(markup).toContain("Core container setup"); + expect(markup).toContain("Daemon provisioning"); + expect(markup).toContain(`/parsar-core/blob/main/services/agents-api/CONTAINER.md`); + expect(markup).toContain("Web only displays the reviewed commands"); + expect(markup).toContain("never receives Docker socket access or executes them"); + expect(markup).toContain("A self-hosted executor is Session-specific"); + expect(markup).not.toContain("docker run"); + expect(markup).not.toContain("docker compose down"); + }); + + it("fails closed to configuration help when container names are unavailable", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Docker startup guide is not configured for this Web build"); + expect(markup).toContain("AGENTS_CORE_WEB_DOCKER_BACKEND_*"); + expect(markup).not.toContain("docker start"); + }); + + it("states the GET-only probe boundary without disabling the current chat contract", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Test connection"); + expect(markup).toContain("with one GET"); + expect(markup).toContain("never creates an Agent, Session, Turn, or Item"); + expect(markup).toContain("Chat uses the current Core events contract"); + expect(markup).toContain("a real request can still fail"); + }); +}); + +describe("Connection probe status", () => { + it("announces the loading state", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="status"'); + expect(markup).toContain('aria-live="polite"'); + expect(markup).toContain("Testing Core connection…"); + expect(markup).toContain("one read-only Agents API GET request"); + }); + + it.each<[ConnectionProbeState, string]>([ + [ + { + status: "complete", + result: { kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }, + }, + "Core API authenticated", + ], + [ + { + status: "complete", + result: { kind: "invalid_configuration", executionReadiness: "unknown" }, + }, + "Core URL blocked", + ], + [ + { + status: "complete", + result: { kind: "unauthorized", executionReadiness: "unknown", httpStatus: 401 }, + }, + "401 invalid_api_key", + ], + [ + { + status: "complete", + result: { kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: 400 }, + }, + "Agents API protocol mismatch", + ], + [ + { + status: "complete", + result: { kind: "unreachable", executionReadiness: "unknown" }, + }, + "Core unreachable", + ], + [ + { + status: "complete", + result: { kind: "http_error", executionReadiness: "unknown", httpStatus: 503 }, + }, + "Core returned HTTP 503", + ], + ])("renders a safe, distinct terminal result", (state, expected) => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain(expected); + expect(markup).toContain("Chat uses the current Agents API contract"); + expect(markup).toContain("does not start a Turn or verify its runtime dependencies"); + }); + + it("uses an alert for failures and a polite status for authenticated access", () => { + const failed = renderToStaticMarkup( + , + ); + const succeeded = renderToStaticMarkup( + , + ); + + expect(failed).toContain('role="alert"'); + expect(succeeded).toContain('role="status"'); + }); +}); diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx new file mode 100644 index 000000000..a5a41f8c5 --- /dev/null +++ b/apps/web/src/components/ConnectionModal.tsx @@ -0,0 +1,464 @@ +import { Check, Container, Copy, ExternalLink, Info, KeyRound } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; + +import { + isLocalProxyBaseUrl, + isValidDirectCoreBaseUrl, + type CoreConnection, +} from "../lib/connection"; +import { + probeCore, + type CoreProbeResult, +} from "../lib/core-probe"; +import type { LocalDockerBackendGuideProfile } from "../lib/docker-guide-config"; +import { Modal } from "./Modal"; +import "./ConnectionModal.css"; + +interface ConnectionModalProps { + connection: CoreConnection; + open: boolean; + proxyAuthEnabled?: boolean; + dockerBackendGuide?: LocalDockerBackendGuideProfile | null; + onClose: () => void; + onSave: (connection: CoreConnection) => void; +} + +type ConnectionMode = "local" | "advanced"; + +export type ConnectionProbeState = + | { status: "idle" } + | { status: "loading" } + | { status: "complete"; result: CoreProbeResult }; + +const operatorGuideUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/docs/web/core-connection.md"; +const troubleshootingUrl = `${operatorGuideUrl}#troubleshooting`; +const parsarCoreSetupUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/services/agents-api/README.md#standalone-http-service"; +const parsarContainerSetupUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/services/agents-api/CONTAINER.md"; +const parsarDaemonSetupUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/services/agents-api/README.md#internal-execution-device-connection"; + +function DockerCommand({ label, command }: { label: string; command: string }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + if (!navigator.clipboard) return; + try { + await navigator.clipboard.writeText(command); + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + } catch { + setCopied(false); + } + }; + + return ( +
+ {command} + +
+ ); +} + +function DockerBackendGuide({ profile }: { profile: LocalDockerBackendGuideProfile | null }) { + return ( +
+
+
+ {profile ? ( + <> +
+
+ Already set up on this computer + Start the existing containers +
+
    +
  1. + Start the dedicated databaseWait until its Docker health status is healthy. + +
  2. +
  3. + Start Core API and daemonThe daemon reconnects to Core independently. + +
  4. +
  5. + Verify Core process healthA successful health response is liveness only; use Test connection next. + +
  6. +
+
+
+
+ First time on this computer + Create Core before trying docker start +
+

+ Docker is assumed to be installed. Parsar Core still requires a dedicated PostgreSQL database, a private + caller principal, migrations, the Core API container, and a provisioned daemon profile. Core does not + publish a safe zero-input bootstrap, so Web will not invent credentials or + create containers with guessed settings. +

+
    +
  1. + Build the Core imageRun from the reviewed parsar-core checkout. + +
  2. +
  3. Create the private Core stackPrepare the dedicated database, caller key files, migrations, and API container using the in-repository container guide.
  4. +
  5. Provision and connect the daemonIssue a separate device profile; caller keys and daemon credentials are not interchangeable.
  6. +
  7. Return here and testConfigure Web's server-side caller-key file, restart Web, then use Test connection.
  8. +
+ +
+ + ) : ( +
+ Docker startup guide is not configured for this Web build. +

+ Set the non-secret AGENTS_CORE_WEB_DOCKER_BACKEND_* values from .env.example, + then restart Web. Container names are operator configuration and are never guessed in the browser. +

+
+ )} +

+ Existing-stack commands only start saved database/Core/daemon containers. First-time setup remains an + operator action because it creates durable state and credentials. A self-hosted executor is Session-specific + and must be connected from that Session's Environment instructions. +

+
+ ); +} + +function initialMode(connection: CoreConnection): ConnectionMode { + return isLocalProxyBaseUrl(connection.baseUrl) ? "local" : "advanced"; +} + +function initialAdvancedDraft(connection: CoreConnection): CoreConnection { + const direct = !isLocalProxyBaseUrl(connection.baseUrl); + return { + baseUrl: direct ? connection.baseUrl : "", + token: direct ? connection.token : "", + }; +} + +function resultCopy(result: CoreProbeResult): { title: string; detail: string } { + switch (result.kind) { + case "authenticated": + return { + title: "Core API authenticated", + detail: "The read-only Agents API request returned a valid collection.", + }; + case "invalid_configuration": + return { + title: "Core URL blocked", + detail: "Remote Core URLs must use HTTPS. Plain HTTP is allowed only for an explicit loopback host.", + }; + case "unauthorized": + return { + title: "Authentication failed", + detail: "Core returned 401 invalid_api_key. Check the server-managed caller key or current-tab token.", + }; + case "protocol_mismatch": + return { + title: "Agents API protocol mismatch", + detail: "The endpoint did not accept the tested /v1/agents GET contract and OpenAI-Beta: agents=v1 header.", + }; + case "http_error": + return { + title: `Core returned HTTP ${result.httpStatus ?? "error"}`, + detail: "The basic Agents API read did not succeed. No Core resource was changed.", + }; + case "unreachable": + return { + title: "Core unreachable", + detail: "The browser could not reach Core. Check the local proxy target, network, or direct-mode CORS policy.", + }; + } +} + +export function ConnectionProbeStatus({ state }: { state: ConnectionProbeState }) { + if (state.status === "idle") return null; + if (state.status === "loading") { + return ( +
+ Testing Core connection… +

Sending one read-only Agents API GET request.

+
+ ); + } + + const copy = resultCopy(state.result); + const failed = state.result.kind !== "authenticated"; + return ( +
+ {copy.title} +

{copy.detail}

+ Chat uses the current Agents API contract. This read-only probe does not start a Turn or verify its runtime dependencies. +
+ ); +} + +export function ConnectionModal({ + connection, + open, + proxyAuthEnabled = import.meta.env.DEV && __AGENTS_CORE_WEB_DEV_PROXY_AUTH__, + dockerBackendGuide = __AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE__, + onClose, + onSave, +}: ConnectionModalProps) { + const modeName = useId(); + const [mode, setMode] = useState(() => initialMode(connection)); + const [advancedDraft, setAdvancedDraft] = useState(() => initialAdvancedDraft(connection)); + const [probeState, setProbeState] = useState({ status: "idle" }); + const probeControllerRef = useRef(undefined); + const probeRevisionRef = useRef(0); + const advancedUrlValid = isValidDirectCoreBaseUrl(advancedDraft.baseUrl); + const draft = mode === "local" + ? { baseUrl: "/v1", token: "" } + : { baseUrl: advancedDraft.baseUrl.trim(), token: advancedDraft.token.trim() }; + + useEffect(() => { + setMode(initialMode(connection)); + setAdvancedDraft(initialAdvancedDraft(connection)); + }, [connection, open]); + + useEffect(() => { + probeRevisionRef.current += 1; + probeControllerRef.current?.abort(); + probeControllerRef.current = undefined; + setProbeState({ status: "idle" }); + }, [advancedDraft.baseUrl, advancedDraft.token, mode, open, proxyAuthEnabled]); + + useEffect(() => () => probeControllerRef.current?.abort(), []); + + const testConnection = async () => { + const controller = new AbortController(); + probeControllerRef.current?.abort(); + probeControllerRef.current = controller; + const revision = ++probeRevisionRef.current; + setProbeState({ status: "loading" }); + + try { + const result = await probeCore({ + baseUrl: draft.baseUrl, + token: mode === "advanced" ? draft.token : undefined, + signal: controller.signal, + }); + if (probeRevisionRef.current !== revision || controller.signal.aborted) return; + probeControllerRef.current = undefined; + setProbeState({ status: "complete", result }); + } catch { + if (probeRevisionRef.current !== revision || controller.signal.aborted) return; + probeControllerRef.current = undefined; + setProbeState({ + status: "complete", + result: { kind: "unreachable", executionReadiness: "unknown" }, + }); + } + }; + + const testDisabled = probeState.status === "loading" || (mode === "advanced" && !advancedUrlValid); + const applyDisabled = mode === "advanced" && !advancedUrlValid; + + return ( + + + + + } + > +
+ Connection mode + + +
+ + {mode === "local" ? ( + <> +
+
+
+
+
+
API base
+
/v1
+
+
+
Authentication
+
{proxyAuthEnabled ? "Server-managed key detected" : "Server-managed key not detected"}
+
+
+

+ {proxyAuthEnabled + ? "The Vite proxy supplies its key server-side. No bearer credential is exposed to browser JavaScript." + : "Configure the local proxy token file and restart Web. Local mode will not request a browser token."} +

+
+ + + ) : ( +
+ + +
+ )} + +
+
+ Connection test +

Checks authenticated Agents API access with one GET. It never creates an Agent, Session, Turn, or Item.

+
+ +
+ + +
+
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/CreateMenu.test.tsx b/apps/web/src/components/CreateMenu.test.tsx new file mode 100644 index 000000000..13b7c0b13 --- /dev/null +++ b/apps/web/src/components/CreateMenu.test.tsx @@ -0,0 +1,25 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { CreateMenuContent } from "./CreateMenu"; + +describe("Create menu", () => { + it("shows only actions supported by the current Core contract", () => { + const html = renderToStaticMarkup( + undefined} + onStartSession={() => undefined} + />, + ); + + expect(html).toContain('role="menu"'); + expect(html).toContain("Agent"); + expect(html).toContain("Start Session"); + expect(html).not.toContain("Environment template"); + expect(html).not.toContain("Environment key"); + expect(html).not.toContain('aria-disabled="true"'); + expect(html).not.toContain("executor_token"); + }); +}); diff --git a/apps/web/src/components/CreateMenu.tsx b/apps/web/src/components/CreateMenu.tsx new file mode 100644 index 000000000..ee6774568 --- /dev/null +++ b/apps/web/src/components/CreateMenu.tsx @@ -0,0 +1,122 @@ +import { + Bot, + ChevronDown, + MessageSquare, + Plus, +} from "lucide-react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; + +interface CreateMenuActions { + canCreateAgent: boolean; + canStartSession: boolean; + onCreateAgent: () => void; + onStartSession: () => void; +} + +export function CreateMenuContent({ + canCreateAgent, + canStartSession, + onCreateAgent, + onStartSession, +}: CreateMenuActions) { + return ( +
+ + +
+ ); +} + +export function CreateMenu(actions: CreateMenuActions) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const closeOnOutsidePointer = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", closeOnOutsidePointer); + return () => document.removeEventListener("mousedown", closeOnOutsidePointer); + }, [open]); + + const focusMenuItem = (edge: "first" | "last") => { + window.requestAnimationFrame(() => { + const items = rootRef.current?.querySelectorAll('button[role="menuitem"]:not([disabled])'); + const item = edge === "first" ? items?.[0] : items?.[items.length - 1]; + item?.focus(); + }); + }; + + const onMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + return; + } + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + const items = Array.from( + rootRef.current?.querySelectorAll('button[role="menuitem"]:not([disabled])') ?? [], + ); + if (!items.length) return; + event.preventDefault(); + if (event.key === "Home") return void items[0]?.focus(); + if (event.key === "End") return void items[items.length - 1]?.focus(); + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const delta = event.key === "ArrowDown" ? 1 : -1; + items[(current + delta + items.length) % items.length]?.focus(); + }; + + return ( +
+ + {open ? ( + { + setOpen(false); + triggerRef.current?.focus(); + actions.onCreateAgent(); + }} + onStartSession={() => { + setOpen(false); + triggerRef.current?.focus(); + actions.onStartSession(); + }} + /> + ) : null} +
+ ); +} diff --git a/apps/web/src/components/ErrorState.tsx b/apps/web/src/components/ErrorState.tsx new file mode 100644 index 000000000..d52166e91 --- /dev/null +++ b/apps/web/src/components/ErrorState.tsx @@ -0,0 +1,48 @@ +import { AlertTriangle, RefreshCw } from "lucide-react"; +import type { ReactNode } from "react"; + +interface ErrorStateProps { + title: string; + description?: string; + detail?: string; + hint?: string; + onRetry?: () => void; + action?: ReactNode; + className?: string; +} + +/** Parsar's flat, durable error state for failures that must not fade away. */ +export function ErrorState({ + title, + description, + detail, + hint, + onRetry, + action, + className, +}: ErrorStateProps) { + return ( +
+
+
+ {onRetry || action ? ( +
+ {onRetry ? ( + + ) : null} + {action} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/MessageMarkdown.tsx b/apps/web/src/components/MessageMarkdown.tsx new file mode 100644 index 000000000..72e85d15b --- /dev/null +++ b/apps/web/src/components/MessageMarkdown.tsx @@ -0,0 +1,11 @@ +import ReactMarkdown from "react-markdown"; + +export function MessageMarkdown({ content }: { content: string }) { + return ( +
+

{children}

}}> + {content} +
+
+ ); +} diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx new file mode 100644 index 000000000..8bb76ea64 --- /dev/null +++ b/apps/web/src/components/Modal.tsx @@ -0,0 +1,127 @@ +import { X } from "lucide-react"; +import { useEffect, useId, useRef, useState, type PropsWithChildren, type ReactNode } from "react"; + +interface ModalProps extends PropsWithChildren { + open: boolean; + title: string; + footer?: ReactNode; + onClose: () => void; +} + +export function Modal({ open, title, footer, onClose, children }: ModalProps) { + const dialogRef = useRef(null); + const onCloseRef = useRef(onClose); + const previousFocusRef = useRef(null); + const wasOpenRef = useRef(false); + const titleId = useId(); + const [mounted, setMounted] = useState(open); + const [closing, setClosing] = useState(false); + onCloseRef.current = onClose; + + useEffect(() => { + if (open) { + if (!wasOpenRef.current) { + previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + wasOpenRef.current = true; + setMounted(true); + setClosing(false); + return; + } + wasOpenRef.current = false; + if (!mounted) return; + + setClosing(true); + const timer = window.setTimeout(() => { + setMounted(false); + setClosing(false); + }, 200); + return () => window.clearTimeout(timer); + }, [mounted, open]); + + useEffect(() => { + if (!open || !mounted) return; + + const dialog = dialogRef.current; + const focusableSelector = [ + "button:not([disabled])", + "input:not([disabled])", + "textarea:not([disabled])", + "select:not([disabled])", + "a[href]", + "[tabindex]:not([tabindex='-1'])", + ].join(","); + const frame = window.requestAnimationFrame(() => { + const preferred = dialog?.querySelector("input:not([disabled]), textarea:not([disabled]), select:not([disabled])"); + const first = preferred ?? dialog?.querySelector(focusableSelector) ?? dialog; + first?.focus(); + }); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab" || !dialog) return; + + const focusable = Array.from(dialog.querySelectorAll(focusableSelector)); + if (!focusable.length) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!dialog.contains(document.activeElement)) { + event.preventDefault(); + (event.shiftKey ? last : first)?.focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + window.cancelAnimationFrame(frame); + document.removeEventListener("keydown", handleKeyDown); + previousFocusRef.current?.focus(); + }; + }, [mounted, open]); + + if (!mounted) return null; + + return ( +
{ + if (!closing) onClose(); + }} + > +
event.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+ {footer ?
{footer}
: null} +
+
+ ); +} diff --git a/apps/web/src/components/ProductNavigation.test.tsx b/apps/web/src/components/ProductNavigation.test.tsx new file mode 100644 index 000000000..75029bc9d --- /dev/null +++ b/apps/web/src/components/ProductNavigation.test.tsx @@ -0,0 +1,31 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ProductNavigation } from "./ProductNavigation"; + +describe("Product navigation", () => { + it("exposes only the Core-backed product destinations and current page", () => { + const html = renderToStaticMarkup( + undefined} />, + ); + + expect(html).toContain('aria-label="Agents product"'); + expect(html).toContain('class="main-nav product-navigation"'); + expect(html).toContain("Workspace"); + expect(html).toContain("Dashboard"); + expect(html).toContain("Agents"); + expect(html).toContain("Sessions"); + expect(html).not.toContain("Vaults"); + expect(html).not.toContain("Environments"); + expect(html).toContain('aria-current="page"'); + }); + + it("shows Vaults only after Core capability discovery succeeds", () => { + const html = renderToStaticMarkup( + undefined} />, + ); + + expect(html).toContain("Vaults"); + expect(html).toContain('aria-current="page"'); + }); +}); diff --git a/apps/web/src/components/ProductNavigation.tsx b/apps/web/src/components/ProductNavigation.tsx new file mode 100644 index 000000000..75b672ee5 --- /dev/null +++ b/apps/web/src/components/ProductNavigation.tsx @@ -0,0 +1,42 @@ +import { Bot, LayoutDashboard, MessageSquare, Vault, type LucideIcon } from "lucide-react"; + +export type ProductView = "dashboard" | "agents" | "sessions" | "vaults"; + +const productViews: Array<{ id: ProductView; label: string; icon: LucideIcon }> = [ + { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, + { id: "agents", label: "Agents", icon: Bot }, + { id: "sessions", label: "Sessions", icon: MessageSquare }, + { id: "vaults", label: "Vaults", icon: Vault }, +]; + +export function ProductNavigation({ + active, + onSelect, + showVaults = false, +}: { + active: ProductView | null; + onSelect: (view: ProductView) => void; + showVaults?: boolean; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/Skeleton.tsx b/apps/web/src/components/Skeleton.tsx new file mode 100644 index 000000000..74376f9d0 --- /dev/null +++ b/apps/web/src/components/Skeleton.tsx @@ -0,0 +1,5 @@ +import type { HTMLAttributes } from "react"; + +export function Skeleton({ className, ...props }: HTMLAttributes) { + return
; +} diff --git a/apps/web/src/components/StatusIcon.tsx b/apps/web/src/components/StatusIcon.tsx new file mode 100644 index 000000000..930fab42f --- /dev/null +++ b/apps/web/src/components/StatusIcon.tsx @@ -0,0 +1,69 @@ +export type StatusKind = "queued" | "running" | "completed" | "failed" | "cancelled" | "interrupted"; + +interface StatusIconProps { + status: StatusKind; + title?: string; +} + +/** Parsar's 14px status signature. Status colour never leaves this glyph. */ +export function StatusIcon({ status, title }: StatusIconProps) { + const common = { + className: `status-icon status-${status}`, + viewBox: "0 0 14 14", + "aria-hidden": title ? undefined : true, + role: title ? "img" : undefined, + } as const; + const label = title ? {title} : null; + + switch (status) { + case "queued": + return ( + + {label} + + + ); + case "running": + return ( + + {label} + + + + + + ); + case "completed": + return ( + + {label} + + + + ); + case "failed": + return ( + + {label} + + + + ); + case "cancelled": + return ( + + {label} + + + + ); + case "interrupted": + return ( + + {label} + + + + ); + } +} diff --git a/apps/web/src/components/ThemeMenu.tsx b/apps/web/src/components/ThemeMenu.tsx new file mode 100644 index 000000000..cef499725 --- /dev/null +++ b/apps/web/src/components/ThemeMenu.tsx @@ -0,0 +1,32 @@ +import { Moon, Sun } from "lucide-react"; + +import { useTheme, type ResolvedTheme } from "../lib/theme"; + +export function ThemeMenu() { + const { resolvedTheme, setPreference } = useTheme(); + const segments: Array<{ value: ResolvedTheme; Icon: typeof Sun; label: string }> = [ + { value: "light", Icon: Sun, label: "Light theme" }, + { value: "dark", Icon: Moon, label: "Dark theme" }, + ]; + + return ( +
+ {segments.map(({ value, Icon, label }) => { + const pressed = resolvedTheme === value; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/Toast.test.ts b/apps/web/src/components/Toast.test.ts new file mode 100644 index 000000000..e0c11d0d7 --- /dev/null +++ b/apps/web/src/components/Toast.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { enqueueToast, type ToastItem } from "./Toast"; + +describe("Parsar toast queue", () => { + it("refreshes an identical keyed toast and advances its timer revision", () => { + const first = enqueueToast([], 1, "Agent created.", { + tone: "success", + key: "success:Agent created.", + }); + const refreshed = enqueueToast(first, 2, "Agent created.", { + tone: "success", + key: "success:Agent created.", + }); + + expect(refreshed).toHaveLength(1); + expect(refreshed[0]).toMatchObject({ + id: 1, + revision: 2, + leaving: false, + message: "Agent created.", + }); + }); + + it("keeps at most three transient toasts live while the oldest exits", () => { + let queue: ToastItem[] = []; + for (let id = 1; id <= 4; id += 1) { + queue = enqueueToast(queue, id, `Notice ${id}`); + } + + expect(queue.filter((toast) => !toast.leaving)).toHaveLength(3); + expect(queue.find((toast) => toast.id === 1)?.leaving).toBe(true); + }); + + it("revives a keyed toast that was already leaving", () => { + const leaving = enqueueToast([], 1, "Connection failed.", { + tone: "error", + key: "error:Connection failed.", + }).map((toast) => ({ ...toast, leaving: true })); + + const revived = enqueueToast(leaving, 2, "Connection failed.", { + tone: "error", + key: "error:Connection failed.", + }); + + expect(revived).toHaveLength(1); + expect(revived[0]).toMatchObject({ id: 1, revision: 2, leaving: false }); + }); +}); diff --git a/apps/web/src/components/Toast.tsx b/apps/web/src/components/Toast.tsx new file mode 100644 index 000000000..f2466938a --- /dev/null +++ b/apps/web/src/components/Toast.tsx @@ -0,0 +1,237 @@ +/* eslint-disable react-refresh/only-export-components */ +import { AlertTriangle, Info, X } from "lucide-react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { StatusIcon } from "./StatusIcon"; + +export type ToastTone = "info" | "success" | "warning" | "error"; + +export interface ToastOptions { + tone?: ToastTone; + detail?: string; + action?: ReactNode; + persist?: boolean; + /** The same key updates and revives the existing toast. */ + key?: string; +} + +export interface ToastItem extends ToastOptions { + id: number; + message: ReactNode; + durationMs: number; + leaving: boolean; + /** Changes on every keyed update, even when the message text is identical. */ + revision: number; +} + +export type ShowToast = (message: ReactNode, options?: ToastOptions) => void; + +interface ToastContextValue { + show: ShowToast; + dismiss: (key: string) => void; +} + +const ToastContext = createContext(null); +const NO_PROVIDER: ToastContextValue = { show: () => undefined, dismiss: () => undefined }; + +const DEFAULT_MS = 4_000; +const ERROR_MS = 7_000; +const MAX_VISIBLE = 3; +const EXIT_FALLBACK_MS = 250; + +export function useToast(): ToastContextValue { + return useContext(ToastContext) ?? NO_PROVIDER; +} + +/** @internal Exported for the focused queue regression tests. */ +export function enqueueToast( + list: ToastItem[], + id: number, + message: ReactNode, + options: ToastOptions = {}, +): ToastItem[] { + const tone = options.tone ?? "success"; + const fields = { + message, + tone, + detail: options.detail, + action: options.action, + persist: options.persist, + key: options.key, + durationMs: tone === "error" ? ERROR_MS : DEFAULT_MS, + leaving: false, + }; + const sameKey = options.key ? list.find((toast) => toast.key === options.key) : undefined; + + if (sameKey) { + return list.map((toast) => ( + toast.id === sameKey.id + ? { ...toast, ...fields, revision: toast.revision + 1 } + : toast + )); + } + + // Parsar lets the oldest transient toast finish its exit rather than + // removing it abruptly. Persistent prompts are never selected for eviction. + const live = list.filter((toast) => !toast.leaving && !toast.persist); + const excess = live.slice(0, Math.max(0, live.length - (MAX_VISIBLE - 1))); + const next = list.map((toast) => ( + excess.some((candidate) => candidate.id === toast.id) + ? { ...toast, leaving: true } + : toast + )); + + return [...next, { id, ...fields, revision: 1 }]; +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(1); + + const drop = useCallback((id: number) => { + setToasts((list) => list.filter((toast) => toast.id !== id)); + }, []); + + const startLeaving = useCallback((id: number) => { + setToasts((list) => list.map((toast) => ( + toast.id === id ? { ...toast, leaving: true } : toast + ))); + }, []); + + const show = useCallback((message, options = {}) => { + const id = nextId.current; + nextId.current += 1; + setToasts((list) => enqueueToast(list, id, message, options)); + }, []); + + const dismiss = useCallback((key: string) => { + setToasts((list) => list.map((toast) => ( + toast.key === key ? { ...toast, leaving: true } : toast + ))); + }, []); + + const value = useMemo(() => ({ show, dismiss }), [dismiss, show]); + const assertive = toasts.filter((toast) => toast.tone === "error"); + const polite = toasts.filter((toast) => toast.tone !== "error"); + + return ( + + {children} + {typeof document !== "undefined" + ? createPortal( +
+
+ {assertive.map((toast) => ( + + ))} +
+
+ {polite.map((toast) => ( + + ))} +
+
, + document.body, + ) + : null} +
+ ); +} + +function ToastStrip({ + toast, + onLeave, + onDrop, +}: { + toast: ToastItem; + onLeave: (id: number) => void; + onDrop: (id: number) => void; +}) { + const [focused, setFocused] = useState(false); + const remaining = useRef(toast.durationMs); + const startedAt = useRef(0); + + // A keyed toast may be shown again with exactly the same text. Revision is + // therefore the signal that this reader-visible countdown must restart. + useEffect(() => { + remaining.current = toast.durationMs; + }, [toast.durationMs, toast.revision]); + + useEffect(() => { + if (toast.persist || toast.leaving || focused) return; + startedAt.current = Date.now(); + const timer = window.setTimeout(() => onLeave(toast.id), remaining.current); + return () => { + window.clearTimeout(timer); + remaining.current = Math.max(0, remaining.current - (Date.now() - startedAt.current)); + }; + }, [focused, onLeave, toast.durationMs, toast.id, toast.leaving, toast.persist, toast.revision]); + + useEffect(() => { + if (!toast.leaving) return; + const timer = window.setTimeout(() => onDrop(toast.id), EXIT_FALLBACK_MS); + return () => window.clearTimeout(timer); + }, [onDrop, toast.id, toast.leaving]); + + const tone = toast.tone ?? "success"; + + return ( +
setFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); + }} + onAnimationEnd={(event) => { + if (toast.leaving && event.target === event.currentTarget) onDrop(toast.id); + }} + > +
+
+ {tone === "success" ? ( + + ) : tone === "error" ? ( +
+ {toast.action} + {!toast.persist ? ( + + ) : null} +
+ {toast.detail ?
{toast.detail}
: null} +
+ ); +} diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx new file mode 100644 index 000000000..e6821aed7 --- /dev/null +++ b/apps/web/src/features/CoreCollectionStates.test.tsx @@ -0,0 +1,489 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { AgentSession, SavedAgent, SessionItem } from "@agents-core-web/agents-client"; + +import { AgentsView } from "./agents/AgentsView"; +import { restoreDraftAfterFailedSend, SessionsView } from "./sessions/SessionsView"; + +const agentsCallbacks = { + onCreate: async () => undefined, + onRefresh: () => undefined, + onStartSession: async () => undefined, +}; + +const sessionsCallbacks = { + onCancel: async () => undefined, + onCreateSession: async () => undefined, + onDeleteSession: async () => true, + onFunctionResult: async () => undefined, + onRefresh: () => undefined, + onRetrySession: () => undefined, + onRetryStream: () => undefined, + onRetrieveSession: async () => undefined, + onSelect: () => undefined, + onSend: async () => undefined, + onUpdateSession: async () => undefined, +}; + +const selectedSession: AgentSession = { + id: "session_1", + object: "agent.session", + agent: { + id: "agent_1", + model: "gpt-5", + name: "Researcher", + instructions: null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + }, + environment: { type: "none" }, + status: "idle", + error: null, + metadata: {}, + required_actions: [], + vault_ids: [], + usage: null, + created_at: 1, + last_active_at: 1, +}; + +const durableItems: SessionItem[] = [{ + id: "item_1", + turn_id: "turn_1", + type: "message", + status: "completed", + role: "user", + content: [{ type: "input_text", text: "Existing durable item" }], +}]; + +const savedAgent: SavedAgent = { + ...selectedSession.agent, + object: "agent", + metadata: {}, + created_at: 1, + updated_at: 1, +}; + +describe("Agent Core collection states", () => { + it("restores a failed message only when it cannot overwrite a newer draft", () => { + expect(restoreDraftAfterFailedSend("", "failed message")).toBe("failed message"); + expect(restoreDraftAfterFailedSend("new draft", "failed message")).toBe("new draft"); + }); + + it("keeps an Agents load failure distinct from a ready empty collection", () => { + const failed = renderToStaticMarkup( + , + ); + const empty = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Couldn’t load Agents"); + expect(failed).toContain("connection refused"); + expect(failed).not.toContain("No saved Agents"); + expect(empty).toContain("No saved Agents"); + }); + + it("keeps a stale Agent ledger visible when its collection refresh fails", () => { + const failed = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Couldn’t refresh Agents"); + expect(failed).toContain("agents refresh failed"); + expect(failed).toContain('aria-label="Agents"'); + expect(failed).toContain("Researcher"); + }); + + it("shows composer activity only for a Core-reported in-progress Session", () => { + const renderStatus = (status: AgentSession["status"], busy = false) => renderToStaticMarkup( + , + ); + + const active = renderStatus("in_progress"); + expect(active).toContain('class="conversation-activity"'); + expect(active).toContain('role="status" aria-live="polite" aria-atomic="true"'); + expect(active).toContain("Researcher is working…"); + expect(renderStatus("idle", true)).not.toContain("conversation-activity"); + expect(renderStatus("requires_action")).not.toContain("conversation-activity"); + expect(renderStatus("failed")).not.toContain("conversation-activity"); + }); + + it("keeps Sessions loading and failure distinct from ready empty copy", () => { + const common = { + agents: [], + sessions: [], + selected: null, + items: [], + busy: false, + detailError: null, + detailState: "idle" as const, + streamError: null, + streamState: "idle" as const, + ...sessionsCallbacks, + }; + const loading = renderToStaticMarkup( + , + ); + const failed = renderToStaticMarkup( + , + ); + const empty = renderToStaticMarkup( + , + ); + + expect(loading).toContain("Loading Sessions"); + expect(loading).not.toContain("No Sessions yet"); + expect(failed).toContain("Couldn’t load Sessions"); + expect(failed).not.toContain("Select or create a Session"); + expect(empty).toContain("No Sessions yet"); + expect(empty).toContain("Select or create a Session"); + }); + + it("keeps stale Sessions and a healthy selected workspace usable after collection refresh fails", () => { + const failed = renderToStaticMarkup( + , + ); + const composer = failed.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0] ?? ""; + + expect(failed).toContain("Couldn’t refresh Sessions"); + expect(failed).toContain("sessions refresh failed"); + expect(failed).toContain("Existing durable item"); + expect(failed).toContain("Live events"); + expect(composer).not.toContain("disabled"); + }); + + it("does not claim an empty timeline before the selected Session load succeeds", () => { + const common = { + agents: [], + sessions: [selectedSession], + selected: selectedSession, + items: [], + busy: false, + coreError: null, + coreState: "ready" as const, + streamError: null, + streamState: "connecting" as const, + ...sessionsCallbacks, + }; + const loading = renderToStaticMarkup( + , + ); + const failed = renderToStaticMarkup( + , + ); + const ready = renderToStaticMarkup( + , + ); + + expect(loading).toContain("Loading Session timeline"); + expect(loading).not.toContain("Session is ready"); + expect(failed).toContain("Couldn’t load this Session"); + expect(failed).toContain("items request failed"); + expect(failed).toContain("Retry"); + expect(failed).not.toContain("Session is ready"); + expect(ready).toContain("Session is ready"); + expect(ready).toContain("Message execution also requires a Core worker and executor."); + }); + + it("keeps durable Items visible beside refresh and terminal Session failures", () => { + const refreshFailed = renderToStaticMarkup( + , + ); + const sessionFailed = renderToStaticMarkup( + , + ); + + expect(refreshFailed).toContain("Existing durable item"); + expect(refreshFailed).toContain("refresh failed"); + expect(refreshFailed).toContain("last loaded Items remain visible"); + expect(sessionFailed).toContain("Latest attempt failed"); + expect(sessionFailed).toContain("You can send another message to start a new Turn in this Session."); + expect(sessionFailed).toContain("model execution failed"); + expect(sessionFailed).not.toContain("Session is ready"); + expect(sessionFailed.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0]).not.toContain("disabled"); + expect(sessionFailed).not.toContain("This Session has failed"); + }); + + it("blocks new messages only when the selected Session has a proven terminal Environment", () => { + const environmentId = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Session cannot continue"); + expect(html).toContain("Start a new Session to continue"); + expect(html.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0]).toContain("disabled"); + }); + + it.each([ + ["401", "A valid key is required."], + ["404", "The Session event stream was not found."], + ])("keeps an events %s rejection visible with an explicit stream retry", (_status, streamError) => { + const failed = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Couldn’t open live events"); + expect(failed).toContain(streamError); + expect(failed).toContain("Retry"); + expect(failed).not.toContain("Session is ready"); + expect(failed).not.toContain("Opening the event stream"); + }); + + it("keeps execution-unavailable message failures visible without claiming a retry", () => { + const failed = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Core execution is unavailable"); + expect(failed).toContain("Execution is not enabled on this service."); + expect(failed).toContain("Your draft was restored and was not retried."); + expect(failed).toContain("operator runtime configuration"); + expect(failed).toContain("Core runtime setup"); + expect(failed).toContain("/parsar-core/blob/main/services/agents-api/README.md#public-text-execution"); + expect(failed).not.toContain("AGENTS_API_DAEMON_WS_URL"); + }); + + it("renders Environment connection actions separately from simultaneous function results", () => { + const waiting = renderToStaticMarkup( + , + ); + + expect(waiting).toContain('aria-label="Environment connection required"'); + expect(waiting).toContain('aria-haspopup="dialog"'); + expect(waiting).toContain('aria-label="Connect environment"'); + expect(waiting).toContain("Live events"); + expect(waiting).not.toContain('aria-label="Environment and Workspace status"'); + expect(waiting).toContain("environment_01"); + expect(waiting).toContain("must be connected by the Core operator"); + expect(waiting).toContain('aria-label="Function result required"'); + expect(waiting).toContain('aria-label="Function result or error"'); + expect(waiting.match(/aria-label="Function result or error"/g)).toHaveLength(1); + expect(waiting).toContain("Structured result"); + expect(waiting).toContain("1 / 2"); + expect(waiting).toContain('title="lookup"'); + expect(waiting).not.toContain('title="summarize"'); + expect(waiting).toContain("This Web cannot connect, complete, or approve it."); + }); + + it("blocks the composer for unknown, malformed, or missing required actions", () => { + for (const requiredActions of [ + [{ type: "future_approval", credential: "must-not-render" }], + [{ type: "function_call", name: "missing identity" }], + [ + { type: "future_approval" }, + { type: "function_call", call_id: "call", turn_id: "turn", name: "valid_but_blocked", arguments: {} }, + ], + [], + null, + ]) { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-label="Unsupported required action"'); + expect(html).toContain("will not infer a form or continue the Session"); + expect(html).not.toContain('aria-label="Message the Agent"'); + expect(html).not.toContain('aria-label="Function result or error"'); + expect(html).toContain('aria-label="Cancel active Turn"'); + expect(html).not.toContain("must-not-render"); + } + }); +}); diff --git a/apps/web/src/features/agents/AgentCatalog.css b/apps/web/src/features/agents/AgentCatalog.css new file mode 100644 index 000000000..b42590015 --- /dev/null +++ b/apps/web/src/features/agents/AgentCatalog.css @@ -0,0 +1,263 @@ +.agents-catalog-scroll { + min-width: 0; + min-height: 0; + flex: 1; + padding: 24px; + overflow: auto; +} + +.agent-catalog-section { + display: grid; + gap: 14px; +} + +.agent-catalog-section + .agent-catalog-section { + margin-top: 34px; +} + +.agent-catalog-section > h2, +.agent-catalog-section > header h2 { + margin: 0; + color: var(--fg); + font-size: 18px; + font-weight: 600; + line-height: 24px; +} + +.agent-catalog-section > header p { + margin: 3px 0 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; +} + +.agent-card-grid, +.agent-template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(245px, 100%), 1fr)); + gap: 12px; +} + +.agent-catalog-card { + min-width: 0; + min-height: 190px; + color: var(--fg); + text-align: left; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: var(--shadow-control); + transition: background-color 150ms var(--ease-settle), border-color 150ms var(--ease-settle), transform 150ms var(--ease-settle); +} + +.agent-catalog-slot, +.agent-catalog-slot > .agent-catalog-card { + height: 100%; +} + +button.agent-catalog-card, +.agent-card-main { + cursor: pointer; +} + +.agent-catalog-card:hover:not(:disabled), +.agent-catalog-card:focus-within { + background: var(--hover); + border-color: var(--line-strong); +} + +.agent-catalog-card:focus-visible, +.agent-card-main:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; +} + +.agent-catalog-card:disabled, +.agent-card-main:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.agent-card-main, +button.agent-catalog-card { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + align-items: flex-start; + padding: 18px; + color: inherit; + text-align: left; +} + +.agent-card-main { + background: transparent; + border: 0; +} + +.agent-saved-card { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.agent-saved-card .agent-card-main { + flex: 1; +} + +.agent-card-icon { + display: inline-flex; + width: 34px; + height: 34px; + flex: 0 0 34px; + align-items: center; + justify-content: center; + margin-bottom: 18px; + color: var(--fg); + background: var(--tile); + border: 1px solid var(--line); + border-radius: 9px; +} + +.agent-catalog-card > strong, +.agent-card-main > strong { + display: block; + max-width: 100%; + overflow: hidden; + font-size: 15px; + font-weight: 550; + line-height: 21px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-card-description { + display: -webkit-box; + min-height: 38px; + margin-top: 5px; + overflow: hidden; + color: var(--fg-muted); + font-size: 12px; + line-height: 18px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.agent-card-meta { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + margin-top: auto; + padding-top: 16px; + gap: 8px; + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; +} + +.agent-card-meta code { + min-width: 0; + flex: 1; + overflow: hidden; + color: inherit; + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-card-opening { + margin-top: 8px; + color: var(--accent); + font-size: 11px; +} + +.agent-saved-card > footer { + display: flex; + min-height: 44px; + align-items: center; + justify-content: flex-end; + padding: 8px 12px; + border-top: 1px solid var(--line); +} + +.agent-create-card, +.agent-template-card { + border-style: dashed; + box-shadow: none; +} + +.agent-create-card .agent-card-icon { + color: var(--surface); + background: var(--accent); + border-color: transparent; +} + +.agent-template-card .agent-card-icon { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + border-color: color-mix(in srgb, var(--accent) 24%, var(--line)); +} + +.agent-catalog-empty { + display: flex; + align-items: center; + padding: 12px 14px; + gap: 8px; + color: var(--fg-muted); + font-size: 12px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 7px; +} + +.agent-catalog-empty strong { + color: var(--fg); + font-weight: 500; +} + +.agent-catalog-empty .button { + margin-left: auto; +} + +.agent-catalog-more { + display: flex; + min-height: 36px; + align-items: center; + justify-content: flex-end; + gap: 10px; + color: var(--fg-muted); + font-size: 11px; +} + +.agent-catalog-more .button { + gap: 5px; +} + +@media (max-width: 640px) { + .agents-catalog-scroll { + padding: 16px 12px 28px; + } + + .agent-card-grid, + .agent-template-grid { + grid-template-columns: minmax(0, 1fr); + } + + .agent-catalog-section + .agent-catalog-section { + margin-top: 28px; + } + + .agent-catalog-empty { + align-items: flex-start; + flex-direction: column; + } + + .agent-catalog-empty .button { + margin-left: 0; + } + + .agent-catalog-more { + justify-content: space-between; + } +} diff --git a/apps/web/src/features/agents/AgentCatalog.test.tsx b/apps/web/src/features/agents/AgentCatalog.test.tsx new file mode 100644 index 000000000..b8379e7dd --- /dev/null +++ b/apps/web/src/features/agents/AgentCatalog.test.tsx @@ -0,0 +1,72 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { SavedAgent } from "@agents-core-web/agents-client"; + +import { AgentCatalog, twoRowSavedAgentCapacity } from "./AgentCatalog"; + +function savedAgent(index: number): SavedAgent { + return { + id: `agent_${index}`, + object: "agent", + model: "provider/model", + name: `Agent ${index}`, + instructions: `Instructions ${index}`, + metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + created_at: 1_700_000_000 + index, + updated_at: 1_700_000_100 + index, + }; +} + +function renderCatalog(expanded = false, isFiltering = false): string { + return renderToStaticMarkup( + savedAgent(index))} + busy={false} + coreReady + expanded={expanded} + hasSavedAgents + isFiltering={isFiltering} + openingAgentId={null} + vaultCatalog={null} + onClearSearch={() => undefined} + onCreate={() => undefined} + onEdit={() => undefined} + onExpandedChange={() => undefined} + onStartSession={() => undefined} + onUseTemplate={() => undefined} + />, + ); +} + +describe("Agent catalog", () => { + it("reserves the first card for Create and bounds saved Agents to two responsive rows", () => { + expect(twoRowSavedAgentCapacity(1)).toBe(1); + expect(twoRowSavedAgentCapacity(2)).toBe(3); + expect(twoRowSavedAgentCapacity(3)).toBe(5); + expect(twoRowSavedAgentCapacity(4)).toBe(7); + + const html = renderCatalog(); + expect(html.indexOf("Create agent")).toBeLessThan(html.indexOf("Edit Agent 0")); + expect(html).toContain("Edit Agent 0 (agent_0)"); + expect(html).not.toContain("Edit Agent 1 (agent_1)"); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("Show 3 more"); + }); + + it("shows every saved Agent after expansion or while filtering", () => { + const expanded = renderCatalog(true); + expect(expanded).toContain("Edit Agent 3 (agent_3)"); + expect(expanded).toContain('aria-expanded="true"'); + expect(expanded).toContain("Show less"); + + const filtered = renderCatalog(false, true); + expect(filtered).toContain("Edit Agent 3 (agent_3)"); + expect(filtered).not.toContain("Show 3 more"); + }); +}); diff --git a/apps/web/src/features/agents/AgentCatalog.tsx b/apps/web/src/features/agents/AgentCatalog.tsx new file mode 100644 index 000000000..515cbecd9 --- /dev/null +++ b/apps/web/src/features/agents/AgentCatalog.tsx @@ -0,0 +1,227 @@ +import { Bot, ChevronDown, ChevronUp, MessageSquare, PanelsTopLeft, Plus } from "lucide-react"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; + +import type { SavedAgent } from "@agents-core-web/agents-client"; + +import type { VaultCatalog } from "../vaults/vault-catalog"; +import { sessionAdmissionBlocker } from "./session-admission"; +import { AGENT_TEMPLATES, type AgentTemplate } from "./agent-templates"; + +import "./AgentCatalog.css"; + +const useIsomorphicLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect; + +export function twoRowSavedAgentCapacity(columnCount: number): number { + return Math.max(1, Math.floor(columnCount) * 2 - 1); +} + +function resolvedGridColumnCount(grid: HTMLElement): number { + const template = window.getComputedStyle(grid).gridTemplateColumns.trim(); + if (!template || template === "none") return 1; + return Math.max(1, template.split(/\s+/u).length); +} + +function formatShortDate(seconds: number): string { + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(new Date(seconds * 1000)); +} + +function AgentSessionStartAction({ + agent, + busy, + onStart, + vaultCatalog, +}: { + agent: SavedAgent; + busy: boolean; + onStart: (agentId: string) => void; + vaultCatalog: VaultCatalog | null; +}) { + const blocker = sessionAdmissionBlocker(agent, vaultCatalog); + const descriptionId = useId(); + return ( + + + {blocker ? ( + + Session unavailable: {blocker} + + ) : null} + + ); +} + +export function AgentCatalog({ + agents, + busy, + coreReady, + hasSavedAgents, + openingAgentId, + vaultCatalog, + onClearSearch, + onCreate, + onEdit, + onExpandedChange, + onStartSession, + onUseTemplate, + expanded, + isFiltering, +}: { + agents: SavedAgent[]; + busy: boolean; + coreReady: boolean; + expanded: boolean; + hasSavedAgents: boolean; + isFiltering: boolean; + openingAgentId: string | null; + vaultCatalog: VaultCatalog | null; + onClearSearch: () => void; + onCreate: (returnFocus: HTMLButtonElement) => void; + onEdit: (agent: SavedAgent, returnFocus: HTMLButtonElement) => void; + onExpandedChange: (expanded: boolean) => void; + onStartSession: (agentId: string) => void; + onUseTemplate: (template: AgentTemplate, returnFocus: HTMLButtonElement) => void; +}) { + const interactionDisabled = busy; + const gridId = useId(); + const gridRef = useRef(null); + const [columnCount, setColumnCount] = useState(1); + const savedCapacity = twoRowSavedAgentCapacity(columnCount); + const canExpand = !isFiltering && agents.length > savedCapacity; + const visibleAgents = isFiltering || expanded ? agents : agents.slice(0, savedCapacity); + const hiddenAgentCount = Math.max(0, agents.length - savedCapacity); + + useIsomorphicLayoutEffect(() => { + const grid = gridRef.current; + if (!grid) return; + + const updateColumnCount = () => { + const next = resolvedGridColumnCount(grid); + setColumnCount((current) => current === next ? current : next); + }; + updateColumnCount(); + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateColumnCount); + return () => window.removeEventListener("resize", updateColumnCount); + } + + const observer = new ResizeObserver(updateColumnCount); + observer.observe(grid); + return () => observer.disconnect(); + }, []); + + return ( +
+
+

Agents

+
+
+ +
+ {visibleAgents.map((agent) => ( +
+ +
+ +
+
+ ))} +
+ {canExpand ? ( +
+ {expanded ? `Showing all ${agents.length} saved Agents` : `${hiddenAgentCount} more saved ${hiddenAgentCount === 1 ? "Agent" : "Agents"}`} + +
+ ) : null} + {!agents.length ? ( +
+ {hasSavedAgents ? "No matching Agents" : "No saved Agents"} + {hasSavedAgents ? "Try another name, model, or ID." : "Start from a blank Agent or choose a starter template below."} + {hasSavedAgents ? : null} +
+ ) : null} +
+ +
+
+
+

Starter templates

+

Prompt starters only. Add and verify any tools separately.

+
+
+
+ {AGENT_TEMPLATES.map((template) => ( +
+ +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/features/agents/AgentDialog.tsx b/apps/web/src/features/agents/AgentDialog.tsx new file mode 100644 index 000000000..21a69ecca --- /dev/null +++ b/apps/web/src/features/agents/AgentDialog.tsx @@ -0,0 +1,9 @@ +import { createPortal } from "react-dom"; +import type { ComponentProps } from "react"; + +import { Modal } from "../../components/Modal"; + +export function AgentDialog(props: ComponentProps) { + const dialog =
; + return typeof document === "undefined" ? dialog : createPortal(dialog, document.body); +} diff --git a/apps/web/src/features/agents/AgentForm.tsx b/apps/web/src/features/agents/AgentForm.tsx new file mode 100644 index 000000000..2d5005796 --- /dev/null +++ b/apps/web/src/features/agents/AgentForm.tsx @@ -0,0 +1,355 @@ +import { Info } from "lucide-react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; + +import type { SavedAgent } from "@agents-core-web/agents-client"; + +import { + buildModelOptionGroups, + CUSTOM_MODEL_OPTION, + modelIdFromOption, + modelOptionValue, +} from "../../lib/model-options"; +import type { VaultCatalog } from "../vaults/vault-catalog"; +import { vaultName } from "../vaults/vault-catalog"; +import { type AgentFormSubmitInput, type AgentFormValues, type AgentToolDraft, validateAgentForm, valuesFromAgent } from "./agent-form"; + +interface AgentFormProps { + agent?: SavedAgent; + disabled?: boolean; + formId: string; + initialValues?: AgentFormValues; + knownModels: string[]; + vaultCatalog?: VaultCatalog | null; + onDraftChange?: (values: AgentFormValues) => void; + onSubmit: (input: AgentFormSubmitInput) => Promise; +} + +export function AgentForm({ agent, disabled = false, formId, initialValues, knownModels, vaultCatalog = null, onDraftChange, onSubmit }: AgentFormProps) { + const nameRef = useRef(null); + const initial = agent ? valuesFromAgent(agent, vaultCatalog) : initialValues ?? valuesFromAgent(undefined, vaultCatalog); + const options = buildModelOptionGroups( + knownModels, + import.meta.env.VITE_AGENT_MODEL_PRESETS, + initial.model || import.meta.env.VITE_AGENT_DEFAULT_MODEL, + ); + const initialIsSuggested = [...options.configured, ...options.previouslyUsed].includes(initial.model); + const [name, setName] = useState(initial.name); + const [modelChoice, setModelChoice] = useState( + initial.model && !initialIsSuggested ? CUSTOM_MODEL_OPTION : modelOptionValue(initial.model || options.defaultModel), + ); + const [customModel, setCustomModel] = useState(initialIsSuggested ? "" : initial.model); + const [instructions, setInstructions] = useState(initial.instructions); + const [metadata, setMetadata] = useState(initial.metadata); + const [reasoningEffort, setReasoningEffort] = useState(initial.reasoningEffort); + const [reasoningSummary, setReasoningSummary] = useState(initial.reasoningSummary); + const [serviceTier, setServiceTier] = useState(initial.serviceTier); + const [textFormat] = useState(initial.textFormat); + const [textVerbosity, setTextVerbosity] = useState(initial.textVerbosity); + const [tools, setTools] = useState(initial.tools); + const [toolsModified, setToolsModified] = useState(initial.toolsModified); + const [configurationError, setConfigurationError] = useState(null); + const [modelError, setModelError] = useState(null); + const [metadataError, setMetadataError] = useState(null); + const [nameError, setNameError] = useState(null); + const [toolsError, setToolsError] = useState(null); + const model = modelChoice === CUSTOM_MODEL_OPTION ? customModel : modelIdFromOption(modelChoice) ?? ""; + + const updateTools = (update: (current: AgentToolDraft[]) => AgentToolDraft[]) => { + setTools((current) => update(current)); + setToolsModified(true); + if (toolsError) setToolsError(null); + }; + + useEffect(() => { + const frame = window.requestAnimationFrame(() => nameRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, []); + + useEffect(() => { + onDraftChange?.({ + name, + model, + instructions, + metadata, + reasoningEffort, + reasoningSummary, + serviceTier, + textFormat, + textVerbosity, + tools, + toolsModified, + }); + }, [instructions, metadata, model, name, onDraftChange, reasoningEffort, reasoningSummary, serviceTier, textFormat, textVerbosity, tools, toolsModified]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const result = validateAgentForm({ + name, + model, + instructions, + metadata, + reasoningEffort, + reasoningSummary, + serviceTier, + textFormat, + textVerbosity, + tools, + toolsModified, + }, agent ? "update" : "create", vaultCatalog); + setConfigurationError(result.configurationError ?? null); + setModelError(result.modelError ?? null); + setMetadataError(result.metadataError ?? null); + setNameError(result.nameError ?? null); + setToolsError(result.toolsError ?? null); + if (!result.input) return; + await onSubmit(result.input); + }; + + return ( +
void submit(event)} noValidate> +
+ +